@onlyne/mcp-gateway 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dbydd
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,126 @@
1
- # Temporary Holding Version
1
+ # @onlyne/mcp-gateway
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ An Onlyne node that hosts MCP servers as resources. Each configured server registers as one
4
+ resource whose operations are that server's tools, so an agent reaches MCP through the same
5
+ resource path it uses for every other capability.
6
+
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.
10
+
11
+ ## Run
12
+
13
+ ```sh
14
+ bunx onlyne-mcp-gateway ./gateway.json
15
+ ```
16
+
17
+ ```json
18
+ {
19
+ "node": { "id": "urn:onlyne:gateway:mcp" },
20
+ "server": { "bind": "127.0.0.1", "port": 3992 },
21
+ "storage": { "path": "./var/mcp/store.json" },
22
+ "servers": [
23
+ {
24
+ "name": "hindsight",
25
+ "url": "http://localhost:8888/mcp/example-bank/",
26
+ "timeout_ms": 60000,
27
+ "controllers": ["urn:onlyne:agent:main"]
28
+ }
29
+ ]
30
+ }
31
+ ```
32
+
33
+ `headers` adds per-request headers such as an API key and is never logged. `controllers` lists
34
+ the nodes allowed to call a server's tools; an empty list leaves the node owner alone.
35
+ `language` accepts `en` or `zh-CN` and otherwise follows `ONLYNE_LANG`, `LC_ALL`, or `LANG`.
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
+
116
+ ## Library use
117
+
118
+ ```ts
119
+ import { createMcpGateway } from '@onlyne/mcp-gateway';
120
+ ```
121
+
122
+ The result carries the node, the connections, and a `fetch` handler.
123
+
124
+ ## License
125
+
126
+ MIT.
package/package.json CHANGED
@@ -1,6 +1,40 @@
1
1
  {
2
2
  "name": "@onlyne/mcp-gateway",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "3.1.0",
4
+ "description": "Onlyne MCP gateway: one node that hosts MCP servers as resources and exposes their tools as operations",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "license": "MIT",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/dbydd/onlyne.git",
12
+ "directory": "packages/mcp-gateway"
13
+ },
14
+ "homepage": "https://github.com/dbydd/onlyne#readme",
15
+ "bugs": "https://github.com/dbydd/onlyne/issues",
16
+ "keywords": ["onlyne", "mcp", "gateway", "cli"],
17
+ "publishConfig": { "access": "public" },
18
+ "engines": { "bun": ">=1.4.3" },
19
+ "exports": {
20
+ ".": "./src/index.ts"
21
+ },
22
+ "bin": { "onlyne-mcp-gateway": "src/main.ts" },
23
+ "files": ["src", "README.md", "LICENSE"],
24
+ "scripts": {
25
+ "check": "tsc --noEmit",
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"
29
+ },
30
+ "dependencies": {
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"
35
+ },
36
+ "devDependencies": {
37
+ "@types/bun": "^1.3.0",
38
+ "typescript": "^5.9.0"
39
+ }
40
+ }
package/src/config.ts ADDED
@@ -0,0 +1,182 @@
1
+ import { OnlyneError, type Json, type NodeId } from '@onlyne/lib';
2
+ import type { Locale } from '@onlyne/node-host/i18n';
3
+ import { t } from './gateway-text.js';
4
+ import { MOUNTS_RESOURCE_PREFIX } from './mounts.js';
5
+
6
+ /** One configured MCP server: its resource name, URL and per-call settings. */
7
+ export interface ServerConfig {
8
+ name: string;
9
+ url: string;
10
+ /** Extra request headers (an API key, a bank header); never logged. */
11
+ headers?: Record<string, string>;
12
+ timeout_ms?: number;
13
+ /** Who may call this server's tools. Empty means the node owner only. */
14
+ controllers?: NodeId[];
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
+
36
+ export interface GatewayConfig {
37
+ node: { id: NodeId };
38
+ server: { bind: string; port: number };
39
+ storage_path: string;
40
+ servers: ServerConfig[];
41
+ /** Scheduled mounts; absent means the gateway serves no scheduler resource at all. */
42
+ mounts?: MountsConfig;
43
+ /** The language of the gateway's own console text; unset follows `ONLYNE_LANG`. */
44
+ language?: Locale;
45
+ }
46
+
47
+ const requireObject = (value: Json | undefined, field: string): Record<string, Json> => {
48
+ if (value === undefined || value === null || typeof value !== 'object' || Array.isArray(value)) {
49
+ throw new OnlyneError('invalid_request', t('config.object', { field }));
50
+ }
51
+ return value as Record<string, Json>;
52
+ };
53
+
54
+ const requireString = (container: Record<string, unknown>, field: string): string => {
55
+ const value = container[field];
56
+ if (typeof value !== 'string' || value.length === 0) {
57
+ throw new OnlyneError('invalid_request', t('config.string', { field }));
58
+ }
59
+ return value;
60
+ };
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
+
119
+ /** `language` is exactly `en` or `zh-CN`: a near miss would silently pick a different console language. */
120
+ const parseLanguage = (value: Json | undefined): Locale | undefined => {
121
+ if (value === undefined) return undefined;
122
+ if (value === 'en' || value === 'zh-CN') return value;
123
+ throw new OnlyneError('invalid_request', t('config.language', { value: JSON.stringify(value) }));
124
+ };
125
+
126
+ /** Loads the gateway configuration from one JSON file; environment retuning comes later. */
127
+ export async function loadConfig(path: string): Promise<GatewayConfig> {
128
+ let file: Json | undefined;
129
+ try {
130
+ file = JSON.parse(await Bun.file(path).text()) as Json;
131
+ } catch {
132
+ throw new OnlyneError('invalid_request', t('config.unreadable', { path }));
133
+ }
134
+ const raw = requireObject(file, 'the configuration root');
135
+ const node = requireObject(raw.node, 'node');
136
+ const server = requireObject(raw.server, 'server');
137
+ const storage = requireObject(raw.storage, 'storage');
138
+ const language = parseLanguage(raw.language);
139
+ const owner = requireString(node, 'id') as NodeId;
140
+ const rawServers = raw.servers === undefined ? [] : raw.servers;
141
+ if (!Array.isArray(rawServers)) {
142
+ throw new OnlyneError('invalid_request', t('config.servers'));
143
+ }
144
+ const servers = rawServers.map((entry, index): ServerConfig => {
145
+ const item = requireObject(entry, `servers[${index}]`);
146
+ const rawHeaders = item.headers === undefined ? undefined : requireObject(item.headers, `servers[${index}].headers`);
147
+ const headers = rawHeaders === undefined
148
+ ? undefined
149
+ : Object.fromEntries(Object.entries(rawHeaders).filter((pair): pair is [string, string] => typeof pair[1] === 'string'));
150
+ const timeout = item.timeout_ms;
151
+ const controllers = item.controllers;
152
+ return {
153
+ name: requireString(item, 'name'),
154
+ url: requireString(item, 'url'),
155
+ ...(headers === undefined || Object.keys(headers).length === 0 ? {} : { headers }),
156
+ ...(typeof timeout === 'number' ? { timeout_ms: timeout } : {}),
157
+ ...(Array.isArray(controllers)
158
+ ? { controllers: controllers.filter((value): value is NodeId => typeof value === 'string' && value.length > 0) }
159
+ : {}),
160
+ };
161
+ });
162
+ const names = new Set<string>();
163
+ for (const entry of servers) {
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
+ }
168
+ names.add(entry.name);
169
+ }
170
+ const mounts = parseMounts(raw.mounts, owner, names);
171
+ return {
172
+ node: { id: owner },
173
+ server: {
174
+ bind: typeof server.bind === 'string' && server.bind.length > 0 ? server.bind : '127.0.0.1',
175
+ port: typeof server.port === 'number' ? server.port : 0,
176
+ },
177
+ storage_path: requireString(storage, 'path'),
178
+ servers,
179
+ ...(mounts === undefined ? {} : { mounts }),
180
+ ...(language === undefined ? {} : { language }),
181
+ };
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
+ }