rootrework 1.0.2 → 1.0.4
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 +20 -0
- package/bin/cli.mjs +266 -1
- package/config/client.json +7 -0
- package/package.json +30 -11
- package/src/agent.mjs +125 -0
- package/src/backend.mjs +391 -0
- package/src/client.mjs +408 -0
- package/src/extended.mjs +324 -0
- package/src/process.mjs +125 -0
- package/src/retention.mjs +193 -0
- package/src/search-worker.mjs +70 -0
- package/src/service.mjs +158 -0
- package/src/stdio.mjs +46 -0
- package/src/supervisor.mjs +119 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { accessSession, readJson, writeJson } from './client.mjs';
|
|
4
|
+
|
|
5
|
+
const validId = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value);
|
|
6
|
+
const finished = new Set(['completed_success', 'completed_failure', 'cancelled', 'timed_out']);
|
|
7
|
+
const failure = (code, message) => Object.assign(new Error(message), { code });
|
|
8
|
+
|
|
9
|
+
export async function createRetainer({ stateDir, deviceId, backend, extensions, signal, transport }) {
|
|
10
|
+
const journal = path.join(stateDir, 'retention');
|
|
11
|
+
await fs.mkdir(journal, { recursive: true, mode: 0o700 });
|
|
12
|
+
const send = transport || (async (route, { method = 'GET', body, headers = {} } = {}) => {
|
|
13
|
+
let session = await accessSession(stateDir);
|
|
14
|
+
const invoke = () => fetch(new URL(route, session.issuer), { method, redirect: 'error',
|
|
15
|
+
headers: { ...headers, authorization: `Bearer ${session.token.access_token}`, ...(body === undefined ? {} : { 'content-type': body instanceof Uint8Array ? 'application/octet-stream' : 'application/json' }) },
|
|
16
|
+
...(body === undefined ? {} : { body: body instanceof Uint8Array ? body : JSON.stringify(body) }),
|
|
17
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(60000)]) : AbortSignal.timeout(60000) });
|
|
18
|
+
let response = await invoke();
|
|
19
|
+
if (response.status === 401) { await response.body?.cancel(); session = await accessSession(stateDir, { refresh: true }); response = await invoke(); }
|
|
20
|
+
return response;
|
|
21
|
+
});
|
|
22
|
+
const call = async (route, options) => {
|
|
23
|
+
const response = await send(route, options);
|
|
24
|
+
const data = await response.json().catch(() => ({}));
|
|
25
|
+
if (!response.ok) throw Object.assign(new Error(data.error_description || data.error || `HTTP ${response.status}`), { code: data.error, status: response.status });
|
|
26
|
+
return data;
|
|
27
|
+
};
|
|
28
|
+
const jobFile = (kind, id) => path.join(journal, `${kind}-${id}.json`);
|
|
29
|
+
const locks = new Map();
|
|
30
|
+
const locked = async (file, operation) => {
|
|
31
|
+
const task = (locks.get(file) || Promise.resolve()).catch(() => {}).then(operation);
|
|
32
|
+
locks.set(file, task);
|
|
33
|
+
try { return await task; } finally { if (locks.get(file) === task) locks.delete(file); }
|
|
34
|
+
};
|
|
35
|
+
const enqueue = async (kind, id, deleting = false) => {
|
|
36
|
+
if (!['artifact', 'task'].includes(kind) || !validId(id)) return;
|
|
37
|
+
const file = jobFile(kind, id);
|
|
38
|
+
await locked(file, async () => {
|
|
39
|
+
const existing = await readJson(file);
|
|
40
|
+
if (existing && !deleting) return;
|
|
41
|
+
await writeJson(file, { ...(existing || {}), kind, id, state: deleting ? 'deleting' : 'pending', attempts: 0, nextAttemptAt: 0 });
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
const scan = async () => {
|
|
45
|
+
for (const [folder, kind] of [['artifacts', 'artifact'], ['tasks', 'task']]) {
|
|
46
|
+
const dir = path.join(stateDir, folder);
|
|
47
|
+
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(error => { if (error.code === 'ENOENT') return []; throw error; });
|
|
48
|
+
for (const entry of entries) if (entry.isDirectory() && validId(entry.name)) await enqueue(kind, entry.name);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
const save = job => {
|
|
52
|
+
const file = jobFile(job.kind, job.id);
|
|
53
|
+
return locked(file, async () => {
|
|
54
|
+
const current = await readJson(file);
|
|
55
|
+
if (current?.state === 'deleting' && !['deleting', 'deleted'].includes(job.state)) return;
|
|
56
|
+
await writeJson(file, job);
|
|
57
|
+
});
|
|
58
|
+
};
|
|
59
|
+
const endpoint = (kind, id) => `/agent/retention/${deviceId}/${kind}/${id}`;
|
|
60
|
+
|
|
61
|
+
async function retainFile(job, kind, file, metadata, name, size) {
|
|
62
|
+
const route = endpoint(kind, job.id);
|
|
63
|
+
const handle = size ? await fs.open(file, 'r') : await fs.open(file, 'r').catch(error => { if (error.code === 'ENOENT') return null; throw error; });
|
|
64
|
+
try {
|
|
65
|
+
const before = handle ? await handle.stat() : { size: 0, mtimeMs: 0, ino: 0 };
|
|
66
|
+
if (before.size !== size || (handle && !before.isFile())) throw failure('SOURCE_CHANGED', 'Retained source size changed.');
|
|
67
|
+
const signature = { size: before.size, modified: before.mtimeMs, inode: before.ino };
|
|
68
|
+
job.sources ||= {};
|
|
69
|
+
if (job.sources[kind] && JSON.stringify(job.sources[kind]) !== JSON.stringify(signature)) throw failure('SOURCE_CHANGED', 'Retained source changed during an interrupted transfer.');
|
|
70
|
+
job.sources[kind] = signature;
|
|
71
|
+
await save(job);
|
|
72
|
+
let remote = await call(route, { method: 'POST', body: { size, name, metadata } });
|
|
73
|
+
if (!Number.isSafeInteger(remote.next_offset) || remote.next_offset < 0 || remote.next_offset > size || !Number.isSafeInteger(remote.chunk_bytes) || remote.chunk_bytes < 1 || remote.chunk_bytes > 16 * 1024 * 1024) throw failure('INVALID_RETENTION_STATE', 'Cloud storage returned an invalid transfer state.');
|
|
74
|
+
for (let offset = remote.next_offset; offset < size;) {
|
|
75
|
+
if (signal?.aborted) throw signal.reason;
|
|
76
|
+
const length = Math.min(remote.chunk_bytes, size - offset);
|
|
77
|
+
const bytes = Buffer.alloc(length);
|
|
78
|
+
let obtained = 0;
|
|
79
|
+
while (obtained < length) {
|
|
80
|
+
const { bytesRead } = await handle.read(bytes, obtained, length - obtained, offset + obtained);
|
|
81
|
+
if (!bytesRead) throw failure('SOURCE_CHANGED', 'The retained source ended before its complete size.');
|
|
82
|
+
obtained += bytesRead;
|
|
83
|
+
}
|
|
84
|
+
remote = await call(route + '?offset=' + offset, { method: 'PUT', body: bytes });
|
|
85
|
+
if (remote.next_offset !== offset + length) throw failure('NONCONTIGUOUS_RETENTION', 'Cloud storage did not accept the complete contiguous chunk.');
|
|
86
|
+
offset = remote.next_offset;
|
|
87
|
+
}
|
|
88
|
+
while (remote.state !== 'retained') {
|
|
89
|
+
if (signal?.aborted) throw signal.reason;
|
|
90
|
+
const previous = remote.verified_bytes || 0;
|
|
91
|
+
remote = await call(route + '/finish', { method: 'POST', body: {} });
|
|
92
|
+
if (remote.state !== 'retained' && (!(remote.verified_bytes > previous) || remote.verified_bytes > size)) throw failure('INVALID_RETENTION_STATE', 'Cloud verification did not advance.');
|
|
93
|
+
}
|
|
94
|
+
job.readback ||= {};
|
|
95
|
+
let offset = job.readback[kind] || 0, emptyPending = size === 0;
|
|
96
|
+
if (!Number.isSafeInteger(offset) || offset < 0 || offset > size) throw failure('INVALID_RETENTION_STATE', 'Saved cloud readback offset is invalid.');
|
|
97
|
+
while (offset < size || emptyPending) {
|
|
98
|
+
const end = Math.min(size, offset + remote.chunk_bytes);
|
|
99
|
+
const response = await send(route + '/data', size ? { headers: { range: `bytes=${offset}-${end - 1}` } } : {});
|
|
100
|
+
if (response.status !== (size ? 206 : 200) || (size && response.headers.get('content-range') !== `bytes ${offset}-${end - 1}/${size}`)) {
|
|
101
|
+
await response.body?.cancel(); throw failure('RETENTION_READBACK_FAILED', `Cloud range readback returned HTTP ${response.status}.`);
|
|
102
|
+
}
|
|
103
|
+
const reader = response.body?.getReader();
|
|
104
|
+
if (!reader) throw failure('RETENTION_READBACK_FAILED', 'Cloud readback did not contain a readable body.');
|
|
105
|
+
try {
|
|
106
|
+
for (;;) {
|
|
107
|
+
const part = await reader.read();
|
|
108
|
+
if (part.done) break;
|
|
109
|
+
if (offset + part.value.byteLength > end) throw failure('RETENTION_READBACK_FAILED', 'Cloud readback exceeded the requested byte range.');
|
|
110
|
+
const expected = Buffer.alloc(part.value.byteLength);
|
|
111
|
+
let obtained = 0;
|
|
112
|
+
while (obtained < expected.length) {
|
|
113
|
+
const { bytesRead } = await handle.read(expected, obtained, expected.length - obtained, offset + obtained);
|
|
114
|
+
if (!bytesRead) throw failure('SOURCE_CHANGED', 'The source changed during cloud readback.');
|
|
115
|
+
obtained += bytesRead;
|
|
116
|
+
}
|
|
117
|
+
if (!expected.equals(Buffer.from(part.value))) throw failure('RETENTION_CONTENT_MISMATCH', 'Cloud readback differs from the complete retained source.');
|
|
118
|
+
offset += part.value.byteLength;
|
|
119
|
+
}
|
|
120
|
+
} catch (error) { await reader.cancel().catch(() => {}); throw error; }
|
|
121
|
+
finally { reader.releaseLock(); }
|
|
122
|
+
if (offset !== end) throw failure('RETENTION_READBACK_FAILED', 'Cloud readback ended before the complete requested range.');
|
|
123
|
+
job.readback[kind] = offset;
|
|
124
|
+
await save(job);
|
|
125
|
+
emptyPending = false;
|
|
126
|
+
}
|
|
127
|
+
if (offset !== size) throw failure('RETENTION_READBACK_FAILED', 'Cloud readback ended before the complete retained size.');
|
|
128
|
+
const after = handle ? await handle.stat() : before;
|
|
129
|
+
if (after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ino !== before.ino) throw failure('SOURCE_CHANGED', 'The source changed during cloud retention.');
|
|
130
|
+
return remote;
|
|
131
|
+
} finally { await handle?.close(); }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function execute(job) {
|
|
135
|
+
if (job.kind === 'artifact') {
|
|
136
|
+
const metadata = await extensions.call('artifact_get', { artifact_id: job.id });
|
|
137
|
+
if (job.state === 'deleting' || metadata.state === 'deleted') {
|
|
138
|
+
await call(endpoint('artifact', job.id), { method: 'DELETE' });
|
|
139
|
+
return { ...job, state: 'deleted', completedAt: Date.now() };
|
|
140
|
+
}
|
|
141
|
+
if (!['retained', 'published'].includes(metadata.state) || metadata.retained_bytes !== metadata.size) return { ...job, state: 'pending', nextAttemptAt: Date.now() + 30000 };
|
|
142
|
+
await retainFile(job, 'artifact', path.join(stateDir, 'artifacts', job.id, 'data.bin'), metadata, metadata.name || 'download', metadata.size);
|
|
143
|
+
} else {
|
|
144
|
+
const task = await backend.call('task_get', { task_id: job.id });
|
|
145
|
+
await call('/agent/task-state', { method: 'POST', body: { deviceId, task } });
|
|
146
|
+
if (!finished.has(task.state)) return { ...job, state: task.state === 'unknown_outcome' ? 'unknown_outcome' : 'pending', nextAttemptAt: Date.now() + 30000 };
|
|
147
|
+
for (const stream of ['stdout', 'stderr', 'terminal']) {
|
|
148
|
+
const size = task.output_bytes?.[stream] || 0;
|
|
149
|
+
task.output_bytes[stream] = size;
|
|
150
|
+
await retainFile(job, stream, path.join(stateDir, 'tasks', job.id, stream + '.bin'), task, `${job.id}-${stream}.bin`, size);
|
|
151
|
+
}
|
|
152
|
+
await call('/agent/task-state', { method: 'POST', body: { deviceId, task } });
|
|
153
|
+
}
|
|
154
|
+
return { ...job, state: 'retained', completedAt: Date.now(), nextAttemptAt: 0 };
|
|
155
|
+
}
|
|
156
|
+
let flushing;
|
|
157
|
+
const flush = () => flushing ||= (async () => {
|
|
158
|
+
await scan();
|
|
159
|
+
for (const name of await fs.readdir(journal)) {
|
|
160
|
+
if (signal?.aborted) break;
|
|
161
|
+
if (!/^(artifact|task)-[A-Za-z0-9_-]+\.json$/.test(name)) continue;
|
|
162
|
+
const job = await readJson(path.join(journal, name));
|
|
163
|
+
if (!job || ['retained', 'deleted', 'failed', 'unknown_outcome'].includes(job.state) || job.nextAttemptAt > Date.now()) continue;
|
|
164
|
+
try { await save(await execute(job)); }
|
|
165
|
+
catch (error) {
|
|
166
|
+
if (signal?.aborted) break;
|
|
167
|
+
const recoverable = !['SOURCE_CHANGED', 'RETENTION_CONTENT_MISMATCH', 'chunk_conflict', 'retention_conflict', 'device_binding_mismatch', 'invalid_grant', 'invalid_client', 'login_required'].includes(error.code) && ![400, 403, 410].includes(error.status);
|
|
168
|
+
const attempts = (job.attempts || 0) + 1;
|
|
169
|
+
await save({ ...job, state: recoverable ? 'pending' : 'failed', attempts, nextAttemptAt: Date.now() + Math.min(300000, 1000 * 2 ** Math.min(attempts, 8)), error: { code: error.code || 'retention_failed', message: error.message }, recoverable });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
})().finally(() => { flushing = null; });
|
|
173
|
+
return {
|
|
174
|
+
flush,
|
|
175
|
+
async notify(name, result, args = {}) {
|
|
176
|
+
if (name === 'artifact_delete') await enqueue('artifact', args.artifact_id || result?.artifact_id, true);
|
|
177
|
+
else if (result?.artifact_id && ['retained', 'published'].includes(result.state)) await enqueue('artifact', result.artifact_id);
|
|
178
|
+
const id = result?.task_id || result?.task?.task_id;
|
|
179
|
+
if (id) await enqueue('task', id);
|
|
180
|
+
},
|
|
181
|
+
async run() {
|
|
182
|
+
while (!signal?.aborted) {
|
|
183
|
+
await flush();
|
|
184
|
+
if (signal?.aborted) break;
|
|
185
|
+
await new Promise(resolve => {
|
|
186
|
+
const done = () => { clearTimeout(timer); signal?.removeEventListener('abort', done); resolve(); };
|
|
187
|
+
const timer = setTimeout(done, 30000);
|
|
188
|
+
signal?.addEventListener('abort', done, { once: true });
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { once } from 'node:events';
|
|
4
|
+
|
|
5
|
+
const options = JSON.parse(process.argv[2]);
|
|
6
|
+
const visited = new Set();
|
|
7
|
+
const expression = options.regex ? new RegExp(options.query, options.case_sensitive ? 'u' : 'iu') : null;
|
|
8
|
+
const needle = options.case_sensitive ? options.query : options.query.toLowerCase();
|
|
9
|
+
const counts = { files: 0, directories: 0, matches: 0, errors: 0, excluded: 0 };
|
|
10
|
+
function matches(value) { return expression ? expression.test(value) : (options.case_sensitive ? value : value.toLowerCase()).includes(needle); }
|
|
11
|
+
async function emit(record) { if (!process.stdout.write(JSON.stringify(record) + '\n')) await once(process.stdout, 'drain'); }
|
|
12
|
+
async function issue(type, file, reason) { counts[type === 'error' ? 'errors' : 'excluded']++; await emit({ type, path: file, reason }); }
|
|
13
|
+
async function searchFile(file) {
|
|
14
|
+
counts.files++;
|
|
15
|
+
const handle = await fs.open(file, 'r');
|
|
16
|
+
try {
|
|
17
|
+
const prefix = Buffer.alloc(8192); const first = await handle.read(prefix, 0, prefix.length, 0); const sample = prefix.subarray(0, first.bytesRead);
|
|
18
|
+
const encoding = sample[0] === 0xff && sample[1] === 0xfe ? 'utf-16le' : sample[0] === 0xfe && sample[1] === 0xff ? 'utf-16be' : 'utf-8';
|
|
19
|
+
if (encoding === 'utf-8' && sample.includes(0)) { await issue('excluded', file, 'binary'); return; }
|
|
20
|
+
const decoder = new TextDecoder(encoding, { fatal: true });
|
|
21
|
+
let pending = '', line = 1, offset = 0;
|
|
22
|
+
async function consume(value, final = false) {
|
|
23
|
+
pending += value;
|
|
24
|
+
let start = 0, end;
|
|
25
|
+
while ((end = pending.indexOf('\n', start)) >= 0) { await inspect(pending.slice(start, end).replace(/\r$/, ''), line++); start = end + 1; }
|
|
26
|
+
pending = pending.slice(start);
|
|
27
|
+
if (final && pending.length) await inspect(pending, line);
|
|
28
|
+
}
|
|
29
|
+
async function inspect(value, number) {
|
|
30
|
+
if (!matches(value)) return;
|
|
31
|
+
counts.matches++;
|
|
32
|
+
const snippet = Buffer.from(value).subarray(0, 8192);
|
|
33
|
+
let preview;
|
|
34
|
+
for (let trim = 0; trim < 4; trim++) { try { preview = new TextDecoder('utf-8', { fatal: true }).decode(snippet.subarray(0, snippet.length - trim)); break; } catch {} }
|
|
35
|
+
await emit({ type: 'match', path: file, line: number, text: preview ?? '', text_truncated: Buffer.byteLength(value) > snippet.length, encoding });
|
|
36
|
+
}
|
|
37
|
+
while (true) {
|
|
38
|
+
const b = Buffer.alloc(65536); const read = await handle.read(b, 0, b.length, offset);
|
|
39
|
+
if (!read.bytesRead) break;
|
|
40
|
+
offset += read.bytesRead;
|
|
41
|
+
await consume(decoder.decode(b.subarray(0, read.bytesRead), { stream: true }));
|
|
42
|
+
}
|
|
43
|
+
await consume(decoder.decode(), true);
|
|
44
|
+
} finally { await handle.close(); }
|
|
45
|
+
}
|
|
46
|
+
async function walk(file) {
|
|
47
|
+
let stat;
|
|
48
|
+
try {
|
|
49
|
+
stat = await fs.lstat(file);
|
|
50
|
+
if (options.mode === 'filename' && matches(path.basename(file))) { counts.matches++; await emit({ type: 'match', path: file, name: path.basename(file), kind: stat.isDirectory() ? 'directory' : stat.isSymbolicLink() ? 'symlink' : 'file' }); }
|
|
51
|
+
if (stat.isSymbolicLink()) {
|
|
52
|
+
if (!options.follow_symlinks) { await issue('excluded', file, 'symlink_not_followed'); return; }
|
|
53
|
+
stat = await fs.stat(file);
|
|
54
|
+
}
|
|
55
|
+
if (stat.isDirectory()) {
|
|
56
|
+
const identity = await fs.realpath(file);
|
|
57
|
+
if (visited.has(identity)) { await issue('excluded', file, 'cycle_or_duplicate_directory'); return; }
|
|
58
|
+
visited.add(identity); counts.directories++;
|
|
59
|
+
for await (const item of await fs.opendir(file)) {
|
|
60
|
+
if (!options.include_hidden && item.name.startsWith('.')) { await issue('excluded', path.join(file, item.name), 'hidden'); continue; }
|
|
61
|
+
const child = path.join(file, item.name);
|
|
62
|
+
await walk(child);
|
|
63
|
+
}
|
|
64
|
+
} else if (stat.isFile()) {
|
|
65
|
+
if (options.mode === 'content') await searchFile(file); else counts.files++;
|
|
66
|
+
} else await issue('excluded', file, 'non_regular_file');
|
|
67
|
+
} catch (e) { await issue('error', file, e.code || (e instanceof TypeError ? 'invalid_text_encoding' : 'read_failed')); }
|
|
68
|
+
}
|
|
69
|
+
try { await walk(options.path); await emit({ type: 'summary', ...counts, complete: true }); }
|
|
70
|
+
catch (e) { await emit({ type: 'error', reason: e.code || 'search_failed' }); process.exitCode = 1; }
|
package/src/service.mjs
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { readJson, writeJson } from './client.mjs';
|
|
6
|
+
|
|
7
|
+
export function run(executable, args, options = {}) {
|
|
8
|
+
const { timeout_ms = 30000, ...nativeOptions } = options;
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
const child = spawn(executable, args, { windowsHide: true, timeout: timeout_ms, ...nativeOptions });
|
|
11
|
+
let stdout = '', stderr = '';
|
|
12
|
+
child.stdout?.on('data', chunk => { stdout += chunk; });
|
|
13
|
+
child.stderr?.on('data', chunk => { stderr += chunk; });
|
|
14
|
+
child.once('error', reject);
|
|
15
|
+
child.once('close', (code, signal) => resolve({ code, signal, stdout, stderr }));
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
const systemdQuote = value => '"' + value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('%', '%%').replaceAll('$', '$$$$').replaceAll('\n', '\\n').replaceAll('\r', '\\r') + '"';
|
|
19
|
+
const xml = value => value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
|
|
20
|
+
const psQuote = value => "'" + value.replaceAll("'", "''") + "'";
|
|
21
|
+
const windowsQuote = value => '"' + value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, '$1$1') + '"';
|
|
22
|
+
|
|
23
|
+
export async function powershell() {
|
|
24
|
+
for (const name of ['pwsh.exe', 'powershell.exe']) for (const directory of (process.env.PATH || '').split(path.delimiter)) {
|
|
25
|
+
const file = path.join(directory, name);
|
|
26
|
+
if (await fs.stat(file).then(info => info.isFile(), () => false)) return file;
|
|
27
|
+
}
|
|
28
|
+
throw new Error('No installed PowerShell executable is available.');
|
|
29
|
+
}
|
|
30
|
+
export function windowsArguments(commandLine) {
|
|
31
|
+
const args = []; let index = 0;
|
|
32
|
+
while (index < commandLine.length) {
|
|
33
|
+
while (/[ \t]/.test(commandLine[index] || '') && index < commandLine.length) index++;
|
|
34
|
+
if (index === commandLine.length) break;
|
|
35
|
+
let value = '', quoted = false;
|
|
36
|
+
while (index < commandLine.length && (quoted || !/[ \t]/.test(commandLine[index]))) {
|
|
37
|
+
let slashes = 0;
|
|
38
|
+
while (commandLine[index] === '\\') { slashes++; index++; }
|
|
39
|
+
if (commandLine[index] === '"') {
|
|
40
|
+
value += '\\'.repeat(Math.floor(slashes / 2)); index++;
|
|
41
|
+
if (slashes % 2) value += '"';
|
|
42
|
+
else if (quoted && commandLine[index] === '"') { value += '"'; index++; }
|
|
43
|
+
else quoted = !quoted;
|
|
44
|
+
} else {
|
|
45
|
+
value += '\\'.repeat(slashes);
|
|
46
|
+
if (index < commandLine.length && (quoted || !/[ \t]/.test(commandLine[index]))) value += commandLine[index++];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
args.push(value);
|
|
50
|
+
}
|
|
51
|
+
return args;
|
|
52
|
+
}
|
|
53
|
+
export async function windowsProcessArguments(pid) {
|
|
54
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return null;
|
|
55
|
+
const command = `$ErrorActionPreference='Stop';$line=[string](Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine;[Console]::Write([Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($line)))`;
|
|
56
|
+
const result = await run(await powershell(), ['-NoProfile', '-NonInteractive', '-Command', command]);
|
|
57
|
+
const encoded = result.stdout.trim();
|
|
58
|
+
if (result.code !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(encoded)) return null;
|
|
59
|
+
return windowsArguments(Buffer.from(encoded, 'base64').toString('utf8'));
|
|
60
|
+
}
|
|
61
|
+
const ps = async command => run(await powershell(), ['-NoProfile', '-NonInteractive', '-Command', "$ErrorActionPreference='Stop';" + command]);
|
|
62
|
+
const scheduledGuard = stateDir => `if($existing){if($existing.Description -ne ${psQuote('rootRework agent: ' + stateDir)} -and !($existing.Description -eq 'rootRework agent' -and $existing.Actions.Arguments.Contains(${psQuote('--state-dir ' + windowsQuote(stateDir))}))){throw 'The task belongs to another installation'}};`;
|
|
63
|
+
const check = result => { if (result.code !== 0) throw new Error(result.stderr.trim() || 'The native service operation failed.'); return result; };
|
|
64
|
+
|
|
65
|
+
async function assertOwned(stateDir, record) {
|
|
66
|
+
if (record.stateDir && path.resolve(record.stateDir) !== path.resolve(stateDir)) throw new Error('The service belongs to another installation.');
|
|
67
|
+
if (record.manager === 'systemd') {
|
|
68
|
+
const text = await fs.readFile(record.file, 'utf8').catch(error => { if (error.code === 'ENOENT') return null; throw error; });
|
|
69
|
+
if (text && (!text.includes('Description=rootRework agent\n') || !text.includes(`--state-dir ${systemdQuote(stateDir)}`))) throw new Error('The service ownership has changed.');
|
|
70
|
+
return text !== null;
|
|
71
|
+
}
|
|
72
|
+
if (record.manager === 'launchd') {
|
|
73
|
+
const text = await fs.readFile(record.file, 'utf8').catch(error => { if (error.code === 'ENOENT') return null; throw error; });
|
|
74
|
+
if (text && (!text.includes('<string>com.rootrework.agent</string>') || !text.includes(`<string>${xml(stateDir)}</string>`))) throw new Error('The launch agent ownership has changed.');
|
|
75
|
+
return text !== null;
|
|
76
|
+
}
|
|
77
|
+
if (record.manager === 'scheduled-task') {
|
|
78
|
+
const result = check(await ps(`$existing=Get-ScheduledTask -TaskName ${psQuote(record.name)} -ErrorAction SilentlyContinue;${scheduledGuard(stateDir)}if($existing){'present'}`));
|
|
79
|
+
return result.stdout.trim() === 'present';
|
|
80
|
+
}
|
|
81
|
+
throw new Error('Unknown service manager.');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function installService(stateDir, release) {
|
|
85
|
+
const script = path.join(release, 'bin', 'cli.mjs');
|
|
86
|
+
let record;
|
|
87
|
+
if (process.platform === 'linux') {
|
|
88
|
+
const system = process.getuid?.() === 0, prefix = system ? [] : ['--user'];
|
|
89
|
+
const available = await run('systemctl', [...prefix, 'show-environment']).catch(() => null);
|
|
90
|
+
if (!available || available.code !== 0) return { installed: false, reason: 'No usable systemd user or system manager.', mode: 'detached' };
|
|
91
|
+
const directory = system ? '/etc/systemd/system' : path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'systemd', 'user');
|
|
92
|
+
record = { installed: true, manager: 'systemd', system, file: path.join(directory, 'rootrework.service'), name: 'rootrework.service', stateDir, release, executable: process.execPath };
|
|
93
|
+
await assertOwned(stateDir, record);
|
|
94
|
+
await fs.mkdir(directory, { recursive: true });
|
|
95
|
+
const unit = `[Unit]\nDescription=rootRework agent\nAfter=network-online.target\nWants=network-online.target\n[Service]\nType=simple\nExecStart=${systemdQuote(process.execPath)} ${systemdQuote(script)} run --state-dir ${systemdQuote(stateDir)}\nRestart=on-failure\nRestartSec=5\nKillMode=process\n[Install]\nWantedBy=${system ? 'multi-user.target' : 'default.target'}\n`;
|
|
96
|
+
const prior = await fs.readFile(record.file, 'utf8').catch(() => null);
|
|
97
|
+
await fs.writeFile(record.file, unit, { mode: 0o644 });
|
|
98
|
+
await writeJson(path.join(stateDir, 'service.json'), record);
|
|
99
|
+
check(await run('systemctl', [...prefix, 'daemon-reload']));
|
|
100
|
+
check(await run('systemctl', [...prefix, 'enable', record.name]));
|
|
101
|
+
check(await run('systemctl', [...prefix, prior && prior !== unit ? 'restart' : 'start', record.name]));
|
|
102
|
+
} else if (process.platform === 'darwin') {
|
|
103
|
+
const name = 'com.rootrework.agent', domain = `gui/${process.getuid()}`;
|
|
104
|
+
const file = path.join(os.homedir(), 'Library', 'LaunchAgents', name + '.plist');
|
|
105
|
+
record = { installed: true, manager: 'launchd', file, domain, name, stateDir, release, executable: process.execPath };
|
|
106
|
+
await assertOwned(stateDir, record);
|
|
107
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
108
|
+
const argv = [process.execPath, script, 'run', '--state-dir', stateDir].map(x => `<string>${xml(x)}</string>`).join('');
|
|
109
|
+
const content = `<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><dict><key>Label</key><string>${name}</string><key>ProgramArguments</key><array>${argv}</array><key>RunAtLoad</key><true/><key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict></dict></plist>`;
|
|
110
|
+
const prior = await fs.readFile(file, 'utf8').catch(() => null);
|
|
111
|
+
const active = await run('launchctl', ['print', `${domain}/${name}`]);
|
|
112
|
+
if (active.code === 0 && prior !== content) check(await run('launchctl', ['bootout', `${domain}/${name}`]));
|
|
113
|
+
await fs.writeFile(file, content, { mode: 0o600 });
|
|
114
|
+
await writeJson(path.join(stateDir, 'service.json'), record);
|
|
115
|
+
if (active.code !== 0 || prior !== content) check(await run('launchctl', ['bootstrap', domain, file]));
|
|
116
|
+
else check(await run('launchctl', ['kickstart', `${domain}/${name}`]));
|
|
117
|
+
} else if (process.platform === 'win32') {
|
|
118
|
+
const name = 'rootRework';
|
|
119
|
+
const argument = [script, 'run', '--state-dir', stateDir].map(windowsQuote).join(' ');
|
|
120
|
+
const existing = `$existing=Get-ScheduledTask -TaskName ${psQuote(name)} -ErrorAction SilentlyContinue;${scheduledGuard(stateDir)}`;
|
|
121
|
+
const command = `${existing}if($existing){Stop-ScheduledTask -TaskName ${psQuote(name)}};$action=New-ScheduledTaskAction -Execute ${psQuote(process.execPath)} -Argument ${psQuote(argument)};$trigger=New-ScheduledTaskTrigger -AtLogOn -User ([System.Security.Principal.WindowsIdentity]::GetCurrent().Name);$settings=New-ScheduledTaskSettingsSet -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew;Register-ScheduledTask -TaskName ${psQuote(name)} -Action $action -Trigger $trigger -Settings $settings -Description ${psQuote('rootRework agent: ' + stateDir)} -Force|Out-Null;Start-ScheduledTask -TaskName ${psQuote(name)}`;
|
|
122
|
+
check(await ps(command));
|
|
123
|
+
record = { installed: true, manager: 'scheduled-task', name, stateDir, release, executable: process.execPath };
|
|
124
|
+
await writeJson(path.join(stateDir, 'service.json'), record);
|
|
125
|
+
} else return { installed: false, mode: 'detached', reason: 'No supported native service manager.' };
|
|
126
|
+
return record;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function serviceAction(stateDir, action) {
|
|
130
|
+
if (!['stop', 'restart', 'uninstall'].includes(action)) throw new Error('Unknown service operation.');
|
|
131
|
+
const record = await readJson(path.join(stateDir, 'service.json'));
|
|
132
|
+
if (!record?.installed) {
|
|
133
|
+
if (action === 'uninstall' && record?.mode === 'detached' && path.resolve(record.stateDir || '') === path.resolve(stateDir)) await fs.rm(path.join(stateDir, 'service.json'), { force: true });
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
if (!await assertOwned(stateDir, record)) {
|
|
137
|
+
if (action === 'uninstall') await fs.rm(path.join(stateDir, 'service.json'), { force: true });
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
if (record.manager === 'systemd') {
|
|
141
|
+
const prefix = record.system ? [] : ['--user'];
|
|
142
|
+
if (action === 'uninstall') {
|
|
143
|
+
check(await run('systemctl', [...prefix, 'disable', '--now', record.name]));
|
|
144
|
+
await fs.rm(record.file);
|
|
145
|
+
check(await run('systemctl', [...prefix, 'daemon-reload']));
|
|
146
|
+
} else check(await run('systemctl', [...prefix, action, record.name]));
|
|
147
|
+
} else if (record.manager === 'launchd') {
|
|
148
|
+
const active = await run('launchctl', ['print', `${record.domain}/${record.name}`]);
|
|
149
|
+
if (active.code === 0) check(await run('launchctl', ['bootout', `${record.domain}/${record.name}`]));
|
|
150
|
+
if (action === 'restart') check(await run('launchctl', ['bootstrap', record.domain, record.file]));
|
|
151
|
+
if (action === 'uninstall') await fs.rm(record.file, { force: true });
|
|
152
|
+
} else {
|
|
153
|
+
const command = `$existing=Get-ScheduledTask -TaskName ${psQuote(record.name)} -ErrorAction SilentlyContinue;${scheduledGuard(stateDir)}if($existing){Stop-ScheduledTask -TaskName ${psQuote(record.name)};${action === 'uninstall' ? `Unregister-ScheduledTask -TaskName ${psQuote(record.name)} -Confirm:$false` : action === 'restart' ? `Start-ScheduledTask -TaskName ${psQuote(record.name)}` : ''}}`;
|
|
154
|
+
check(await ps(command));
|
|
155
|
+
}
|
|
156
|
+
if (action === 'uninstall') await fs.rm(path.join(stateDir, 'service.json'), { force: true });
|
|
157
|
+
return true;
|
|
158
|
+
}
|
package/src/stdio.mjs
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import readline from 'node:readline';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { once } from 'node:events';
|
|
4
|
+
import { createBackend } from './backend.mjs';
|
|
5
|
+
import { createExtensions } from './extended.mjs';
|
|
6
|
+
import { packageRoot, readJson } from './client.mjs';
|
|
7
|
+
|
|
8
|
+
const versions = ['2025-03-26', '2025-06-18', '2025-11-25', '2026-07-28'];
|
|
9
|
+
export async function runStdio(stateDir, input = process.stdin, output = process.stdout) {
|
|
10
|
+
const manifest = await readJson(path.join(packageRoot, 'package.json'));
|
|
11
|
+
const backend = createBackend({ stateDir, packageRoot, deviceId: 'local' });
|
|
12
|
+
const extensions = await createExtensions({ stateDir, packageRoot, deviceId: 'local', backend });
|
|
13
|
+
const tools = [...backend.tools, ...extensions.tools];
|
|
14
|
+
const lines = readline.createInterface({ input, crlfDelay: Infinity });
|
|
15
|
+
const active = new Set();
|
|
16
|
+
let writes = Promise.resolve();
|
|
17
|
+
const write = value => { writes = writes.then(async () => { if (!output.write(JSON.stringify(value) + '\n')) await once(output, 'drain'); }); return writes; };
|
|
18
|
+
async function handle(line) {
|
|
19
|
+
let message;
|
|
20
|
+
try { message = JSON.parse(line); }
|
|
21
|
+
catch { return write({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }); }
|
|
22
|
+
const fail = (code, text) => write({ jsonrpc: '2.0', id: message?.id ?? null, error: { code, message: text } });
|
|
23
|
+
if (!message || Array.isArray(message) || message.jsonrpc !== '2.0' || typeof message.method !== 'string' || Object.hasOwn(message, 'id') && !(typeof message.id === 'string' || Number.isSafeInteger(message.id))) return fail(-32600, 'Invalid request');
|
|
24
|
+
if (!Object.hasOwn(message, 'id')) return;
|
|
25
|
+
const version = message.params?._meta?.['io.modelcontextprotocol/protocolVersion'] || message.params?.protocolVersion;
|
|
26
|
+
if (version && !versions.includes(version)) return fail(-32022, 'Unsupported protocol version');
|
|
27
|
+
let result;
|
|
28
|
+
if (message.method === 'initialize') result = { protocolVersion: version && version !== '2026-07-28' ? version : '2025-11-25', capabilities: { tools: {} }, serverInfo: { name: 'rootRework', version: manifest.version } };
|
|
29
|
+
else if (message.method === 'server/discover') result = { supportedVersions: versions, capabilities: { tools: {} } };
|
|
30
|
+
else if (message.method === 'ping') result = {};
|
|
31
|
+
else if (message.method === 'tools/list') result = { tools };
|
|
32
|
+
else if (message.method === 'tools/call') {
|
|
33
|
+
const name = message.params?.name;
|
|
34
|
+
const target = backend.tools.some(tool => tool.name === name) ? backend : extensions;
|
|
35
|
+
if (!tools.some(tool => tool.name === name)) return fail(-32602, 'Unknown tool');
|
|
36
|
+
try { const value = await target.call(name, message.params.arguments || {}); result = { content: [{ type: 'text', text: JSON.stringify(value) }], structuredContent: value }; }
|
|
37
|
+
catch (error) { result = { content: [{ type: 'text', text: error.message }], isError: true }; }
|
|
38
|
+
} else return fail(-32601, 'Method not found');
|
|
39
|
+
if (version === '2026-07-28') result = { ...result, resultType: 'complete', _meta: { 'io.modelcontextprotocol/serverInfo': { name: 'rootRework', version: manifest.version } } };
|
|
40
|
+
return write({ jsonrpc: '2.0', id: message.id, result });
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
for await (const line of lines) { const request = handle(line).finally(() => active.delete(request)); active.add(request); }
|
|
44
|
+
await Promise.all(active); await writes;
|
|
45
|
+
} finally { lines.close(); await extensions.close(); await backend.close(); }
|
|
46
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import sync from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
import { atomic, json, envWith, killTree, processIdentity, delay, finished } from './process.mjs';
|
|
7
|
+
|
|
8
|
+
const dir = process.argv[2];
|
|
9
|
+
let state, child, pty, stopping, done = false, busy = false, beat, poll, timeout, force;
|
|
10
|
+
const fds = {};
|
|
11
|
+
process.on('uncaughtExceptionMonitor', error => {
|
|
12
|
+
try { sync.writeFileSync(path.join(dir, 'supervisor-error.json'), JSON.stringify({ code: error.code || 'SUPERVISOR_FAILED', syscall: error.syscall || null, observed_state: state?.state || 'initializing', at: new Date().toISOString() }), { mode: 0o600 }); } catch {}
|
|
13
|
+
});
|
|
14
|
+
async function persist() { state.updated_at = new Date().toISOString(); await atomic(path.join(dir, 'state.json'), state); }
|
|
15
|
+
let chain = Promise.resolve();
|
|
16
|
+
function save() { chain = chain.then(persist, persist); return chain; }
|
|
17
|
+
function write(stream, data) {
|
|
18
|
+
if (done) return;
|
|
19
|
+
try {
|
|
20
|
+
const b = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
21
|
+
let offset = 0;
|
|
22
|
+
while (offset < b.length) offset += sync.writeSync(fds[stream], b, offset, b.length - offset);
|
|
23
|
+
state.output_bytes[stream] += b.length;
|
|
24
|
+
} catch (error) { state.storage_error = error.code || 'OUTPUT_WRITE_FAILED'; void stop('completed_failure'); }
|
|
25
|
+
}
|
|
26
|
+
async function finish(code, signal, error) {
|
|
27
|
+
if (done) return;
|
|
28
|
+
done = true;
|
|
29
|
+
clearInterval(beat); clearInterval(poll); clearTimeout(timeout);
|
|
30
|
+
if (force) { await force; }
|
|
31
|
+
for (const fd of Object.values(fds)) { try { sync.fsyncSync(fd); sync.closeSync(fd); } catch {} }
|
|
32
|
+
state.state = stopping || (error || code !== 0 ? 'completed_failure' : 'completed_success');
|
|
33
|
+
state.exit_code = Number.isInteger(code) ? code : null;
|
|
34
|
+
state.signal = signal || null;
|
|
35
|
+
if (error) state.error = { code: error.code || 'SPAWN_FAILED', message: error.code === 'ENOENT' ? 'Executable was not found.' : 'Process could not run.' };
|
|
36
|
+
state.finished_at = new Date().toISOString();
|
|
37
|
+
await save();
|
|
38
|
+
}
|
|
39
|
+
async function stop(reason) {
|
|
40
|
+
if (done || stopping) return;
|
|
41
|
+
stopping = reason;
|
|
42
|
+
state.termination_requested_at = new Date().toISOString();
|
|
43
|
+
state.termination_reason = reason;
|
|
44
|
+
await save();
|
|
45
|
+
if (!child) return finish(null, null);
|
|
46
|
+
const pid = child.pid;
|
|
47
|
+
try {
|
|
48
|
+
await killTree(pid, 'SIGTERM');
|
|
49
|
+
if (pty && process.platform === 'win32') child.kill();
|
|
50
|
+
if (process.platform !== 'win32') force = delay(750).then(() => killTree(pid, 'SIGKILL')).catch(() => {});
|
|
51
|
+
} catch (error) { state.termination_error = error.code || 'TERMINATION_FAILED'; await save(); }
|
|
52
|
+
}
|
|
53
|
+
async function control() {
|
|
54
|
+
if (busy || done) return;
|
|
55
|
+
busy = true;
|
|
56
|
+
try {
|
|
57
|
+
for (const name of (await fs.readdir(path.join(dir, 'controls'))).filter(n => n.endsWith('.request.json')).sort()) {
|
|
58
|
+
const request = path.join(dir, 'controls', name);
|
|
59
|
+
const working = request.replace('.request.json', '.working.json');
|
|
60
|
+
let op;
|
|
61
|
+
try { await fs.rename(request, working); op = await json(working); } catch { continue; }
|
|
62
|
+
let result;
|
|
63
|
+
try {
|
|
64
|
+
if (op.operation === 'cancel' || op.operation === 'close') { await stop('cancelled'); result = { accepted: true }; }
|
|
65
|
+
else if (!pty) throw Object.assign(new Error('This task is not a terminal.'), { code: 'NOT_TERMINAL' });
|
|
66
|
+
else if (op.operation === 'input') { child.write(Buffer.from(op.data, 'base64')); result = { accepted: true, bytes: Buffer.byteLength(op.data, 'base64') }; }
|
|
67
|
+
else if (op.operation === 'resize') { child.resize(op.cols, op.rows); state.cols = op.cols; state.rows = op.rows; await save(); result = { cols: op.cols, rows: op.rows }; }
|
|
68
|
+
else if (op.operation === 'interrupt') { child.write('\x03'); result = { accepted: true }; }
|
|
69
|
+
else throw Object.assign(new Error('Unsupported control operation.'), { code: 'INVALID_ARGUMENT' });
|
|
70
|
+
} catch (e) { result = { error: { code: e.code || 'CONTROL_FAILED', message: 'Control operation failed.' } }; }
|
|
71
|
+
await atomic(request.replace('.request.json', '.result.json'), result);
|
|
72
|
+
await fs.rm(working, { force: true });
|
|
73
|
+
}
|
|
74
|
+
} catch (e) { state.control_error = e.code || 'CONTROL_FAILED'; }
|
|
75
|
+
finally { busy = false; }
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const spec = await json(path.join(dir, 'spec.json'));
|
|
79
|
+
state = await json(path.join(dir, 'state.json'));
|
|
80
|
+
if (state.state !== 'queued') process.exit(0);
|
|
81
|
+
state.state = 'starting'; state.supervisor_pid = process.pid; state.supervisor_identity = await processIdentity(process.pid);
|
|
82
|
+
state.started_at = new Date().toISOString(); state.output_bytes = { stdout: 0, stderr: 0, terminal: 0 };
|
|
83
|
+
for (const stream of Object.keys(state.output_bytes)) fds[stream] = sync.openSync(path.join(dir, `${stream}.bin`), 'a', 0o600);
|
|
84
|
+
await save();
|
|
85
|
+
const env = envWith(spec.env);
|
|
86
|
+
if (spec.kind === 'terminal') {
|
|
87
|
+
pty = createRequire(path.join(spec.packageRoot, 'package.json'))('@lydell/node-pty');
|
|
88
|
+
child = pty.spawn(spec.executable, spec.args, { name: env.TERM || 'xterm-256color', cols: spec.cols, rows: spec.rows, cwd: spec.cwd, env, encoding: null });
|
|
89
|
+
child.onData(data => write('terminal', data));
|
|
90
|
+
child.onExit(({ exitCode, signal }) => { void finish(exitCode, signal); });
|
|
91
|
+
if (spec.stdin) child.write(Buffer.from(spec.stdin, 'base64'));
|
|
92
|
+
} else {
|
|
93
|
+
child = spawn(spec.executable, spec.args, { cwd: spec.cwd, env, detached: process.platform !== 'win32', windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
94
|
+
child.stdout.on('data', data => write('stdout', data));
|
|
95
|
+
child.stderr.on('data', data => write('stderr', data));
|
|
96
|
+
child.once('error', e => { void finish(null, null, e); });
|
|
97
|
+
child.once('exit', (code, signal) => { state.root_exited = { exit_code: code, signal, at: new Date().toISOString() }; });
|
|
98
|
+
child.once('close', (code, signal) => { void finish(code, signal); });
|
|
99
|
+
child.stdin.on('error', error => { state.stdin_error = error.code || 'STDIN_WRITE_FAILED'; });
|
|
100
|
+
child.stdin.end(spec.stdin ? Buffer.from(spec.stdin, 'base64') : undefined);
|
|
101
|
+
}
|
|
102
|
+
state.pid = child.pid;
|
|
103
|
+
if (!done) { state.state = 'running'; await save(); }
|
|
104
|
+
if (!done) {
|
|
105
|
+
beat = setInterval(() => { if (!done) void save().catch(() => stop('completed_failure')); }, 1000);
|
|
106
|
+
poll = setInterval(() => { void control(); }, 75);
|
|
107
|
+
if (spec.timeout_ms > 0) {
|
|
108
|
+
const deadline = performance.now() + spec.timeout_ms;
|
|
109
|
+
const checkTimeout = () => { const left = deadline - performance.now(); if (left <= 0) void stop('timed_out'); else timeout = setTimeout(checkTimeout, Math.min(left, 2147483647)); };
|
|
110
|
+
checkTimeout();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
process.on('SIGTERM', () => { void stop('cancelled'); });
|
|
114
|
+
process.on('SIGINT', () => { void stop('cancelled'); });
|
|
115
|
+
await fs.rm(path.join(dir, 'spec.json'), { force: true });
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (state) await finish(null, null, error);
|
|
118
|
+
process.exitCode = 1;
|
|
119
|
+
}
|