@aphrody/os 0.0.0-stage → 0.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.
- package/package.json +22 -4
- package/src/index.ts +299 -0
- package/src/systemd.ts +160 -0
- package/test/kernel.test.ts +80 -0
- package/test/systemd.test.ts +54 -0
- package/test/transport.test.ts +73 -0
- package/tsconfig.json +10 -0
- package/README.md +0 -3
package/package.json
CHANGED
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aphrody/os",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Bun client for the Aphrody OS kernel: join the OS as a subsystem, call any OS tool or skill.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "src/index.ts",
|
|
8
|
+
"types": "src/index.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.ts"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "bun build src/index.ts --target bun --packages external --outdir dist",
|
|
17
|
+
"lint": "oxlint src test",
|
|
18
|
+
"test": "bun test",
|
|
19
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"bun": ">=1.4.2"
|
|
23
|
+
}
|
|
24
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Bun client for the Aphrody OS kernel.
|
|
4
|
+
*
|
|
5
|
+
* A Bun process joins the OS as a subsystem with {@link registerSubsystem}: its tools are
|
|
6
|
+
* published to every agent next to the native ones and the kernel routes their calls back
|
|
7
|
+
* here. {@link KernelClient} also calls any OS tool or skill directly.
|
|
8
|
+
*
|
|
9
|
+
* Wire format: newline-delimited JSON-RPC 2.0 over the per-user local endpoint (Unix socket or
|
|
10
|
+
* Windows named pipe), the same one `aphrody-kernel` and the desktop serve.
|
|
11
|
+
*/
|
|
12
|
+
import { connect, type Socket } from "node:net";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
const MAX_MESSAGE_BYTES = 64 * 1024 * 1024;
|
|
16
|
+
|
|
17
|
+
/** The per-user endpoint, identical to `aphrody_kernel::transport::default_endpoint`. */
|
|
18
|
+
export function defaultEndpoint(): string {
|
|
19
|
+
const override = process.env.APHRODY_OS_SOCKET;
|
|
20
|
+
if (override) return override;
|
|
21
|
+
const user = (process.env.USERNAME ?? process.env.USER ?? "user").replace(/[^A-Za-z0-9_-]/g, "");
|
|
22
|
+
if (process.platform === "win32") return `\\\\.\\pipe\\aphrody-os-${user}`;
|
|
23
|
+
const runtime = process.env.XDG_RUNTIME_DIR;
|
|
24
|
+
return runtime ? join(runtime, "aphrody-os.sock") : `/tmp/aphrody-os-${user}.sock`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
|
|
28
|
+
interface Message {
|
|
29
|
+
jsonrpc?: string;
|
|
30
|
+
id?: string | number | null;
|
|
31
|
+
method?: string;
|
|
32
|
+
params?: Json;
|
|
33
|
+
result?: Json;
|
|
34
|
+
error?: { code: number; message: string };
|
|
35
|
+
}
|
|
36
|
+
export class KernelError extends Error {
|
|
37
|
+
constructor(
|
|
38
|
+
message: string,
|
|
39
|
+
readonly code: number,
|
|
40
|
+
) {
|
|
41
|
+
super(message);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A JSON-RPC connection to the kernel. */
|
|
46
|
+
export class KernelClient {
|
|
47
|
+
#socket: Socket;
|
|
48
|
+
#buffer = "";
|
|
49
|
+
#bufferBytes = 0;
|
|
50
|
+
#nextId = 1;
|
|
51
|
+
#pending = new Map<string, { resolve: (m: Message) => void; reject: (e: Error) => void }>();
|
|
52
|
+
#onRequest?: (message: Message) => Promise<Json>;
|
|
53
|
+
#onNotification?: (message: Message) => void;
|
|
54
|
+
readonly closed: Promise<void>;
|
|
55
|
+
|
|
56
|
+
private constructor(socket: Socket) {
|
|
57
|
+
this.#socket = socket;
|
|
58
|
+
socket.setEncoding("utf8");
|
|
59
|
+
socket.on("data", (chunk: string) => this.#receive(chunk));
|
|
60
|
+
socket.on("error", (error: Error) => {
|
|
61
|
+
for (const { reject } of this.#pending.values()) reject(error);
|
|
62
|
+
this.#pending.clear();
|
|
63
|
+
});
|
|
64
|
+
this.closed = new Promise((resolve) => {
|
|
65
|
+
socket.on("close", () => {
|
|
66
|
+
for (const { reject } of this.#pending.values())
|
|
67
|
+
reject(new Error("kernel connection closed"));
|
|
68
|
+
this.#pending.clear();
|
|
69
|
+
resolve();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Connect and perform the MCP handshake. */
|
|
75
|
+
static async connect(
|
|
76
|
+
endpoint = defaultEndpoint(),
|
|
77
|
+
clientName = "aphrody-os-bun",
|
|
78
|
+
timeoutMs = 30_000,
|
|
79
|
+
): Promise<KernelClient> {
|
|
80
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
|
|
81
|
+
throw new Error("connect timeout must be positive");
|
|
82
|
+
const socket = await new Promise<Socket>((resolve, reject) => {
|
|
83
|
+
const s = connect(endpoint);
|
|
84
|
+
const timer = setTimeout(
|
|
85
|
+
() => s.destroy(new Error("kernel connection timed out")),
|
|
86
|
+
timeoutMs,
|
|
87
|
+
);
|
|
88
|
+
s.once("connect", () => {
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
resolve(s);
|
|
91
|
+
});
|
|
92
|
+
s.once("error", (error) => {
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
reject(error);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
const client = new KernelClient(socket);
|
|
98
|
+
const initialize: { [key: string]: Json } = {
|
|
99
|
+
protocolVersion: "2025-06-18",
|
|
100
|
+
capabilities: {},
|
|
101
|
+
clientInfo: { name: clientName, version: "1.0.0" },
|
|
102
|
+
};
|
|
103
|
+
const authToken = process.env.APHRODY_OS_AUTH_TOKEN;
|
|
104
|
+
if (authToken) initialize.authToken = authToken;
|
|
105
|
+
try {
|
|
106
|
+
await client.request("initialize", initialize, timeoutMs);
|
|
107
|
+
client.notify("notifications/initialized", {});
|
|
108
|
+
return client;
|
|
109
|
+
} catch (error) {
|
|
110
|
+
client.close();
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
#send(message: Message): void {
|
|
116
|
+
this.#socket.write(`${JSON.stringify({ jsonrpc: "2.0", ...message })}\n`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
#receive(chunk: string): void {
|
|
120
|
+
let offset = 0;
|
|
121
|
+
while (offset < chunk.length) {
|
|
122
|
+
const newline = chunk.indexOf("\n", offset);
|
|
123
|
+
const end = newline < 0 ? chunk.length : newline + 1;
|
|
124
|
+
const part = chunk.slice(offset, end);
|
|
125
|
+
this.#bufferBytes += Buffer.byteLength(part, "utf8");
|
|
126
|
+
if (this.#bufferBytes > MAX_MESSAGE_BYTES) {
|
|
127
|
+
this.#socket.destroy(new Error("kernel message too large"));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
this.#buffer += part;
|
|
131
|
+
offset = end;
|
|
132
|
+
if (newline < 0) continue;
|
|
133
|
+
const line = this.#buffer.trim();
|
|
134
|
+
this.#buffer = "";
|
|
135
|
+
this.#bufferBytes = 0;
|
|
136
|
+
if (!line) continue;
|
|
137
|
+
let message: Message;
|
|
138
|
+
try {
|
|
139
|
+
message = JSON.parse(line) as Message;
|
|
140
|
+
} catch {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (message.method === undefined && message.id !== undefined) {
|
|
144
|
+
const waiter = this.#pending.get(String(message.id));
|
|
145
|
+
this.#pending.delete(String(message.id));
|
|
146
|
+
if (!waiter) continue;
|
|
147
|
+
if (message.error)
|
|
148
|
+
waiter.reject(new KernelError(message.error.message, message.error.code));
|
|
149
|
+
else waiter.resolve(message);
|
|
150
|
+
} else if (message.method !== undefined && message.id !== undefined && message.id !== null) {
|
|
151
|
+
const id = message.id;
|
|
152
|
+
const handle =
|
|
153
|
+
this.#onRequest ??
|
|
154
|
+
(async () => ({ content: [{ type: "text", text: "no handler" }], isError: true }));
|
|
155
|
+
void handle(message).then(
|
|
156
|
+
(result) => this.#send({ id, result }),
|
|
157
|
+
(error: unknown) => this.#send({ id, error: { code: -32603, message: String(error) } }),
|
|
158
|
+
);
|
|
159
|
+
} else if (message.method !== undefined) {
|
|
160
|
+
this.#onNotification?.(message);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Send a request; resolves with its `result`. */
|
|
166
|
+
request(method: string, params: Json = {}, timeoutMs = 0): Promise<Json> {
|
|
167
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 0)
|
|
168
|
+
return Promise.reject(new Error("request timeout must be nonnegative"));
|
|
169
|
+
if (this.#socket.destroyed) return Promise.reject(new Error("kernel connection closed"));
|
|
170
|
+
const id = String(this.#nextId++);
|
|
171
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
172
|
+
return new Promise<Message>((resolve, reject) => {
|
|
173
|
+
this.#pending.set(id, { resolve, reject });
|
|
174
|
+
if (timeoutMs > 0)
|
|
175
|
+
timer = setTimeout(() => {
|
|
176
|
+
this.#pending.delete(id);
|
|
177
|
+
reject(new Error(`kernel ${method} timed out`));
|
|
178
|
+
}, timeoutMs);
|
|
179
|
+
this.#send({ id, method, params });
|
|
180
|
+
})
|
|
181
|
+
.then((message) => message.result ?? null)
|
|
182
|
+
.finally(() => clearTimeout(timer));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
notify(method: string, params: Json = {}): void {
|
|
186
|
+
this.#send({ method, params });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Call an OS tool; resolves with its structured value, rejects when the tool failed. */
|
|
190
|
+
async call(tool: string, args: Record<string, Json> = {}, timeoutMs = 0): Promise<Json> {
|
|
191
|
+
const result = (await this.request(
|
|
192
|
+
"tools/call",
|
|
193
|
+
{ name: tool, arguments: args },
|
|
194
|
+
timeoutMs,
|
|
195
|
+
)) as Record<string, Json>;
|
|
196
|
+
const text = ((result.content as Array<Record<string, Json>>) ?? [])
|
|
197
|
+
.map((part) => part.text ?? "")
|
|
198
|
+
.join("\n");
|
|
199
|
+
if (result.isError) throw new KernelError(text, -32000);
|
|
200
|
+
const structured = result.structuredContent as Record<string, Json> | undefined;
|
|
201
|
+
if (structured)
|
|
202
|
+
return Object.keys(structured).length === 1 && "result" in structured
|
|
203
|
+
? structured.result
|
|
204
|
+
: structured;
|
|
205
|
+
return text;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
onRequest(handler: (message: Message) => Promise<Json>): void {
|
|
209
|
+
this.#onRequest = handler;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
onNotification(handler: (message: Message) => void): void {
|
|
213
|
+
this.#onNotification = handler;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
close(): void {
|
|
217
|
+
this.#socket.destroy();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export interface ToolSpec {
|
|
222
|
+
name: string;
|
|
223
|
+
description: string;
|
|
224
|
+
inputSchema?: Json;
|
|
225
|
+
readOnly?: boolean;
|
|
226
|
+
handler: (args: Record<string, Json>) => unknown | Promise<unknown>;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export interface SubsystemSpec {
|
|
230
|
+
name: string;
|
|
231
|
+
description?: string;
|
|
232
|
+
tools: ToolSpec[];
|
|
233
|
+
skills?: Json[];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function toCallResult(value: unknown): Json {
|
|
237
|
+
const json = (value ?? null) as Json;
|
|
238
|
+
const text = typeof json === "string" ? json : JSON.stringify(json, null, 2);
|
|
239
|
+
const structured =
|
|
240
|
+
json !== null && typeof json === "object" && !Array.isArray(json) ? json : { result: json };
|
|
241
|
+
return { content: [{ type: "text", text }], structuredContent: structured, isError: false };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Join the kernel as a subsystem. The returned client stays connected; closing it (or the
|
|
246
|
+
* process exiting) unregisters the subsystem.
|
|
247
|
+
*/
|
|
248
|
+
export async function registerSubsystem(
|
|
249
|
+
spec: SubsystemSpec,
|
|
250
|
+
endpoint = defaultEndpoint(),
|
|
251
|
+
): Promise<KernelClient> {
|
|
252
|
+
const client = await KernelClient.connect(endpoint, `bun/${spec.name}`);
|
|
253
|
+
const handlers = new Map(spec.tools.map((tool) => [tool.name, tool.handler]));
|
|
254
|
+
client.onRequest(async (message) => {
|
|
255
|
+
if (message.method === "ping") return {};
|
|
256
|
+
if (message.method !== "tools/call")
|
|
257
|
+
return { content: [{ type: "text", text: `unsupported: ${message.method}` }], isError: true };
|
|
258
|
+
const params = (message.params ?? {}) as Record<string, Json>;
|
|
259
|
+
const handler = handlers.get(String(params.name));
|
|
260
|
+
if (!handler)
|
|
261
|
+
return { content: [{ type: "text", text: `unknown tool: ${params.name}` }], isError: true };
|
|
262
|
+
try {
|
|
263
|
+
return toCallResult(await handler((params.arguments ?? {}) as Record<string, Json>));
|
|
264
|
+
} catch (error) {
|
|
265
|
+
return {
|
|
266
|
+
content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }],
|
|
267
|
+
isError: true,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
await client.request("system/register_subsystem", {
|
|
272
|
+
name: spec.name,
|
|
273
|
+
description: spec.description ?? `Bun subsystem ${spec.name}.`,
|
|
274
|
+
runtime: "bun",
|
|
275
|
+
tools: spec.tools.map((tool) => ({
|
|
276
|
+
name: tool.name,
|
|
277
|
+
description: tool.description,
|
|
278
|
+
inputSchema: tool.inputSchema ?? { type: "object" },
|
|
279
|
+
annotations: { readOnlyHint: tool.readOnly ?? false },
|
|
280
|
+
})),
|
|
281
|
+
skills: spec.skills ?? [],
|
|
282
|
+
});
|
|
283
|
+
return client;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export * as systemd from "./systemd.ts";
|
|
287
|
+
export {
|
|
288
|
+
formatJournal,
|
|
289
|
+
getWatchdogUsec,
|
|
290
|
+
journal,
|
|
291
|
+
notifyReady,
|
|
292
|
+
notifyStatus,
|
|
293
|
+
notifyStopping,
|
|
294
|
+
notifyWatchdog,
|
|
295
|
+
sdNotify,
|
|
296
|
+
startWatchdogLoop,
|
|
297
|
+
SyslogPriority,
|
|
298
|
+
type SyslogLevel,
|
|
299
|
+
} from "./systemd.ts";
|
package/src/systemd.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Native systemd integration for Bun runtimes:
|
|
4
|
+
* - Zero-dependency sd_notify and watchdog heartbeats via `bun:ffi` libc sockets.
|
|
5
|
+
* - Standard syslog priority formatting for high-performance direct journald indexing.
|
|
6
|
+
*/
|
|
7
|
+
import { dlopen, FFIType } from "bun:ffi";
|
|
8
|
+
|
|
9
|
+
/** Standard syslog priority prefixes for stdout/stderr journald indexing. */
|
|
10
|
+
export const SyslogPriority = {
|
|
11
|
+
EMERG: "<0>",
|
|
12
|
+
ALERT: "<1>",
|
|
13
|
+
CRIT: "<2>",
|
|
14
|
+
ERR: "<3>",
|
|
15
|
+
WARNING: "<4>",
|
|
16
|
+
NOTICE: "<5>",
|
|
17
|
+
INFO: "<6>",
|
|
18
|
+
DEBUG: "<7>",
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
export type SyslogLevel = keyof typeof SyslogPriority;
|
|
22
|
+
|
|
23
|
+
/** Format a message with a syslog priority prefix recognized by systemd journald. */
|
|
24
|
+
export function formatJournal(level: SyslogLevel, message: string): string {
|
|
25
|
+
return `${SyslogPriority[level]}${message}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const journal = {
|
|
29
|
+
emerg: (msg: string) => console.error(formatJournal("EMERG", msg)),
|
|
30
|
+
alert: (msg: string) => console.error(formatJournal("ALERT", msg)),
|
|
31
|
+
crit: (msg: string) => console.error(formatJournal("CRIT", msg)),
|
|
32
|
+
err: (msg: string) => console.error(formatJournal("ERR", msg)),
|
|
33
|
+
warn: (msg: string) => console.warn(formatJournal("WARNING", msg)),
|
|
34
|
+
notice: (msg: string) => console.log(formatJournal("NOTICE", msg)),
|
|
35
|
+
info: (msg: string) => console.log(formatJournal("INFO", msg)),
|
|
36
|
+
debug: (msg: string) => console.debug(formatJournal("DEBUG", msg)),
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
let libc: any = null;
|
|
40
|
+
function getLibc() {
|
|
41
|
+
if (libc !== null) return libc;
|
|
42
|
+
if (process.platform === "win32") {
|
|
43
|
+
libc = false;
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const libPath = process.platform === "darwin" ? "libSystem.B.dylib" : "libc.so.6";
|
|
48
|
+
libc = dlopen(libPath, {
|
|
49
|
+
socket: { args: [FFIType.i32, FFIType.i32, FFIType.i32], returns: FFIType.i32 },
|
|
50
|
+
sendto: {
|
|
51
|
+
args: [FFIType.i32, FFIType.ptr, FFIType.u64, FFIType.i32, FFIType.ptr, FFIType.u32],
|
|
52
|
+
returns: FFIType.i64,
|
|
53
|
+
},
|
|
54
|
+
close: { args: [FFIType.i32], returns: FFIType.i32 },
|
|
55
|
+
});
|
|
56
|
+
return libc;
|
|
57
|
+
} catch {
|
|
58
|
+
libc = false;
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Send a notification string to systemd via `NOTIFY_SOCKET`.
|
|
65
|
+
* Supports both filesystem paths and Linux abstract namespace sockets (leading `@`).
|
|
66
|
+
*
|
|
67
|
+
* @returns `true` if notification was transmitted, `false` if `NOTIFY_SOCKET` was unset or unavailable.
|
|
68
|
+
*/
|
|
69
|
+
export function sdNotify(state: string): boolean {
|
|
70
|
+
const socketPath = process.env.NOTIFY_SOCKET;
|
|
71
|
+
if (!socketPath) return false;
|
|
72
|
+
|
|
73
|
+
const lib = getLibc();
|
|
74
|
+
if (!lib) return false;
|
|
75
|
+
|
|
76
|
+
const AF_UNIX = 1;
|
|
77
|
+
const SOCK_DGRAM = 2;
|
|
78
|
+
const fd = lib.symbols.socket(AF_UNIX, SOCK_DGRAM, 0);
|
|
79
|
+
if (fd < 0) return false;
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const isAbstract = socketPath.startsWith("@");
|
|
83
|
+
const rawPath = isAbstract ? `\0${socketPath.slice(1)}` : socketPath;
|
|
84
|
+
const pathBuffer = Buffer.from(rawPath, "utf-8");
|
|
85
|
+
|
|
86
|
+
// struct sockaddr_un:
|
|
87
|
+
// unsigned short sun_family (2 bytes)
|
|
88
|
+
// char sun_path[108]
|
|
89
|
+
const sockaddr = Buffer.alloc(2 + 108);
|
|
90
|
+
sockaddr.writeUInt16LE(AF_UNIX, 0);
|
|
91
|
+
pathBuffer.copy(sockaddr, 2, 0, Math.min(pathBuffer.length, 108));
|
|
92
|
+
|
|
93
|
+
const stateBuf = Buffer.from(state, "utf-8");
|
|
94
|
+
const addrLen = isAbstract ? 2 + pathBuffer.length : 2 + pathBuffer.length + 1;
|
|
95
|
+
|
|
96
|
+
const sent = lib.symbols.sendto(fd, stateBuf, BigInt(stateBuf.length), 0, sockaddr, addrLen);
|
|
97
|
+
|
|
98
|
+
return Number(sent) >= 0;
|
|
99
|
+
} finally {
|
|
100
|
+
lib.symbols.close(fd);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Notify service manager that startup is complete. */
|
|
105
|
+
export function notifyReady(): boolean {
|
|
106
|
+
return sdNotify("READY=1");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Update the service's single-line status message. */
|
|
110
|
+
export function notifyStatus(status: string): boolean {
|
|
111
|
+
return sdNotify(`STATUS=${status}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Notify service manager that service shutdown has initiated. */
|
|
115
|
+
export function notifyStopping(): boolean {
|
|
116
|
+
return sdNotify("STOPPING=1");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Send keepalive heartbeat ping for services with `WatchdogSec=`. */
|
|
120
|
+
export function notifyWatchdog(): boolean {
|
|
121
|
+
return sdNotify("WATCHDOG=1");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Read watchdog timeout in microseconds from `WATCHDOG_USEC`. */
|
|
125
|
+
export function getWatchdogUsec(): number | null {
|
|
126
|
+
const raw = process.env.WATCHDOG_USEC;
|
|
127
|
+
if (!raw) return null;
|
|
128
|
+
const parsed = Number.parseInt(raw, 10);
|
|
129
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Spawns an automatic periodic watchdog heartbeat timer.
|
|
134
|
+
* Defaults to sending `WATCHDOG=1` at half the interval specified in `WATCHDOG_USEC`.
|
|
135
|
+
*
|
|
136
|
+
* @returns a controller with `.stop()` to cancel the heartbeat, or `null` if watchdog is not enabled.
|
|
137
|
+
*/
|
|
138
|
+
export function startWatchdogLoop(customIntervalMs?: number): { stop(): void } | null {
|
|
139
|
+
let intervalMs: number | null = null;
|
|
140
|
+
if (customIntervalMs !== undefined && customIntervalMs > 0) {
|
|
141
|
+
intervalMs = customIntervalMs;
|
|
142
|
+
} else {
|
|
143
|
+
const usec = getWatchdogUsec();
|
|
144
|
+
if (usec) {
|
|
145
|
+
intervalMs = Math.max(100, Math.floor(usec / 2000));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (!intervalMs) return null;
|
|
150
|
+
|
|
151
|
+
const timer = setInterval(() => {
|
|
152
|
+
sdNotify("WATCHDOG=1");
|
|
153
|
+
}, intervalMs);
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
stop() {
|
|
157
|
+
clearInterval(timer);
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// End-to-end against a real `aphrody-kernel serve` on a private endpoint.
|
|
3
|
+
import { afterAll, beforeAll, expect, test } from "bun:test";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { KernelClient, registerSubsystem } from "../src/index.ts";
|
|
7
|
+
|
|
8
|
+
const exe = process.platform === "win32" ? "aphrody-kernel.exe" : "aphrody-kernel";
|
|
9
|
+
// The Rust workspace root owns target/; an explicit binary override is available for installed builds.
|
|
10
|
+
const binary =
|
|
11
|
+
process.env.APHRODY_KERNEL_BIN ?? join(import.meta.dir, "../../../../target/debug", exe);
|
|
12
|
+
const endpoint =
|
|
13
|
+
process.platform === "win32"
|
|
14
|
+
? `\\\\.\\pipe\\aphrody-os-bun-test-${process.pid}`
|
|
15
|
+
: `/tmp/aphrody-os-bun-test-${process.pid}.sock`;
|
|
16
|
+
let server: ReturnType<typeof Bun.spawn> | undefined;
|
|
17
|
+
|
|
18
|
+
beforeAll(async () => {
|
|
19
|
+
if (!existsSync(binary))
|
|
20
|
+
throw new Error(`build it first through scripts/build/rust/cargo-serial.sh: ${binary}`);
|
|
21
|
+
server = Bun.spawn([binary, "--socket", endpoint, "serve", "--no-browser"], {
|
|
22
|
+
stderr: "pipe",
|
|
23
|
+
stdout: "pipe",
|
|
24
|
+
});
|
|
25
|
+
for (let i = 0; i < 200; i++) {
|
|
26
|
+
try {
|
|
27
|
+
(await KernelClient.connect(endpoint)).close();
|
|
28
|
+
return;
|
|
29
|
+
} catch {
|
|
30
|
+
await Bun.sleep(50);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
throw new Error("kernel did not start");
|
|
34
|
+
}, 60_000);
|
|
35
|
+
|
|
36
|
+
afterAll(() => server?.kill());
|
|
37
|
+
|
|
38
|
+
test("a Bun subsystem is served to other clients and leaves on close", async () => {
|
|
39
|
+
const bun = await registerSubsystem(
|
|
40
|
+
{
|
|
41
|
+
name: "bun_text",
|
|
42
|
+
tools: [
|
|
43
|
+
{
|
|
44
|
+
name: "bun_reverse",
|
|
45
|
+
description: "Reverse a string.",
|
|
46
|
+
inputSchema: {
|
|
47
|
+
type: "object",
|
|
48
|
+
properties: { text: { type: "string" } },
|
|
49
|
+
required: ["text"],
|
|
50
|
+
},
|
|
51
|
+
handler: ({ text }) => ({
|
|
52
|
+
reversed: String(text).split("").toReversed().join(""),
|
|
53
|
+
runtime: `bun ${Bun.version}`,
|
|
54
|
+
}),
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
endpoint,
|
|
59
|
+
);
|
|
60
|
+
const agent = await KernelClient.connect(endpoint, "agent");
|
|
61
|
+
const tools = (await agent.request("tools/list")) as { tools: { name: string }[] };
|
|
62
|
+
expect(tools.tools.map((t) => t.name)).toContain("bun_reverse");
|
|
63
|
+
const out = (await agent.call("bun_reverse", { text: "aphrody" })) as { reversed: string };
|
|
64
|
+
expect(out.reversed).toBe("ydorhpa");
|
|
65
|
+
|
|
66
|
+
// The Bun process can drive native subsystems too: Nushell through the kernel.
|
|
67
|
+
const nu = (await bun.call("nu_eval", { code: "[1 2 3] | math sum" })) as { value: number };
|
|
68
|
+
expect(nu.value).toBe(6);
|
|
69
|
+
|
|
70
|
+
bun.close();
|
|
71
|
+
await bun.closed;
|
|
72
|
+
for (let i = 0; i < 100; i++) {
|
|
73
|
+
const after = (await agent.request("tools/list")) as { tools: { name: string }[] };
|
|
74
|
+
if (!after.tools.some((t) => t.name === "bun_reverse")) break;
|
|
75
|
+
await Bun.sleep(20);
|
|
76
|
+
}
|
|
77
|
+
const after = (await agent.request("tools/list")) as { tools: { name: string }[] };
|
|
78
|
+
expect(after.tools.map((t) => t.name)).not.toContain("bun_reverse");
|
|
79
|
+
agent.close();
|
|
80
|
+
}, 60_000);
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import { expect, test } from "bun:test";
|
|
3
|
+
import {
|
|
4
|
+
formatJournal,
|
|
5
|
+
getWatchdogUsec,
|
|
6
|
+
notifyReady,
|
|
7
|
+
notifyStatus,
|
|
8
|
+
notifyStopping,
|
|
9
|
+
notifyWatchdog,
|
|
10
|
+
sdNotify,
|
|
11
|
+
startWatchdogLoop,
|
|
12
|
+
SyslogPriority,
|
|
13
|
+
} from "../src/systemd.ts";
|
|
14
|
+
|
|
15
|
+
test("journal formatting uses standard syslog prefixes", () => {
|
|
16
|
+
expect(formatJournal("INFO", "service booted")).toBe("<6>service booted");
|
|
17
|
+
expect(formatJournal("ERR", "database connection failed")).toBe("<3>database connection failed");
|
|
18
|
+
expect(formatJournal("WARNING", "rate limit approaching")).toBe("<4>rate limit approaching");
|
|
19
|
+
expect(SyslogPriority.EMERG).toBe("<0>");
|
|
20
|
+
expect(SyslogPriority.DEBUG).toBe("<7>");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("sdNotify returns false when NOTIFY_SOCKET is not set", () => {
|
|
24
|
+
const prev = process.env.NOTIFY_SOCKET;
|
|
25
|
+
try {
|
|
26
|
+
delete process.env.NOTIFY_SOCKET;
|
|
27
|
+
expect(sdNotify("READY=1")).toBe(false);
|
|
28
|
+
expect(notifyReady()).toBe(false);
|
|
29
|
+
expect(notifyStatus("running")).toBe(false);
|
|
30
|
+
expect(notifyStopping()).toBe(false);
|
|
31
|
+
expect(notifyWatchdog()).toBe(false);
|
|
32
|
+
} finally {
|
|
33
|
+
if (prev !== undefined) process.env.NOTIFY_SOCKET = prev;
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("watchdog interval calculation parses WATCHDOG_USEC correctly", () => {
|
|
38
|
+
const prev = process.env.WATCHDOG_USEC;
|
|
39
|
+
try {
|
|
40
|
+
process.env.WATCHDOG_USEC = "20000000"; // 20s
|
|
41
|
+
expect(getWatchdogUsec()).toBe(20000000);
|
|
42
|
+
|
|
43
|
+
const loop = startWatchdogLoop();
|
|
44
|
+
expect(loop).not.toBeNull();
|
|
45
|
+
loop?.stop();
|
|
46
|
+
|
|
47
|
+
delete process.env.WATCHDOG_USEC;
|
|
48
|
+
expect(getWatchdogUsec()).toBeNull();
|
|
49
|
+
expect(startWatchdogLoop()).toBeNull();
|
|
50
|
+
} finally {
|
|
51
|
+
if (prev !== undefined) process.env.WATCHDOG_USEC = prev;
|
|
52
|
+
else delete process.env.WATCHDOG_USEC;
|
|
53
|
+
}
|
|
54
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import { expect, test } from "bun:test";
|
|
3
|
+
import { createServer, type Socket } from "node:net";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { KernelClient } from "../src/index.ts";
|
|
8
|
+
|
|
9
|
+
async function endpoint(reply: boolean) {
|
|
10
|
+
const root = await mkdtemp(join(tmpdir(), "aphrody-transport-"));
|
|
11
|
+
const path =
|
|
12
|
+
process.platform === "win32"
|
|
13
|
+
? `\\\\.\\pipe\\aphrody-transport-${crypto.randomUUID()}`
|
|
14
|
+
: join(root, "kernel.sock");
|
|
15
|
+
const sockets = new Set<Socket>();
|
|
16
|
+
const server = createServer((socket) => {
|
|
17
|
+
sockets.add(socket);
|
|
18
|
+
socket.on("close", () => sockets.delete(socket));
|
|
19
|
+
socket.on("error", () => {});
|
|
20
|
+
let buffer = "";
|
|
21
|
+
socket.on("data", (data) => {
|
|
22
|
+
buffer += data.toString();
|
|
23
|
+
const lines = buffer.split("\n");
|
|
24
|
+
buffer = lines.pop()!;
|
|
25
|
+
for (const line of lines) {
|
|
26
|
+
const request = JSON.parse(line);
|
|
27
|
+
if (reply && request.method === "initialize")
|
|
28
|
+
socket.write(JSON.stringify({ jsonrpc: "2.0", id: request.id, result: {} }) + "\n");
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
await new Promise<void>((resolve, reject) => {
|
|
33
|
+
server.once("error", reject);
|
|
34
|
+
server.listen(path, resolve);
|
|
35
|
+
});
|
|
36
|
+
return {
|
|
37
|
+
path,
|
|
38
|
+
async dispose() {
|
|
39
|
+
for (const socket of sockets) socket.destroy();
|
|
40
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
41
|
+
await rm(root, { recursive: true, force: true });
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
test("a stalled MCP handshake times out and closes its connection", async () => {
|
|
47
|
+
const server = await endpoint(false);
|
|
48
|
+
try {
|
|
49
|
+
await expect(KernelClient.connect(server.path, "test", 30)).rejects.toThrow("timed out");
|
|
50
|
+
} finally {
|
|
51
|
+
await server.dispose();
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("request deadlines and shutdown reject waits, including calls made after close", async () => {
|
|
56
|
+
const server = await endpoint(true);
|
|
57
|
+
const client = await KernelClient.connect(server.path);
|
|
58
|
+
try {
|
|
59
|
+
await expect(client.call("stalled-tool", {}, 30)).rejects.toThrow("timed out");
|
|
60
|
+
const pending = client.request("tools/list");
|
|
61
|
+
client.close();
|
|
62
|
+
await expect(pending).rejects.toThrow("closed");
|
|
63
|
+
await client.closed;
|
|
64
|
+
await expect(client.request("tools/list")).rejects.toThrow("closed");
|
|
65
|
+
} finally {
|
|
66
|
+
client.close();
|
|
67
|
+
await server.dispose();
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("invalid deadlines are refused before starting a connection", async () => {
|
|
72
|
+
await expect(KernelClient.connect("/missing", "test", 0)).rejects.toThrow("positive");
|
|
73
|
+
});
|
package/tsconfig.json
ADDED
package/README.md
DELETED