@kubilaycakmak/projectfreeze 0.0.0-stage → 0.1.0-dev.5
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 +21 -0
- package/README.md +50 -2
- package/bin/projectfreeze.js +4 -0
- package/docs/interfaces.md +98 -0
- package/docs/operations.md +42 -0
- package/examples/demo.js +72 -0
- package/examples/stream-test.mjs +71 -0
- package/examples/workload.js +29 -0
- package/helpers/.gitkeep +0 -0
- package/helpers/projectfreeze-darwin-arm64 +0 -0
- package/helpers/projectfreeze-darwin-arm64.sha256 +1 -0
- package/helpers/projectfreeze-darwin-x64 +0 -0
- package/helpers/projectfreeze-darwin-x64.sha256 +1 -0
- package/helpers/projectfreeze-linux-arm64 +0 -0
- package/helpers/projectfreeze-linux-arm64.sha256 +1 -0
- package/helpers/projectfreeze-linux-x64 +0 -0
- package/helpers/projectfreeze-linux-x64.sha256 +1 -0
- package/helpers/projectfreeze-win32-arm64.exe +0 -0
- package/helpers/projectfreeze-win32-arm64.exe.sha256 +1 -0
- package/helpers/projectfreeze-win32-x64.exe +0 -0
- package/helpers/projectfreeze-win32-x64.exe.sha256 +1 -0
- package/package.json +17 -4
- package/src/cli.js +135 -0
- package/src/contracts.d.ts +28 -0
- package/src/discovery.js +112 -0
- package/src/engine.js +820 -0
- package/src/errors.js +20 -0
- package/src/integrations.js +195 -0
- package/src/inventory.js +37 -0
- package/src/ipc.js +258 -0
- package/src/line-menu.js +147 -0
- package/src/mcp.js +113 -0
- package/src/platform.js +320 -0
- package/src/recipe.js +69 -0
- package/src/store.js +61 -0
- package/src/supervisors.js +163 -0
- package/src/systemd.js +104 -0
- package/src/tui.js +596 -0
- package/src/windows-supervisors.ps1 +83 -0
package/src/mcp.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline';
|
|
2
|
+
import manifest from '../package.json' with { type: 'json' };
|
|
3
|
+
import { problem } from './errors.js';
|
|
4
|
+
import { request, waitOperation } from './ipc.js';
|
|
5
|
+
|
|
6
|
+
const id = { type: 'string' };
|
|
7
|
+
const ids = { type: 'array', items: id, minItems: 1, maxItems: 32 };
|
|
8
|
+
const identity = { type: 'object', properties: { pid: { type: 'integer', minimum: 2 }, birth: id, boot: id }, required: ['pid', 'birth', 'boot'] };
|
|
9
|
+
const recipe = { type: 'object', properties: {
|
|
10
|
+
argv: { type: 'array', items: id, minItems: 1 }, cwd: id, instance: id, env: { type: 'array', items: id }, dependencies: ids,
|
|
11
|
+
stop: { type: 'array', items: id }, timeout: { type: 'integer', minimum: 100, maximum: 120000 },
|
|
12
|
+
ready: { type: 'object', properties: { address: { enum: ['127.0.0.1', '::1'] }, port: { type: 'integer', minimum: 1, maximum: 65535 } }, required: ['address', 'port'] },
|
|
13
|
+
restart: { enum: ['never', 'on-failure'] }, cgroup: id,
|
|
14
|
+
containers: { type: 'array', items: { type: 'object', properties: { project: id, service: id, shared: { type: 'boolean' } }, required: ['project', 'service', 'shared'] } },
|
|
15
|
+
systemd: { type: 'array', items: { type: 'object', properties: { unit: id, shared: { type: 'boolean' } }, required: ['unit', 'shared'] } },
|
|
16
|
+
supervisors: { type: 'array', maxItems: 12, items: { type: 'object', properties: {
|
|
17
|
+
kind: { enum: ['launchd', 'scm', 'task'] }, name: id, shared: { type: 'boolean' }, plist: id
|
|
18
|
+
}, required: ['kind', 'name', 'shared'], additionalProperties: false } }
|
|
19
|
+
}, required: ['argv', 'cwd'], additionalProperties: false };
|
|
20
|
+
const tool = (name, description, properties = {}, required = [], mutation = false) => ({
|
|
21
|
+
name, description, inputSchema: { type: 'object', properties, required, additionalProperties: false },
|
|
22
|
+
annotations: { readOnlyHint: !mutation, destructiveHint: mutation, openWorldHint: false }, mutation
|
|
23
|
+
});
|
|
24
|
+
const tools = [
|
|
25
|
+
tool('list', 'List projects and live resources in local authorized scope.'),
|
|
26
|
+
tool('discover', 'Find candidate identities and evidence for explicit adoption.'),
|
|
27
|
+
tool('listeners', 'List TCP listeners of locally observable processes, including unassigned listeners.'),
|
|
28
|
+
tool('ports', 'List local TCP listeners and bound UDP sockets from OS tables.'),
|
|
29
|
+
tool('services', 'List running services visible to the local service managers.'),
|
|
30
|
+
tool('inspect', 'Inspect resource identities, candidates, and recent operations.', { id }, ['id']),
|
|
31
|
+
tool('history', 'Read recent durable operations.'),
|
|
32
|
+
tool('operation', 'Read one operation and its per-resource outcomes.', { id }, ['id']),
|
|
33
|
+
tool('cancel', 'Request cancellation at the next resource boundary.', { id }, ['id'], true),
|
|
34
|
+
tool('adopt', 'Associate reviewed identities with a project. Obtain user approval for the exact set.',
|
|
35
|
+
{ id, cwd: id, members: { type: 'array', items: identity, minItems: 1 }, exclude: { type: 'array', items: identity }, confirm: { const: true }, recipe }, ['cwd', 'members', 'confirm'], true),
|
|
36
|
+
tool('run', 'Launch a reviewed local executable with literal arguments.', { recipe }, ['recipe'], true),
|
|
37
|
+
tool('freeze', 'Suspend owned resources and preserve memory and bound sockets.', { ids }, ['ids'], true),
|
|
38
|
+
tool('resume', 'Release suspensions recorded by ProjectFreeze.', { ids }, ['ids'], true),
|
|
39
|
+
tool('sleep', 'Gracefully stop owned resources. Force requires specific user approval.', { ids, force: { type: 'boolean' } }, ['ids'], true),
|
|
40
|
+
tool('wake', 'Restart from durable launch recipes.', { ids }, ['ids'], true),
|
|
41
|
+
tool('checkpoint', 'Capture process state using a supported local backend.', { ids, backend: { enum: ['criu', 'docker'] } }, ['ids'], true),
|
|
42
|
+
tool('restore', 'Restore a verified local checkpoint.', { ids, checkpoint: id }, ['ids', 'checkpoint'], true),
|
|
43
|
+
tool('reconcile', 'Review durable ownership against the current host state.', { ids }, [], true)
|
|
44
|
+
];
|
|
45
|
+
const byName = new Map(tools.map(entry => [entry.name, entry]));
|
|
46
|
+
const versions = new Set(['2024-11-05', '2025-03-26', '2025-06-18', '2025-11-25']);
|
|
47
|
+
const object = value => value && typeof value === 'object' && !Array.isArray(value);
|
|
48
|
+
|
|
49
|
+
export async function serve() {
|
|
50
|
+
const lines = createInterface({ input: process.stdin, crlfDelay: Infinity, terminal: false });
|
|
51
|
+
const active = new Map();
|
|
52
|
+
let initialized = false;
|
|
53
|
+
let negotiated = false;
|
|
54
|
+
const send = msg => process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', ...msg })}\n`);
|
|
55
|
+
const rpcError = (id, code, message) => send({ id, error: { code, message } });
|
|
56
|
+
lines.on('close', () => { for (const entry of active.values()) entry.abort.abort(); });
|
|
57
|
+
lines.on('line', raw => {
|
|
58
|
+
let message;
|
|
59
|
+
if (Buffer.byteLength(raw) > 128 * 1024) { rpcError(null, -32600, 'Request exceeds size limit.'); return; }
|
|
60
|
+
try { message = JSON.parse(raw); } catch { rpcError(null, -32700, 'Invalid JSON.'); return; }
|
|
61
|
+
if (!object(message) || message.jsonrpc !== '2.0' || typeof message.method !== 'string') { rpcError(null, -32600, 'Invalid request.'); return; }
|
|
62
|
+
const { id, method, params } = message;
|
|
63
|
+
if (id === undefined) {
|
|
64
|
+
if (method === 'notifications/initialized' && negotiated) initialized = true;
|
|
65
|
+
if (method === 'notifications/cancelled') {
|
|
66
|
+
const entry = active.get(params?.requestId);
|
|
67
|
+
entry?.abort.abort();
|
|
68
|
+
if (entry?.operation) void request('cancel', { id: entry.operation }).catch(() => {});
|
|
69
|
+
}
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (!['number', 'string'].includes(typeof id)) { rpcError(null, -32600, 'Invalid request ID.'); return; }
|
|
73
|
+
const result = data => send({ id, result: data });
|
|
74
|
+
if (method === 'initialize') {
|
|
75
|
+
if (!params || typeof params.protocolVersion !== 'string' || negotiated) { rpcError(id, -32602, 'Invalid initialize parameters.'); return; }
|
|
76
|
+
negotiated = true;
|
|
77
|
+
result({ protocolVersion: versions.has(params.protocolVersion) ? params.protocolVersion : '2025-11-25',
|
|
78
|
+
serverInfo: { name: 'projectfreeze', version: manifest.version }, capabilities: { tools: {} },
|
|
79
|
+
instructions: 'Use discover and inspect before adopting or controlling a project. Obtain specific approval for ambiguous adoption or force termination.' });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (method === 'ping') { result({}); return; }
|
|
83
|
+
if (!initialized) { rpcError(id, -32002, 'Server has not initialized.'); return; }
|
|
84
|
+
if (method === 'tools/list') { result({ tools: tools.map(({ mutation, ...entry }) => entry) }); return; }
|
|
85
|
+
if (method !== 'tools/call') { rpcError(id, -32601, 'Unknown method.'); return; }
|
|
86
|
+
const entry = byName.get(params?.name);
|
|
87
|
+
const input = params?.arguments ?? {};
|
|
88
|
+
if (!entry || !object(input) || Object.keys(input).some(key => !Object.hasOwn(entry.inputSchema.properties, key)) ||
|
|
89
|
+
entry.inputSchema.required.some(key => !Object.hasOwn(input, key))) { rpcError(id, -32602, 'Invalid tool or arguments.'); return; }
|
|
90
|
+
if (active.has(id) || active.size >= 32) { rpcError(id, -32000, 'Too many requests or duplicate request ID.'); return; }
|
|
91
|
+
const context = { abort: new AbortController(), operation: undefined };
|
|
92
|
+
active.set(id, context);
|
|
93
|
+
void (async () => {
|
|
94
|
+
let data = await request(entry.name, input);
|
|
95
|
+
if (entry.mutation && entry.name !== 'cancel') {
|
|
96
|
+
context.operation = data.id;
|
|
97
|
+
if (context.abort.signal.aborted) await request('cancel', { id: data.id }).catch(() => {});
|
|
98
|
+
const token = params?._meta?.progressToken;
|
|
99
|
+
let progress = 0;
|
|
100
|
+
data = await waitOperation(data.id, op => {
|
|
101
|
+
if (!context.abort.signal.aborted && ['string', 'number'].includes(typeof token)) {
|
|
102
|
+
send({ method: 'notifications/progress', params: { progressToken: token, progress: ++progress, message: op.pending ?? op.status } });
|
|
103
|
+
}
|
|
104
|
+
}, context.abort.signal);
|
|
105
|
+
}
|
|
106
|
+
const payload = { schemaVersion: 1, data };
|
|
107
|
+
if (!context.abort.signal.aborted) result({ content: [{ type: 'text', text: JSON.stringify(payload) }], structuredContent: payload,
|
|
108
|
+
...(entry.mutation && !['complete', undefined].includes(data.status) ? { isError: true } : {}) });
|
|
109
|
+
})().catch(error => {
|
|
110
|
+
if (!context.abort.signal.aborted) result({ isError: true, content: [{ type: 'text', text: JSON.stringify({ schemaVersion: 1, error: problem(error) }) }] });
|
|
111
|
+
}).finally(() => active.delete(id));
|
|
112
|
+
});
|
|
113
|
+
}
|
package/src/platform.js
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { access, readFile, readdir, readlink, realpath, stat, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { constants, statSync } from 'node:fs';
|
|
5
|
+
import { basename, delimiter, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
8
|
+
import { createInterface } from 'node:readline';
|
|
9
|
+
import { Fault } from './errors.js';
|
|
10
|
+
|
|
11
|
+
const exec = promisify(execFile);
|
|
12
|
+
export const windows = process.platform === 'win32';
|
|
13
|
+
export const linux = process.platform === 'linux';
|
|
14
|
+
const name = `projectfreeze-${process.platform}-${process.arch}${windows ? '.exe' : ''}`;
|
|
15
|
+
export const helper = fileURLToPath(new URL(`../helpers/${name}`, import.meta.url));
|
|
16
|
+
const cliPath = fileURLToPath(new URL('../bin/projectfreeze.js', import.meta.url));
|
|
17
|
+
const samples = new Map();
|
|
18
|
+
const knownStates = new Set(['R', 'S', 'D', 'T', 't', 'I', 'M', '?', 'P', 'Z', 'X']);
|
|
19
|
+
const ignorePids = new Set([process.pid]);
|
|
20
|
+
|
|
21
|
+
export function identity(p) { return { pid: p.pid, birth: p.birth, boot: p.boot }; }
|
|
22
|
+
export function key(p) { return `${p.boot}/${p.pid}/${p.birth}`; }
|
|
23
|
+
export function matches(a, b) { return a && b && key(a) === key(b); }
|
|
24
|
+
export function ignored(pid) { return ignorePids.has(pid); }
|
|
25
|
+
|
|
26
|
+
export async function command(file, args, options = {}) {
|
|
27
|
+
try {
|
|
28
|
+
return (await exec(file, args, { timeout: 12000, maxBuffer: 16 * 1024 * 1024, windowsHide: true, ...options })).stdout;
|
|
29
|
+
} catch (error) {
|
|
30
|
+
const code = ({ ENOENT: 'UNAVAILABLE', EACCES: 'DENIED', 2: 'USAGE', 3: 'NOT_FOUND', 4: 'DENIED', 5: 'UNSUPPORTED', 6: 'BUSY' })[error.code] ?? 'PARTIAL';
|
|
31
|
+
throw new Fault(code, `Execution of ${basename(file)} failed.`, { exit: error.code });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let checked;
|
|
36
|
+
export function checkHelper() {
|
|
37
|
+
return checked ??= Promise.all([readFile(helper), readFile(`${helper}.sha256`, 'utf8')]).then(([bytes, manifest]) => {
|
|
38
|
+
if (createHash('sha256').update(bytes).digest('hex') !== manifest.split(' ')[0]) {
|
|
39
|
+
throw new Fault('DENIED', 'Native helper integrity check failed. Reinstall the package.');
|
|
40
|
+
}
|
|
41
|
+
}, () => { throw new Fault('UNAVAILABLE', `Missing native helper for ${process.platform}/${process.arch}. Install a release tarball or run npm run build:native.`); });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let boot;
|
|
45
|
+
export function bootId() {
|
|
46
|
+
return boot ??= (linux ? readFile('/proc/sys/kernel/random/boot_id', 'utf8') : windows
|
|
47
|
+
? command(helper, ['boot'])
|
|
48
|
+
: command('/usr/sbin/sysctl', ['-n', 'kern.bootsessionuuid'])).then(value => {
|
|
49
|
+
const id = value.trim();
|
|
50
|
+
if (!/^[a-f0-9-]{12,80}$/i.test(id)) throw new Fault('UNAVAILABLE', 'A stable boot identity could not be verified.');
|
|
51
|
+
return id;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let linuxUnits;
|
|
56
|
+
function units() {
|
|
57
|
+
return linuxUnits ??= Promise.all([command('/usr/bin/getconf', ['CLK_TCK']), command('/usr/bin/getconf', ['PAGESIZE'])])
|
|
58
|
+
.then(values => values.map(Number));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function linuxProcess(pid, boot, clock, pageSize, full) {
|
|
62
|
+
const base = `/proc/${pid}`;
|
|
63
|
+
try {
|
|
64
|
+
if ((await stat(base)).uid !== process.getuid()) return null;
|
|
65
|
+
const raw = await readFile(`${base}/stat`, 'utf8');
|
|
66
|
+
const parts = raw.slice(raw.lastIndexOf(')') + 2).trim().split(/\s+/);
|
|
67
|
+
if (!knownStates.has(parts[0]) || parts[0] === 'Z' || parts[0] === 'X') return null;
|
|
68
|
+
const row = { pid, boot, birth: parts[19], parent: +parts[1], group: +parts[2], session: +parts[3], state: parts[0],
|
|
69
|
+
cpuTime: (+parts[11] + +parts[12]) * 1e9 / clock, rss: +parts[21] * pageSize, ports: [] };
|
|
70
|
+
if (full) {
|
|
71
|
+
const [cwd, image, args] = await Promise.all([
|
|
72
|
+
readlink(`${base}/cwd`).catch(() => undefined), readlink(`${base}/exe`).catch(() => undefined),
|
|
73
|
+
readFile(`${base}/cmdline`, 'utf8').catch(() => '')
|
|
74
|
+
]);
|
|
75
|
+
row.cwd = cwd;
|
|
76
|
+
row.image = image;
|
|
77
|
+
row.projectHint = argumentHint(args.split('\0'), cwd);
|
|
78
|
+
const current = await readFile(`${base}/stat`, 'utf8');
|
|
79
|
+
if (current.slice(current.lastIndexOf(')') + 2).split(' ')[19] !== row.birth) return null;
|
|
80
|
+
}
|
|
81
|
+
return row;
|
|
82
|
+
} catch { return null; }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function argumentHint(args, cwd) {
|
|
86
|
+
const path = args.slice(1, 8).find(value => /\.(?:[cm]?js|tsx?|py|rb|jar|php)$/i.test(value) &&
|
|
87
|
+
!value.startsWith('-') && (isAbsolute(value) || cwd));
|
|
88
|
+
return path ? resolve(cwd ?? '.', path) : undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function parseAddress(hex) {
|
|
92
|
+
const bytes = Buffer.from(hex, 'hex');
|
|
93
|
+
if (bytes.length === 4) return [...bytes.reverse()].join('.');
|
|
94
|
+
if (bytes.length !== 16) return undefined;
|
|
95
|
+
for (let i = 0; i < 16; i += 4) bytes.subarray(i, i + 4).reverse();
|
|
96
|
+
return Array.from({ length: 8 }, (_, i) => bytes.readUInt16BE(i * 2).toString(16)).join(':');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function procSockets(directory, protocols) {
|
|
100
|
+
const tables = await Promise.all(protocols.map(async table => {
|
|
101
|
+
const protocol = table.slice(0, 3);
|
|
102
|
+
const data = await readFile(`${directory}/${table}`, 'utf8').catch(error => {
|
|
103
|
+
if (error.code !== 'ENOENT' || table === 'tcp') throw error;
|
|
104
|
+
return '';
|
|
105
|
+
});
|
|
106
|
+
return data.trimEnd().split('\n').slice(1).flatMap(line => {
|
|
107
|
+
const fields = line.trim().split(/\s+/);
|
|
108
|
+
if (!fields[1] || (protocol === 'tcp' && fields[3] !== '0A')) return [];
|
|
109
|
+
const [host, hexPort] = fields[1].split(':');
|
|
110
|
+
const address = parseAddress(host);
|
|
111
|
+
const port = Number.parseInt(hexPort, 16);
|
|
112
|
+
return address && port ? [{ protocol, address, port, socket: `socket:[${fields[9]}]` }] : [];
|
|
113
|
+
});
|
|
114
|
+
}));
|
|
115
|
+
return tables.flat();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function socketLinks(pid) {
|
|
119
|
+
const path = `/proc/${pid}/fd`;
|
|
120
|
+
const descriptors = await readdir(path).catch(() => []);
|
|
121
|
+
return new Set(await Promise.all(descriptors.map(fd => readlink(`${path}/${fd}`).catch(() => ''))));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function linuxPorts(rows) {
|
|
125
|
+
const namespaces = new Map();
|
|
126
|
+
await Promise.all(rows.map(async row => {
|
|
127
|
+
const ns = await readlink(`/proc/${row.pid}/ns/net`).catch(() => undefined);
|
|
128
|
+
if (ns) namespaces.set(ns, [...(namespaces.get(ns) ?? []), row]);
|
|
129
|
+
}));
|
|
130
|
+
for (const group of namespaces.values()) {
|
|
131
|
+
const entries = await procSockets(`/proc/${group[0].pid}/net`, ['tcp', 'tcp6']).catch(() => []);
|
|
132
|
+
const sockets = new Map(entries.map(({ address, port, socket }) => [socket, { address, port }]));
|
|
133
|
+
for (const row of group) {
|
|
134
|
+
row.ports = [...await socketLinks(row.pid)].map(link => sockets.get(link)).filter(Boolean);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function windowsSockets(protocol) {
|
|
140
|
+
const net = await command(helper, [protocol === 'tcp' ? 'listeners' : 'udp-sockets']);
|
|
141
|
+
return net.trimEnd().split(/\r?\n/).filter(Boolean).map(line => {
|
|
142
|
+
const [pid, address, port] = line.split('\t');
|
|
143
|
+
return { protocol, address, port: +port, pid: +pid || null };
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function localPorts(rows) {
|
|
148
|
+
if (windows) return (await Promise.all(['tcp', 'udp'].map(windowsSockets))).flat();
|
|
149
|
+
if (linux) {
|
|
150
|
+
const entries = await procSockets('/proc/net', ['tcp', 'tcp6', 'udp', 'udp6']);
|
|
151
|
+
const namespace = await readlink('/proc/self/ns/net');
|
|
152
|
+
const bySocket = new Map();
|
|
153
|
+
for (const { pid } of rows) {
|
|
154
|
+
if (await readlink(`/proc/${pid}/ns/net`).catch(() => '') !== namespace) continue;
|
|
155
|
+
for (const socket of await socketLinks(pid)) {
|
|
156
|
+
if (socket.startsWith('socket:[')) bySocket.set(socket, [...(bySocket.get(socket) ?? []), pid]);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return entries.flatMap(({ socket, ...endpoint }) => (bySocket.get(socket) ?? [null]).map(pid => ({ ...endpoint, pid })));
|
|
160
|
+
}
|
|
161
|
+
const output = (await Promise.all(['inet', 'inet6'].map(family => command('/usr/sbin/netstat', ['-anW', '-f', family])))).join('\n');
|
|
162
|
+
return output.split('\n').flatMap(line => {
|
|
163
|
+
const [kind, , , endpoint, , state] = line.trim().split(/\s+/);
|
|
164
|
+
if (!/^(?:tcp|udp)[46]+$/.test(kind) || (kind.startsWith('tcp') && state !== 'LISTEN')) return [];
|
|
165
|
+
const parsed = endpoint?.match(/^(.+)\.(\d+)$/);
|
|
166
|
+
if (!parsed) return [];
|
|
167
|
+
const [, address, port] = parsed;
|
|
168
|
+
const protocol = kind.slice(0, 3);
|
|
169
|
+
const owners = protocol === 'tcp' ? rows.filter(row => row.ports.some(value => value.port === +port && value.address === address)) : [];
|
|
170
|
+
return (owners.length ? owners.map(row => row.pid) : [null]).map(pid => ({ protocol, address, port: +port, pid }));
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function decode(text, encoding) { return text ? Buffer.from(text, encoding).toString('utf8') : undefined; }
|
|
175
|
+
|
|
176
|
+
async function nonLinuxRows(boot, full) {
|
|
177
|
+
const result = await command(helper, [windows && full ? 'discover' : 'scan']);
|
|
178
|
+
const rows = result.split('\n').filter(Boolean).map(line => {
|
|
179
|
+
const fields = line.replace(/\r$/, '').split('\t');
|
|
180
|
+
if (windows) {
|
|
181
|
+
const [pid, parent, birth, state, cpuTime, rss, image, hint] = fields;
|
|
182
|
+
return { pid: +pid, parent: +parent, group: 0, session: 0, boot, birth, state, cpuTime: +cpuTime,
|
|
183
|
+
rss: +rss < 0 ? undefined : +rss, image: decode(image, 'base64'), projectHint: decode(hint, 'base64'), ports: [] };
|
|
184
|
+
}
|
|
185
|
+
const [pid, parent, group, session, birth, state, cpuTime, rss, image, cwd] = fields;
|
|
186
|
+
return { pid: +pid, parent: +parent, group: +group, session: +session, boot, birth, state, cpuTime: +cpuTime, rss: +rss,
|
|
187
|
+
image: decode(image, 'hex'), cwd: decode(cwd, 'hex'), ports: [] };
|
|
188
|
+
});
|
|
189
|
+
if (!full) return rows;
|
|
190
|
+
const byPid = new Map(rows.map(row => [row.pid, row]));
|
|
191
|
+
if (windows) {
|
|
192
|
+
for (const { pid, address, port } of await windowsSockets('tcp')) byPid.get(pid)?.ports.push({ address, port });
|
|
193
|
+
} else {
|
|
194
|
+
const sockets = await command('/usr/sbin/lsof', ['-nP', '-iTCP', '-sTCP:LISTEN', '-Fpn']).catch(() => '');
|
|
195
|
+
let pid = 0;
|
|
196
|
+
for (const line of sockets.split('\n')) {
|
|
197
|
+
if (line.startsWith('p')) pid = Number(line.slice(1));
|
|
198
|
+
const match = line.match(/^n(.+):(\d+)$/);
|
|
199
|
+
if (match) byPid.get(pid)?.ports.push({ address: match[1].replace(/^\[|\]$/g, ''), port: +match[2] });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return rows;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function scan(full = true, stagedPid) {
|
|
206
|
+
await checkHelper();
|
|
207
|
+
const boot = await bootId();
|
|
208
|
+
let rows;
|
|
209
|
+
if (linux) {
|
|
210
|
+
const [clock, page] = await units();
|
|
211
|
+
const ids = (await readdir('/proc')).filter(name => /^\d+$/.test(name)).map(Number);
|
|
212
|
+
rows = [];
|
|
213
|
+
for (let i = 0; i < ids.length; i += 48) {
|
|
214
|
+
rows.push(...(await Promise.all(ids.slice(i, i + 48).map(pid => linuxProcess(pid, boot, clock, page, full)))).filter(Boolean));
|
|
215
|
+
}
|
|
216
|
+
if (full) await linuxPorts(rows);
|
|
217
|
+
} else rows = await nonLinuxRows(boot, full);
|
|
218
|
+
rows = rows.filter(row => row.pid > 1 && !ignored(row.pid) && (row.pid === stagedPid ||
|
|
219
|
+
(row.image !== helper && !/^launcher-[a-f0-9]{64}\.exe$/i.test(basename(row.image ?? '')))) && row.projectHint !== cliPath);
|
|
220
|
+
if (full) {
|
|
221
|
+
const time = performance.now();
|
|
222
|
+
const current = new Set();
|
|
223
|
+
for (const row of rows) {
|
|
224
|
+
const id = key(row);
|
|
225
|
+
current.add(id);
|
|
226
|
+
const previous = samples.get(id);
|
|
227
|
+
if (previous && time > previous.time && row.cpuTime >= previous.cpu) {
|
|
228
|
+
row.cpuPercent = (row.cpuTime - previous.cpu) / ((time - previous.time) * 1e4);
|
|
229
|
+
}
|
|
230
|
+
samples.set(id, { time, cpu: row.cpuTime });
|
|
231
|
+
}
|
|
232
|
+
for (const id of samples.keys()) if (!current.has(id)) samples.delete(id);
|
|
233
|
+
}
|
|
234
|
+
return rows;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export async function control(action, target) {
|
|
238
|
+
if (target.boot !== await bootId() || ignored(target.pid)) throw new Fault('NOT_FOUND', 'The process identity is stale or protected.');
|
|
239
|
+
try { await command(helper, [action, String(target.pid), target.birth]); }
|
|
240
|
+
catch (error) { if (error.details?.exit === 10) return false; throw error; }
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export async function executable(value, cwd, env) {
|
|
245
|
+
const path = env.PATH ?? env.Path ?? '';
|
|
246
|
+
const explicit = isAbsolute(value) || value.includes('/') || value.includes('\\');
|
|
247
|
+
const candidates = explicit ? [resolve(cwd, value)] : path.split(delimiter).filter(isAbsolute).map(dir => join(dir, value));
|
|
248
|
+
for (const candidate of candidates) {
|
|
249
|
+
for (const file of windows && !/\.(exe|com)$/i.test(candidate) ? [`${candidate}.exe`, `${candidate}.com`] : [candidate]) {
|
|
250
|
+
try {
|
|
251
|
+
const absolute = await realpath(file);
|
|
252
|
+
await access(absolute, constants.X_OK);
|
|
253
|
+
if (statSync(absolute).isFile()) return absolute;
|
|
254
|
+
} catch { /* A later PATH entry may be executable. */ }
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
throw new Fault('UNAVAILABLE', 'The recipe executable could not be resolved. Use an absolute executable or add it to PATH.');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export async function stageLaunch(argv, cwd, env, job, cgroup, statusFile) {
|
|
261
|
+
await checkHelper();
|
|
262
|
+
const file = await executable(argv[0], cwd, env);
|
|
263
|
+
const marker = windows ? job : cgroup ? join(cgroup, 'cgroup.procs') : '-';
|
|
264
|
+
let launcher = helper;
|
|
265
|
+
if (windows) {
|
|
266
|
+
const bytes = await readFile(helper);
|
|
267
|
+
const hash = createHash('sha256').update(bytes).digest('hex');
|
|
268
|
+
launcher = join(dirname(statusFile), `launcher-${hash}.exe`);
|
|
269
|
+
await writeFile(launcher, bytes, { flag: 'wx' }).catch(async error => {
|
|
270
|
+
if (error.code !== 'EEXIST' || createHash('sha256').update(await readFile(launcher)).digest('hex') !== hash) throw error;
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
const child = spawn(launcher, ['launch', marker, statusFile, file, ...argv.slice(1)], { cwd, env, windowsHide: true, detached: true,
|
|
274
|
+
stdio: ['pipe', 'pipe', 'ignore'] });
|
|
275
|
+
child.once('exit', () => { if (child.pid) ignorePids.delete(child.pid); });
|
|
276
|
+
child.stdin.on('error', () => {});
|
|
277
|
+
if (child.pid) ignorePids.add(child.pid);
|
|
278
|
+
const lines = createInterface({ input: child.stdout, terminal: false });
|
|
279
|
+
let timer;
|
|
280
|
+
try {
|
|
281
|
+
const ready = await new Promise((resolve, reject) => {
|
|
282
|
+
timer = setTimeout(() => reject(new Fault('UNAVAILABLE', 'Native launch handshake timed out.')), 6000);
|
|
283
|
+
child.once('error', () => reject(new Fault('UNAVAILABLE', 'Native launcher could not start.')));
|
|
284
|
+
lines.once('line', resolve);
|
|
285
|
+
lines.once('close', () => reject(new Fault('DENIED', 'Native launcher could not prepare the workload.')));
|
|
286
|
+
});
|
|
287
|
+
const pid = Number(ready.split('\t')[0]);
|
|
288
|
+
const root = (await scan(false, pid)).find(row => row.pid === pid);
|
|
289
|
+
if (!root || (windows && root.birth !== ready.split('\t')[1]) || (!windows && root.group !== pid)) {
|
|
290
|
+
throw new Fault('PARTIAL', 'Launch identity could not be verified.');
|
|
291
|
+
}
|
|
292
|
+
return { root: identity(root), release() { child.stdin.end('G'); child.unref(); }, abort() { child.stdin.end(); } };
|
|
293
|
+
} catch (error) {
|
|
294
|
+
child.stdin.end();
|
|
295
|
+
throw error;
|
|
296
|
+
} finally { clearTimeout(timer); lines.close(); child.stdout.destroy(); }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export async function containerPids(project) {
|
|
300
|
+
if (windows && project.job) {
|
|
301
|
+
return new Set((await command(helper, ['job-scan', project.job])).split('\n').filter(Boolean).map(line => {
|
|
302
|
+
const [pid, , birth] = line.split('\t');
|
|
303
|
+
return key({ pid: Number(pid), birth, boot: project.root.boot });
|
|
304
|
+
}));
|
|
305
|
+
}
|
|
306
|
+
if (linux && project.cgroup) {
|
|
307
|
+
const walk = async dir => {
|
|
308
|
+
const rows = await readFile(join(dir, 'cgroup.procs'), 'utf8');
|
|
309
|
+
const directories = (await readdir(dir, { withFileTypes: true })).filter(item => item.isDirectory());
|
|
310
|
+
return [...rows.trim().split(/\s+/).map(Number), ...(await Promise.all(directories.map(item => walk(join(dir, item.name))))).flat()];
|
|
311
|
+
};
|
|
312
|
+
return new Set(await walk(project.cgroup));
|
|
313
|
+
}
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function manifestStart(p) {
|
|
318
|
+
if (p.projectHint) return dirname(p.projectHint);
|
|
319
|
+
return p.cwd;
|
|
320
|
+
}
|
package/src/recipe.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { readFile, realpath, mkdir, statfs } from 'node:fs/promises';
|
|
2
|
+
import { isAbsolute, join } from 'node:path';
|
|
3
|
+
import { Fault } from './errors.js';
|
|
4
|
+
import { directory } from './discovery.js';
|
|
5
|
+
import { linux } from './platform.js';
|
|
6
|
+
import { validSystemd } from './systemd.js';
|
|
7
|
+
import { supervisorPolicies } from './supervisors.js';
|
|
8
|
+
|
|
9
|
+
const envName = /^[A-Za-z_][A-Za-z_0-9]*$/;
|
|
10
|
+
const projectName = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/;
|
|
11
|
+
const platformEnv = new Set(['PATH', 'HOME', 'USER', 'LOGNAME', 'USERPROFILE', 'SYSTEMROOT', 'WINDIR', 'SYSTEMDRIVE',
|
|
12
|
+
'TEMP', 'TMP', 'TMPDIR', 'LANG', 'LC_ALL', 'APPDATA', 'LOCALAPPDATA', 'COMSPEC', 'PATHEXT']);
|
|
13
|
+
const argv = (values) => Array.isArray(values) && values.length > 0 && values.length <= 128 &&
|
|
14
|
+
values.every(s => typeof s === 'string' && !s.includes('\0') && s.length <= 16384) && Boolean(values[0]);
|
|
15
|
+
|
|
16
|
+
export async function recipe(input) {
|
|
17
|
+
if (!input || !argv(input.argv)) throw new Fault('USAGE', 'Supply an executable and literal arguments.');
|
|
18
|
+
if (input.stop !== undefined && !argv(input.stop)) throw new Fault('USAGE', 'The shutdown command must be an argument array.');
|
|
19
|
+
const cwd = await directory(input.cwd ?? '.');
|
|
20
|
+
const { instance, env = [], dependencies = [], timeout = 10000, restart = 'never', ready, stop, cgroup, containers = [], systemd = [] } = input;
|
|
21
|
+
if (instance !== undefined && (typeof instance !== 'string' || !projectName.test(instance))) {
|
|
22
|
+
throw new Fault('USAGE', 'Use a workload instance name of up to 128 letters, digits, underscores, or hyphens, starting with a letter or digit.');
|
|
23
|
+
}
|
|
24
|
+
if (!Array.isArray(env) || !env.every(s => typeof s === 'string' && envName.test(s) && !s.startsWith('PROJECTFREEZE_'))) {
|
|
25
|
+
throw new Fault('USAGE', 'Environment references must be variable names.');
|
|
26
|
+
}
|
|
27
|
+
if (!Array.isArray(dependencies) || !dependencies.every(s => typeof s === 'string' && /^p-[a-f0-9]{16}$/.test(s))) {
|
|
28
|
+
throw new Fault('USAGE', 'Dependencies must be registered project IDs.');
|
|
29
|
+
}
|
|
30
|
+
if (!Array.isArray(containers) || containers.length > 24 || !containers.every(c => c && typeof c.project === 'string' && typeof c.service === 'string' &&
|
|
31
|
+
projectName.test(c.project) && projectName.test(c.service) && typeof c.shared === 'boolean') ||
|
|
32
|
+
new Set(containers.map(c => `${c.project}/${c.service}`)).size !== containers.length) {
|
|
33
|
+
throw new Fault('USAGE', 'Containers require a Compose project, service, and explicit shared policy.');
|
|
34
|
+
}
|
|
35
|
+
if (!validSystemd(systemd) || (!linux && systemd.length)) throw new Fault('USAGE', 'Configure exact Linux user systemd unit names and explicit shared policies.');
|
|
36
|
+
if (!Number.isInteger(timeout) || timeout < 100 || timeout > 120000 || !['never', 'on-failure'].includes(restart)) {
|
|
37
|
+
throw new Fault('USAGE', 'Choose a shutdown timeout from 100 to 120000 ms and restart never or on-failure.');
|
|
38
|
+
}
|
|
39
|
+
if (ready && (!['127.0.0.1', '::1'].includes(ready.address) || !Number.isInteger(ready.port) || ready.port < 1 || ready.port > 65535)) {
|
|
40
|
+
throw new Fault('USAGE', 'Readiness requires a loopback TCP address and port.');
|
|
41
|
+
}
|
|
42
|
+
if (cgroup !== undefined && (!linux || typeof cgroup !== 'string' || !isAbsolute(cgroup))) {
|
|
43
|
+
throw new Fault('USAGE', 'A cgroup requires an absolute, delegated cgroup v2 directory on Linux.');
|
|
44
|
+
}
|
|
45
|
+
return { argv: input.argv, cwd, instance, env: [...new Set(env)], dependencies: [...new Set(dependencies)], timeout, restart, ready, stop, cgroup,
|
|
46
|
+
containers, systemd, supervisors: supervisorPolicies(input.supervisors ?? []) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function launchEnv(recipe, available) {
|
|
50
|
+
const entries = Object.entries(available).filter(([name]) => platformEnv.has(name.toUpperCase()));
|
|
51
|
+
const env = Object.fromEntries(entries);
|
|
52
|
+
for (const name of recipe.env) {
|
|
53
|
+
if (!Object.hasOwn(available, name)) throw new Fault('UNAVAILABLE', `Required environment reference is missing: ${name}.`);
|
|
54
|
+
env[name] = available[name];
|
|
55
|
+
}
|
|
56
|
+
return env;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function prepareCgroup(parent, id) {
|
|
60
|
+
if (!parent) return undefined;
|
|
61
|
+
try {
|
|
62
|
+
const canonical = await realpath(parent);
|
|
63
|
+
if ((await statfs(canonical)).type !== 0x63677270) throw new Error();
|
|
64
|
+
const child = join(canonical, id);
|
|
65
|
+
await mkdir(child);
|
|
66
|
+
if ((await readFile(join(child, 'cgroup.procs'), 'utf8')).trim()) throw new Error();
|
|
67
|
+
return child;
|
|
68
|
+
} catch { throw new Fault('DENIED', 'Could not create an empty dedicated cgroup in the delegated cgroup v2 directory.'); }
|
|
69
|
+
}
|
package/src/store.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { Fault } from './errors.js';
|
|
4
|
+
|
|
5
|
+
export class Store {
|
|
6
|
+
constructor(directory) {
|
|
7
|
+
this.db = new DatabaseSync(join(directory, 'state.sqlite'));
|
|
8
|
+
this.db.exec('PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA trusted_schema=OFF; PRAGMA busy_timeout=3000;');
|
|
9
|
+
const version = this.db.prepare('PRAGMA user_version').get().user_version;
|
|
10
|
+
if (version > 1) throw new Fault('UNSUPPORTED', 'This daemon is older than the state database. Upgrade it first.');
|
|
11
|
+
this.db.exec(`BEGIN IMMEDIATE;
|
|
12
|
+
CREATE TABLE IF NOT EXISTS projects (id TEXT PRIMARY KEY, body TEXT NOT NULL) STRICT;
|
|
13
|
+
CREATE TABLE IF NOT EXISTS operations (id TEXT PRIMARY KEY, started TEXT NOT NULL, body TEXT NOT NULL) STRICT;
|
|
14
|
+
PRAGMA user_version=1;
|
|
15
|
+
COMMIT;`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
projects() { return this.db.prepare('SELECT body FROM projects ORDER BY id').all().map(row => JSON.parse(row.body)); }
|
|
19
|
+
project(id) {
|
|
20
|
+
const row = this.db.prepare('SELECT body FROM projects WHERE id = ?').get(id);
|
|
21
|
+
if (!row) throw new Fault('NOT_FOUND', 'Unknown project ID. Run list or discover first.');
|
|
22
|
+
return JSON.parse(row.body);
|
|
23
|
+
}
|
|
24
|
+
save(project) {
|
|
25
|
+
this.db.prepare('INSERT OR REPLACE INTO projects VALUES (?, ?)').run(project.id, JSON.stringify(project));
|
|
26
|
+
}
|
|
27
|
+
journal(operation) {
|
|
28
|
+
this.db.prepare('INSERT OR REPLACE INTO operations VALUES (?, ?, ?)')
|
|
29
|
+
.run(operation.id, operation.started, JSON.stringify(operation));
|
|
30
|
+
}
|
|
31
|
+
history(limit = 100) {
|
|
32
|
+
return this.db.prepare('SELECT body FROM operations ORDER BY started DESC LIMIT ?')
|
|
33
|
+
.all(limit).map(row => JSON.parse(row.body));
|
|
34
|
+
}
|
|
35
|
+
operation(id) {
|
|
36
|
+
const row = this.db.prepare('SELECT body FROM operations WHERE id = ?').get(id);
|
|
37
|
+
if (!row) throw new Fault('NOT_FOUND', 'Unknown operation ID.');
|
|
38
|
+
return JSON.parse(row.body);
|
|
39
|
+
}
|
|
40
|
+
recover() {
|
|
41
|
+
const rows = this.db.prepare("SELECT body FROM operations WHERE json_extract(body, '$.status') = 'running'").all();
|
|
42
|
+
for (const row of rows) {
|
|
43
|
+
const op = JSON.parse(row.body);
|
|
44
|
+
op.status = 'interrupted';
|
|
45
|
+
op.ended = new Date().toISOString();
|
|
46
|
+
this.journal(op);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
commit(project, operation) {
|
|
50
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
51
|
+
try {
|
|
52
|
+
if (project) this.save(project);
|
|
53
|
+
this.journal(operation);
|
|
54
|
+
this.db.exec('COMMIT');
|
|
55
|
+
} catch (error) {
|
|
56
|
+
this.db.exec('ROLLBACK');
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
close() { this.db.close(); }
|
|
61
|
+
}
|