@onlyne/mcp-gateway 3.0.0 → 3.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/README.md +82 -2
- package/package.json +9 -7
- package/src/config.ts +84 -0
- package/src/cron.ts +122 -0
- package/src/exec.ts +163 -0
- package/src/gateway-text.ts +18 -0
- package/src/index.ts +77 -14
- package/src/main.ts +13 -4
- package/src/mounts.ts +1043 -0
package/README.md
CHANGED
|
@@ -4,8 +4,9 @@ An Onlyne node that hosts MCP servers as resources. Each configured server regis
|
|
|
4
4
|
resource whose operations are that server's tools, so an agent reaches MCP through the same
|
|
5
5
|
resource path it uses for every other capability.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
The node also runs persistent scheduled tasks. A task executes a locally allowed command or an
|
|
8
|
+
allowed MCP tool and sends a readable result to its controller through Onlyne. These results use
|
|
9
|
+
ordinary messages. They do not create a platform event stream or a resource event log.
|
|
9
10
|
|
|
10
11
|
## Run
|
|
11
12
|
|
|
@@ -33,6 +34,85 @@ bunx onlyne-mcp-gateway ./gateway.json
|
|
|
33
34
|
the nodes allowed to call a server's tools; an empty list leaves the node owner alone.
|
|
34
35
|
`language` accepts `en` or `zh-CN` and otherwise follows `ONLYNE_LANG`, `LC_ALL`, or `LANG`.
|
|
35
36
|
|
|
37
|
+
## Scheduled tasks
|
|
38
|
+
|
|
39
|
+
Mounts are disabled until the operator configures the `mounts` section:
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
{
|
|
43
|
+
"mounts": {
|
|
44
|
+
"controllers": ["urn:onlyne:agent:main"],
|
|
45
|
+
"commands": [
|
|
46
|
+
{ "id": "heartbeat", "command": "/bin/sh", "args": ["-c", "printf 'heartbeat\\n'"], "cwd": "/tmp" }
|
|
47
|
+
],
|
|
48
|
+
"tools": [{ "server": "hindsight", "tool": "recall" }],
|
|
49
|
+
"tick_ms": 1000
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The node operator defines executable commands and arguments in local configuration. A remote
|
|
55
|
+
mount selects a command id. Remote input cannot replace its executable, arguments, or working
|
|
56
|
+
directory. An MCP mount selects an allowed server and tool. Its controller must also appear in
|
|
57
|
+
that server's `controllers` list. The store records the actor and definition for each change.
|
|
58
|
+
|
|
59
|
+
Each configured controller owns one scheduler resource named `mounts:<controller node id>`.
|
|
60
|
+
The resource's handler is that controller, so another controller cannot operate its tasks. The
|
|
61
|
+
gateway owner can read the resource. Agents use the existing resource operation path through
|
|
62
|
+
`msg`; hosts may call the authenticated resource HTTP endpoint directly.
|
|
63
|
+
|
|
64
|
+
The resource exposes these operations:
|
|
65
|
+
|
|
66
|
+
| Operation | Data | Result |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| `mount` | `schedule`, `action`, optional `title` | New task |
|
|
69
|
+
| `list` | Optional `include_removed` | This controller's tasks |
|
|
70
|
+
| `inspect` | `task` | Definition, runs, and change records |
|
|
71
|
+
| `update` | `task`, optional `title`, `schedule`, `action` | Updated definition |
|
|
72
|
+
| `runs` | `task`, optional `limit` from 1 to 100 | Recent runs, newest first |
|
|
73
|
+
| `cancel` | `task` | Stops future runs and the scheduler's active command |
|
|
74
|
+
| `resume` | `task` | Re-arms the task |
|
|
75
|
+
| `remove` | `task` | Removes the mount and retains its records |
|
|
76
|
+
|
|
77
|
+
For example, mount a command every 30 seconds:
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"operation": "mount",
|
|
82
|
+
"data": {
|
|
83
|
+
"title": "Heartbeat",
|
|
84
|
+
"schedule": { "kind": "interval", "every_ms": 30000 },
|
|
85
|
+
"action": { "kind": "command", "command": "heartbeat" }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
An interval fires immediately, then measures each interval from its previous start. A one-shot
|
|
91
|
+
schedule uses `{ "kind": "once", "at": "<future ISO timestamp>" }`. Cron uses
|
|
92
|
+
`{ "kind": "cron", "expression": "*/5 * * * *" }`, with five numeric UTC fields and the
|
|
93
|
+
standard `*`, comma, range, and step syntax. Named months and weekdays are rejected. An MCP
|
|
94
|
+
action uses `{ "kind": "mcp", "server": "hindsight", "tool": "recall", "arguments": {} }`.
|
|
95
|
+
|
|
96
|
+
The store retains each task, its next trigger time, and every run's start, finish, exit code,
|
|
97
|
+
output summary, and failure reason. A task runs once at a time. A missed trigger during an
|
|
98
|
+
active run records a skipped run. On restart, unfinished runs become interrupted records.
|
|
99
|
+
Overdue interval and one-shot tasks receive one catch-up run. Cron resumes at its next future
|
|
100
|
+
UTC occurrence. Updating an active task changes future runs and preserves the active run's
|
|
101
|
+
definition.
|
|
102
|
+
|
|
103
|
+
Each completed run has a durable push record. Its stable run id is also its message id, so a
|
|
104
|
+
retry reuses the same delivery identity. Results are sent to the controller with
|
|
105
|
+
`response: "none"` and `execution: "existing"`. Pending and uncertain deliveries retry with
|
|
106
|
+
backoff within a 24-hour window. Refused and expired deliveries retain their reason. An
|
|
107
|
+
accepted receipt means the controller saved the message; the controller host confirms model
|
|
108
|
+
consumption through its existing delivery path.
|
|
109
|
+
|
|
110
|
+
## Terminal
|
|
111
|
+
|
|
112
|
+
The shared trust terminal shows servers and tool counts, task state and next trigger times,
|
|
113
|
+
the pending push queue, and recent results. `ONLYNE_NO_TUI=1` keeps JSON logs on stdout,
|
|
114
|
+
including when the process runs in a terminal.
|
|
115
|
+
|
|
36
116
|
## Library use
|
|
37
117
|
|
|
38
118
|
```ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onlyne/mcp-gateway",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"description": "Onlyne MCP gateway: one node that hosts MCP servers as resources and exposes their tools as operations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -19,17 +19,19 @@
|
|
|
19
19
|
"exports": {
|
|
20
20
|
".": "./src/index.ts"
|
|
21
21
|
},
|
|
22
|
-
"bin": { "onlyne-mcp-gateway": "
|
|
22
|
+
"bin": { "onlyne-mcp-gateway": "src/main.ts" },
|
|
23
23
|
"files": ["src", "README.md", "LICENSE"],
|
|
24
24
|
"scripts": {
|
|
25
25
|
"check": "tsc --noEmit",
|
|
26
|
-
"test": "bun tests/verify.ts"
|
|
26
|
+
"test": "bun tests/verify.ts && bun tests/mounts-verify.ts",
|
|
27
|
+
"smoke": "bun tests/mounts-smoke.ts",
|
|
28
|
+
"smoke:fast": "MOUNT_SMOKE_INTERVAL_MS=3000 bun tests/mounts-smoke.ts"
|
|
27
29
|
},
|
|
28
30
|
"dependencies": {
|
|
29
|
-
"@onlyne/im-gateway": "^3.
|
|
30
|
-
"@onlyne/lib": "^3.
|
|
31
|
-
"@onlyne/mcp-core": "^3.
|
|
32
|
-
"@onlyne/node-host": "^3.
|
|
31
|
+
"@onlyne/im-gateway": "^3.1.0",
|
|
32
|
+
"@onlyne/lib": "^3.1.0",
|
|
33
|
+
"@onlyne/mcp-core": "^3.1.0",
|
|
34
|
+
"@onlyne/node-host": "^3.1.0"
|
|
33
35
|
},
|
|
34
36
|
"devDependencies": {
|
|
35
37
|
"@types/bun": "^1.3.0",
|
package/src/config.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { OnlyneError, type Json, type NodeId } from '@onlyne/lib';
|
|
2
2
|
import type { Locale } from '@onlyne/node-host/i18n';
|
|
3
3
|
import { t } from './gateway-text.js';
|
|
4
|
+
import { MOUNTS_RESOURCE_PREFIX } from './mounts.js';
|
|
4
5
|
|
|
5
6
|
/** One configured MCP server: its resource name, URL and per-call settings. */
|
|
6
7
|
export interface ServerConfig {
|
|
@@ -13,11 +14,32 @@ export interface ServerConfig {
|
|
|
13
14
|
controllers?: NodeId[];
|
|
14
15
|
}
|
|
15
16
|
|
|
17
|
+
/** One locally allowlisted executable a scheduled mount may run: the mount names this id only. */
|
|
18
|
+
export interface MountCommandConfig {
|
|
19
|
+
id: string;
|
|
20
|
+
command: string;
|
|
21
|
+
args?: string[];
|
|
22
|
+
cwd?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** One MCP tool a scheduled mount may call; the server's own controller list still applies. */
|
|
26
|
+
export interface MountToolConfig { server: string; tool: string }
|
|
27
|
+
|
|
28
|
+
export interface MountsConfig {
|
|
29
|
+
/** The nodes that may mount, inspect and cancel tasks on their own scheduler resource. */
|
|
30
|
+
controllers: NodeId[];
|
|
31
|
+
commands: MountCommandConfig[];
|
|
32
|
+
tools: MountToolConfig[];
|
|
33
|
+
tick_ms: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
16
36
|
export interface GatewayConfig {
|
|
17
37
|
node: { id: NodeId };
|
|
18
38
|
server: { bind: string; port: number };
|
|
19
39
|
storage_path: string;
|
|
20
40
|
servers: ServerConfig[];
|
|
41
|
+
/** Scheduled mounts; absent means the gateway serves no scheduler resource at all. */
|
|
42
|
+
mounts?: MountsConfig;
|
|
21
43
|
/** The language of the gateway's own console text; unset follows `ONLYNE_LANG`. */
|
|
22
44
|
language?: Locale;
|
|
23
45
|
}
|
|
@@ -37,6 +59,63 @@ const requireString = (container: Record<string, unknown>, field: string): strin
|
|
|
37
59
|
return value;
|
|
38
60
|
};
|
|
39
61
|
|
|
62
|
+
const requireStrings = (value: Json | undefined, field: string): string[] => {
|
|
63
|
+
if (value === undefined) return [];
|
|
64
|
+
if (!Array.isArray(value) || value.some(item => typeof item !== 'string' || item.length === 0)) {
|
|
65
|
+
throw new OnlyneError('invalid_request', t('config.stringList', { field }));
|
|
66
|
+
}
|
|
67
|
+
return value as string[];
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
function parseMounts(raw: Json | undefined, owner: NodeId, serverNames: Set<string>): MountsConfig | undefined {
|
|
71
|
+
if (raw === undefined) return undefined;
|
|
72
|
+
const record = requireObject(raw, 'mounts');
|
|
73
|
+
const controllers = [...new Set(requireStrings(record.controllers, 'mounts.controllers'))];
|
|
74
|
+
if (controllers.length === 0) {
|
|
75
|
+
throw new OnlyneError('invalid_request', t('config.mountsControllers'));
|
|
76
|
+
}
|
|
77
|
+
if (controllers.includes(owner)) {
|
|
78
|
+
throw new OnlyneError('invalid_request', t('config.mountsOwner', { owner }));
|
|
79
|
+
}
|
|
80
|
+
const commands = (record.commands === undefined ? [] : record.commands);
|
|
81
|
+
if (!Array.isArray(commands)) throw new OnlyneError('invalid_request', t('config.mountsCommands'));
|
|
82
|
+
const commandIds = new Set<string>();
|
|
83
|
+
const parsedCommands = commands.map((entry, index): MountCommandConfig => {
|
|
84
|
+
const item = requireObject(entry, `mounts.commands[${index}]`);
|
|
85
|
+
const id = requireString(item, 'id');
|
|
86
|
+
if (commandIds.has(id)) throw new OnlyneError('invalid_request', t('config.mountsDuplicate', { kind: 'command', id }));
|
|
87
|
+
commandIds.add(id);
|
|
88
|
+
const args = item.args === undefined ? [] : requireStrings(item.args, `mounts.commands[${index}].args`);
|
|
89
|
+
const cwd = item.cwd === undefined ? undefined : requireString(item, 'cwd');
|
|
90
|
+
return {
|
|
91
|
+
id,
|
|
92
|
+
command: requireString(item, 'command'),
|
|
93
|
+
...(args.length === 0 ? {} : { args }),
|
|
94
|
+
...(cwd === undefined ? {} : { cwd }),
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
const tools = (record.tools === undefined ? [] : record.tools);
|
|
98
|
+
if (!Array.isArray(tools)) throw new OnlyneError('invalid_request', t('config.mountsTools'));
|
|
99
|
+
const toolIds = new Set<string>();
|
|
100
|
+
const parsedTools = tools.map((entry, index): MountToolConfig => {
|
|
101
|
+
const item = requireObject(entry, `mounts.tools[${index}]`);
|
|
102
|
+
const server = requireString(item, 'server');
|
|
103
|
+
const tool = requireString(item, 'tool');
|
|
104
|
+
if (!serverNames.has(server)) {
|
|
105
|
+
throw new OnlyneError('invalid_request', t('config.mountsToolServer', { server }));
|
|
106
|
+
}
|
|
107
|
+
const pair = `${server}\u0000${tool}`;
|
|
108
|
+
if (toolIds.has(pair)) throw new OnlyneError('invalid_request', t('config.mountsDuplicate', { kind: 'tool', id: `${server}/${tool}` }));
|
|
109
|
+
toolIds.add(pair);
|
|
110
|
+
return { server, tool };
|
|
111
|
+
});
|
|
112
|
+
const tick = record.tick_ms === undefined ? 1_000 : record.tick_ms;
|
|
113
|
+
if (typeof tick !== 'number' || !Number.isInteger(tick) || tick < 100 || tick > 60_000) {
|
|
114
|
+
throw new OnlyneError('invalid_request', t('config.mountTick'));
|
|
115
|
+
}
|
|
116
|
+
return { controllers, commands: parsedCommands, tools: parsedTools, tick_ms: tick };
|
|
117
|
+
}
|
|
118
|
+
|
|
40
119
|
/** `language` is exactly `en` or `zh-CN`: a near miss would silently pick a different console language. */
|
|
41
120
|
const parseLanguage = (value: Json | undefined): Locale | undefined => {
|
|
42
121
|
if (value === undefined) return undefined;
|
|
@@ -83,8 +162,12 @@ export async function loadConfig(path: string): Promise<GatewayConfig> {
|
|
|
83
162
|
const names = new Set<string>();
|
|
84
163
|
for (const entry of servers) {
|
|
85
164
|
if (names.has(entry.name)) throw new OnlyneError('invalid_request', t('config.duplicateName', { name: entry.name }));
|
|
165
|
+
if (entry.name.startsWith(MOUNTS_RESOURCE_PREFIX)) {
|
|
166
|
+
throw new OnlyneError('invalid_request', t('config.reservedName', { name: entry.name, prefix: MOUNTS_RESOURCE_PREFIX }));
|
|
167
|
+
}
|
|
86
168
|
names.add(entry.name);
|
|
87
169
|
}
|
|
170
|
+
const mounts = parseMounts(raw.mounts, owner, names);
|
|
88
171
|
return {
|
|
89
172
|
node: { id: owner },
|
|
90
173
|
server: {
|
|
@@ -93,6 +176,7 @@ export async function loadConfig(path: string): Promise<GatewayConfig> {
|
|
|
93
176
|
},
|
|
94
177
|
storage_path: requireString(storage, 'path'),
|
|
95
178
|
servers,
|
|
179
|
+
...(mounts === undefined ? {} : { mounts }),
|
|
96
180
|
...(language === undefined ? {} : { language }),
|
|
97
181
|
};
|
|
98
182
|
}
|
package/src/cron.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { OnlyneError } from '@onlyne/lib';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A parsed five-field cron expression. Every field becomes a sorted list of the values it
|
|
5
|
+
* admits, so matching is a membership test and never a re-parse. Time is UTC: a mounted task
|
|
6
|
+
* must fire at the same instant after a restart, in every process, on every host.
|
|
7
|
+
*/
|
|
8
|
+
export interface CronSchedule {
|
|
9
|
+
readonly minutes: readonly number[];
|
|
10
|
+
readonly hours: readonly number[];
|
|
11
|
+
readonly days: readonly number[];
|
|
12
|
+
readonly months: readonly number[];
|
|
13
|
+
readonly weekdays: readonly number[];
|
|
14
|
+
/** Which of the day fields the expression restricts; both restricted match either (Vixie). */
|
|
15
|
+
readonly domRestricted: boolean;
|
|
16
|
+
readonly dowRestricted: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const FIELDS = [
|
|
20
|
+
{ name: 'minute', min: 0, max: 59 },
|
|
21
|
+
{ name: 'hour', min: 0, max: 23 },
|
|
22
|
+
{ name: 'day-of-month', min: 1, max: 31 },
|
|
23
|
+
{ name: 'month', min: 1, max: 12 },
|
|
24
|
+
// 7 is Sunday: accepted as an input alias only, and normalized before the schedule is stored.
|
|
25
|
+
{ name: 'day-of-week', min: 0, max: 7 },
|
|
26
|
+
] as const;
|
|
27
|
+
|
|
28
|
+
/** `7` is Sunday in the day-of-week field; the schedule normalizes it to `0`. */
|
|
29
|
+
const WEEKDAY_ALIAS: Record<string, number> = { '7': 0 };
|
|
30
|
+
|
|
31
|
+
const RANGE = /^(\d+)-(\d+)$/;
|
|
32
|
+
const NUMBER = /^\d+$/;
|
|
33
|
+
|
|
34
|
+
function parseField(text: string, spec: (typeof FIELDS)[number]): number[] {
|
|
35
|
+
if (text.length === 0) throw cronError(`The ${spec.name} field is empty.`);
|
|
36
|
+
const values = new Set<number>();
|
|
37
|
+
for (const part of text.split(',')) {
|
|
38
|
+
if (part.length === 0) throw cronError(`The ${spec.name} field holds an empty list item.`);
|
|
39
|
+
const [rangeText = '', stepText, extra] = part.split('/');
|
|
40
|
+
if (extra !== undefined) throw cronError(`The ${spec.name} field step has more than one slash.`);
|
|
41
|
+
const step = stepText === undefined ? 1 : Number(stepText);
|
|
42
|
+
if (!Number.isInteger(step) || step <= 0) throw cronError(`The ${spec.name} field step is a positive integer.`);
|
|
43
|
+
let from: number;
|
|
44
|
+
let to: number;
|
|
45
|
+
if (rangeText === '*') {
|
|
46
|
+
from = spec.min;
|
|
47
|
+
to = spec.max;
|
|
48
|
+
} else if (NUMBER.test(rangeText)) {
|
|
49
|
+
from = Number(rangeText);
|
|
50
|
+
// A bare number with a step names the range from that number to the field maximum.
|
|
51
|
+
to = stepText === undefined ? from : spec.max;
|
|
52
|
+
} else {
|
|
53
|
+
const range = RANGE.exec(rangeText);
|
|
54
|
+
if (range === null) throw cronError(`The ${spec.name} field value ${JSON.stringify(rangeText)} is not a number, range or '*'.`);
|
|
55
|
+
from = Number(range[1]);
|
|
56
|
+
to = Number(range[2]);
|
|
57
|
+
}
|
|
58
|
+
const low = from;
|
|
59
|
+
const high = to;
|
|
60
|
+
if (low < spec.min || low > spec.max || high < spec.min || high > spec.max) {
|
|
61
|
+
throw cronError(`The ${spec.name} field allows ${spec.min}..${spec.max}, not ${from}..${to}.`);
|
|
62
|
+
}
|
|
63
|
+
if (low > high) throw cronError(`The ${spec.name} field range ${from}-${to} runs backwards.`);
|
|
64
|
+
for (let value = low; value <= high; value += step) values.add(WEEKDAY_ALIAS[String(value)] ?? value);
|
|
65
|
+
}
|
|
66
|
+
return [...values].sort((a, b) => a - b);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function cronError(message: string): OnlyneError {
|
|
70
|
+
return new OnlyneError('invalid_request', message);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Parses one five-field expression; throwing `invalid_request` names the offending field. */
|
|
74
|
+
export function parseCron(expression: string): CronSchedule {
|
|
75
|
+
if (typeof expression !== 'string') throw cronError('A cron expression is a string.');
|
|
76
|
+
const parts = expression.trim().split(/\s+/);
|
|
77
|
+
if (parts.length !== FIELDS.length) {
|
|
78
|
+
throw cronError(`A cron expression has ${FIELDS.length} fields (minute hour day month weekday), not ${parts.length}.`);
|
|
79
|
+
}
|
|
80
|
+
const [minutes, hours, days, months, weekdays] = parts.map((part, index) => parseField(part, FIELDS[index]!));
|
|
81
|
+
return {
|
|
82
|
+
minutes: minutes!, hours: hours!, days: days!, months: months!, weekdays: weekdays!,
|
|
83
|
+
domRestricted: parts[2] !== '*',
|
|
84
|
+
dowRestricted: parts[4] !== '*',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const MINUTE_MS = 60_000;
|
|
89
|
+
const HOUR_MS = 3_600_000;
|
|
90
|
+
const DAY_MS = 86_400_000;
|
|
91
|
+
/** Five years covers every Gregorian recurrence, including 29 February on a weekday. */
|
|
92
|
+
const HORIZON_DAYS = 366 * 5;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The first instant strictly after `fromMs` that the expression admits, to the minute, in UTC.
|
|
96
|
+
* `undefined` means no occurrence inside the five-year horizon (for example `0 0 31 2 *`).
|
|
97
|
+
*/
|
|
98
|
+
export function nextCronTime(schedule: CronSchedule, fromMs: number): number | undefined {
|
|
99
|
+
const dayStart = Math.floor(fromMs / DAY_MS) * DAY_MS;
|
|
100
|
+
for (let day = 0; day < HORIZON_DAYS; day++) {
|
|
101
|
+
const base = dayStart + day * DAY_MS;
|
|
102
|
+
const date = new Date(base);
|
|
103
|
+
const month = date.getUTCMonth() + 1;
|
|
104
|
+
if (!schedule.months.includes(month)) continue;
|
|
105
|
+
const dom = date.getUTCDate();
|
|
106
|
+
const dow = date.getUTCDay();
|
|
107
|
+
const domMatch = schedule.days.includes(dom);
|
|
108
|
+
const dowMatch = schedule.weekdays.includes(dow);
|
|
109
|
+
// Vixie cron: a restricted day-of-month and a restricted day-of-week combine with OR.
|
|
110
|
+
const dayMatch = schedule.domRestricted && schedule.dowRestricted
|
|
111
|
+
? domMatch || dowMatch
|
|
112
|
+
: domMatch && dowMatch;
|
|
113
|
+
if (!dayMatch) continue;
|
|
114
|
+
for (const hour of schedule.hours) {
|
|
115
|
+
for (const minute of schedule.minutes) {
|
|
116
|
+
const at = base + hour * HOUR_MS + minute * MINUTE_MS;
|
|
117
|
+
if (at > fromMs) return at;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
package/src/exec.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { OnlyneError } from '@onlyne/lib';
|
|
2
|
+
|
|
3
|
+
/** Output kept per stream: enough for one readable result line, bounded so a chatty script cannot bloat the store. */
|
|
4
|
+
export const OUTPUT_CAP = 4_096;
|
|
5
|
+
/** How long a cancelled or stopping run gets to exit before the subtree is killed outright. */
|
|
6
|
+
const KILL_GRACE_MS = 1_500;
|
|
7
|
+
|
|
8
|
+
export interface CommandResult {
|
|
9
|
+
exit_code: number | null;
|
|
10
|
+
stdout: string;
|
|
11
|
+
stderr: string;
|
|
12
|
+
output_bytes: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CommandTarget {
|
|
16
|
+
command: string;
|
|
17
|
+
args?: readonly string[];
|
|
18
|
+
cwd?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A handle the scheduler keeps so it can signal exactly the process it started, never a name match. */
|
|
22
|
+
export interface RunningChild {
|
|
23
|
+
cancelled: boolean;
|
|
24
|
+
pid?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const safeKill = (pid: number, signal: NodeJS.Signals): void => {
|
|
28
|
+
// Never signal this process or init: a scheduler cancel must not take the gateway down.
|
|
29
|
+
if (pid <= 1 || pid === process.pid) return;
|
|
30
|
+
try { process.kill(pid, signal); } catch { /* the process is already gone */ }
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** The parent the kernel currently reports for a pid, used to prove the process is still ours. */
|
|
34
|
+
function parentOf(pid: number): number | undefined {
|
|
35
|
+
try {
|
|
36
|
+
const text = Bun.spawnSync({
|
|
37
|
+
cmd: ['ps', '-o', 'ppid=', '-p', String(pid)], stdout: 'pipe', stderr: 'ignore',
|
|
38
|
+
}).stdout.toString().trim();
|
|
39
|
+
const parent = Number(text);
|
|
40
|
+
return Number.isInteger(parent) ? parent : undefined;
|
|
41
|
+
} catch {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The live descendants of `pid`, read from `ps` once per signal so the list is the kernel's
|
|
48
|
+
* current view and not a stale snapshot from before the parent exited. Every pid is exact;
|
|
49
|
+
* nothing is matched by name.
|
|
50
|
+
*/
|
|
51
|
+
async function descendantsOf(pid: number): Promise<number[]> {
|
|
52
|
+
let output: string;
|
|
53
|
+
try {
|
|
54
|
+
output = Bun.spawnSync({
|
|
55
|
+
cmd: ['ps', '-axo', 'pid=,ppid='], stdout: 'pipe', stderr: 'ignore',
|
|
56
|
+
}).stdout.toString();
|
|
57
|
+
} catch {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
const children = new Map<number, number[]>();
|
|
61
|
+
for (const line of output.split('\n')) {
|
|
62
|
+
const [childText, parentText] = line.trim().split(/\s+/);
|
|
63
|
+
const child = Number(childText);
|
|
64
|
+
const parent = Number(parentText);
|
|
65
|
+
if (!Number.isInteger(child) || !Number.isInteger(parent)) continue;
|
|
66
|
+
const list = children.get(parent) ?? [];
|
|
67
|
+
list.push(child);
|
|
68
|
+
children.set(parent, list);
|
|
69
|
+
}
|
|
70
|
+
const found: number[] = [];
|
|
71
|
+
const queue = [...(children.get(pid) ?? [])];
|
|
72
|
+
while (queue.length > 0) {
|
|
73
|
+
const next = queue.shift()!;
|
|
74
|
+
if (next === process.pid || found.includes(next)) continue;
|
|
75
|
+
found.push(next);
|
|
76
|
+
queue.push(...(children.get(next) ?? []));
|
|
77
|
+
}
|
|
78
|
+
return found;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Signals the process group the scheduler built: the exact child first, then its verified descendants. */
|
|
82
|
+
export async function signalTree(child: RunningChild, signal: NodeJS.Signals): Promise<void> {
|
|
83
|
+
const pid = child.pid;
|
|
84
|
+
if (pid === undefined) return;
|
|
85
|
+
// A pid that no longer descends from this process has exited and its number may now belong to
|
|
86
|
+
// an unrelated process: drop the handle instead of signalling whatever took the slot.
|
|
87
|
+
if (parentOf(pid) !== process.pid) {
|
|
88
|
+
child.pid = undefined;
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const descendants = await descendantsOf(pid);
|
|
92
|
+
for (const descendant of descendants.reverse()) safeKill(descendant, signal);
|
|
93
|
+
safeKill(pid, signal);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Stops one run: SIGTERM for a clean exit, then SIGKILL of the same subtree while its root is
|
|
98
|
+
* still alive after the grace period. The escalation is not awaited by the caller, so a cancel
|
|
99
|
+
* answers as soon as the signal is sent; the run settles when its process actually exits.
|
|
100
|
+
*/
|
|
101
|
+
export function stopChild(child: RunningChild): void {
|
|
102
|
+
if (child.cancelled) return;
|
|
103
|
+
child.cancelled = true;
|
|
104
|
+
const pid = child.pid;
|
|
105
|
+
if (pid === undefined) return;
|
|
106
|
+
void signalTree(child, 'SIGTERM');
|
|
107
|
+
const timer = setTimeout(() => {
|
|
108
|
+
// Re-derive from the live root: a dead root's pid may already belong to another process,
|
|
109
|
+
// and a reused pid must never receive this run's signal.
|
|
110
|
+
if (child.pid === pid) void signalTree(child, 'SIGKILL');
|
|
111
|
+
}, KILL_GRACE_MS);
|
|
112
|
+
timer.unref?.();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function drain(stream: ReadableStream<Uint8Array>, cap: number): Promise<{ text: string; bytes: number }> {
|
|
116
|
+
const decoder = new TextDecoder();
|
|
117
|
+
let text = '';
|
|
118
|
+
let bytes = 0;
|
|
119
|
+
for await (const chunk of stream as unknown as AsyncIterable<Uint8Array>) {
|
|
120
|
+
bytes += chunk.byteLength;
|
|
121
|
+
text += decoder.decode(chunk, { stream: true });
|
|
122
|
+
if (text.length > cap) text = text.slice(-cap);
|
|
123
|
+
}
|
|
124
|
+
text += decoder.decode();
|
|
125
|
+
if (text.length > cap) text = text.slice(-cap);
|
|
126
|
+
return { text, bytes };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Runs one allowlisted executable with argv directly, never through a shell: no quoting, no
|
|
131
|
+
* metacharacters, no remote string ever reaches `/bin/sh`. The child's pid is recorded before
|
|
132
|
+
* any output is read so a cancel that arrives first still has a handle to signal.
|
|
133
|
+
*/
|
|
134
|
+
export async function runCommand(target: CommandTarget, child: RunningChild): Promise<CommandResult> {
|
|
135
|
+
let proc: ReturnType<typeof Bun.spawn>;
|
|
136
|
+
try {
|
|
137
|
+
proc = Bun.spawn({
|
|
138
|
+
cmd: [target.command, ...(target.args ?? [])],
|
|
139
|
+
cwd: target.cwd ?? process.cwd(),
|
|
140
|
+
env: process.env,
|
|
141
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
142
|
+
});
|
|
143
|
+
} catch (error) {
|
|
144
|
+
throw new OnlyneError('invalid_request', `The command ${target.command} could not be started: ${error instanceof Error ? error.message : String(error)}`);
|
|
145
|
+
}
|
|
146
|
+
child.pid = proc.pid;
|
|
147
|
+
try {
|
|
148
|
+
const [stdout, stderr] = await Promise.all([
|
|
149
|
+
drain(proc.stdout as ReadableStream<Uint8Array>, OUTPUT_CAP),
|
|
150
|
+
drain(proc.stderr as ReadableStream<Uint8Array>, OUTPUT_CAP),
|
|
151
|
+
proc.exited,
|
|
152
|
+
]);
|
|
153
|
+
return {
|
|
154
|
+
exit_code: proc.exitCode ?? null,
|
|
155
|
+
stdout: stdout.text,
|
|
156
|
+
stderr: stderr.text,
|
|
157
|
+
output_bytes: stdout.bytes + stderr.bytes,
|
|
158
|
+
};
|
|
159
|
+
} finally {
|
|
160
|
+
// The child is gone; releasing the handle keeps a late cancel from signalling a reused pid.
|
|
161
|
+
if (proc.exitCode !== null) child.pid = undefined;
|
|
162
|
+
}
|
|
163
|
+
}
|
package/src/gateway-text.ts
CHANGED
|
@@ -9,6 +9,15 @@ const en = {
|
|
|
9
9
|
'config.servers': 'The configuration needs servers as an array.',
|
|
10
10
|
'config.duplicateName': 'Two servers share the name {name}.',
|
|
11
11
|
'config.language': 'The configuration language is en or zh-CN, not {value}.',
|
|
12
|
+
'config.stringList': 'The configuration field {field} needs a list of non-empty strings.',
|
|
13
|
+
'config.reservedName': 'The server name {name} uses the reserved resource prefix {prefix}.',
|
|
14
|
+
'config.mountsControllers': 'mounts.controllers needs at least one node id.',
|
|
15
|
+
'config.mountsOwner': 'The node owner {owner} cannot be its own mount controller.',
|
|
16
|
+
'config.mountsCommands': 'mounts.commands needs an array.',
|
|
17
|
+
'config.mountsTools': 'mounts.tools needs an array.',
|
|
18
|
+
'config.mountsToolServer': 'mounts.tools names the server {server}, which is not configured.',
|
|
19
|
+
'config.mountsDuplicate': 'Two mounts entries share the {kind} {id}.',
|
|
20
|
+
'config.mountTick': 'mounts.tick_ms is an integer from 100 to 60000.',
|
|
12
21
|
'error.claimIdentity': 'HTTP callers cannot claim the gateway identity.',
|
|
13
22
|
'error.decide': 'The gateway forwards tool calls; it decides nothing.',
|
|
14
23
|
'error.noContext': 'The gateway consumes no model context.',
|
|
@@ -23,6 +32,15 @@ const zh: Record<keyof typeof en, string> = {
|
|
|
23
32
|
'config.servers': '配置项 servers 需要是一个数组。',
|
|
24
33
|
'config.duplicateName': '有两个服务器共用了名称 {name}。',
|
|
25
34
|
'config.language': '配置项 language 只能是 en 或 zh-CN,而不是 {value}。',
|
|
35
|
+
'config.stringList': '配置项 {field} 需要是非空字符串列表。',
|
|
36
|
+
'config.reservedName': '服务器名称 {name} 占用了保留的资源前缀 {prefix}。',
|
|
37
|
+
'config.mountsControllers': 'mounts.controllers 至少需要一个节点标识。',
|
|
38
|
+
'config.mountsOwner': '节点自身 {owner} 不能作为自己的挂载控制者。',
|
|
39
|
+
'config.mountsCommands': 'mounts.commands 需要是一个数组。',
|
|
40
|
+
'config.mountsTools': 'mounts.tools 需要是一个数组。',
|
|
41
|
+
'config.mountsToolServer': 'mounts.tools 指向的服务器 {server} 并未配置。',
|
|
42
|
+
'config.mountsDuplicate': '有两个挂载配置项共用了 {kind} {id}。',
|
|
43
|
+
'config.mountTick': 'mounts.tick_ms 是 100 到 60000 之间的整数。',
|
|
26
44
|
'error.claimIdentity': 'HTTP 调用方不能冒用网关身份。',
|
|
27
45
|
'error.decide': '网关只转发工具调用,不做任何决策。',
|
|
28
46
|
'error.noContext': '网关不消费模型上下文。',
|