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
package/README.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# rootRework
|
|
2
|
+
|
|
3
|
+
Run either command on your computer with Node.js 22.13 or later:
|
|
4
|
+
|
|
5
|
+
npx -y @rootenterprise/rootrework@latest remote
|
|
6
|
+
npx -y rootrework@latest remote
|
|
7
|
+
|
|
8
|
+
Both commands also start remote mode when `remote` is omitted. Complete the browser connection, then add rootRework in ChatGPT using `https://rootrework.com/mcp`, OAuth, and Dynamic Client Registration. Use the same browser for installation and ChatGPT authorization. No customer username, password, subscription activation, or device capability is required.
|
|
9
|
+
|
|
10
|
+
Check the live connection at `https://rootrework.com/verify`. In ChatGPT, select rootRework using its **Try in chat** control or the plugin picker for each chat where you want to use it. The host may require a plugin selection for a later request; this reuses the connected account without repeating authorization. Existing account credentials remain optional for recovering older installations. The `session` command opens the current installation's browser workspace when you change browsers or clear site data. Keep installation state private.
|
|
11
|
+
|
|
12
|
+
For a local MCP client, use command `npx` with arguments `-y`, `@rootenterprise/rootrework@latest`, `stdio`. Standard input and output carry MCP JSON messages; diagnostics use standard error. This local transport runs tools under the invoking operating-system user. OAuth DCR protects the public remote service.
|
|
13
|
+
|
|
14
|
+
Use `rootrework status`, `diagnose`, `repair`, `restart`, `stop`, `update`, or `uninstall` through the same npx package command. Updates preserve valid registration, tokens, device identity and retained results. `uninstall` preserves data; `uninstall --purge` removes only that installation's owned data after its processes stop. No independent subscription or billing service is installed.
|
|
15
|
+
|
|
16
|
+
State uses native conventions: `%LOCALAPPDATA%\rootRework` on Windows, `~/Library/Application Support/rootRework` on macOS, and `${XDG_STATE_HOME:-~/.local/state}/rootRework` on Linux. npm-cache projections are compatibility views; the writable native state directory preserves operation across cache removal.
|
|
17
|
+
|
|
18
|
+
The `.org` domain redirects with HTTP 308. Configure MCP clients with the canonical `.com` URL. Some HTTP clients remove bearer authorization across origins; a redirect cannot force them to forward it.
|
|
19
|
+
|
|
20
|
+
Tools run with the current user's permissions and can change files or execute commands. Tool availability reflects installed native capabilities. DOCX/XLSX editing preserves supported document parts; PDF extraction reads text layers and does not perform OCR. Scheduled commands require the device agent to be running.
|
package/bin/cli.mjs
CHANGED
|
@@ -1,2 +1,267 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import '
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { packageRoot, statePath, readJson, writeJson, withLock, materialize, materializeProjection, activateInstallation, removeOwnedCode, login, api, alive, sleep, requestJson, openBrowser, initializeOwner } from '../src/client.mjs';
|
|
8
|
+
import { installService, serviceAction, run, windowsProcessArguments } from '../src/service.mjs';
|
|
9
|
+
import { runAgent } from '../src/agent.mjs';
|
|
10
|
+
|
|
11
|
+
const args = process.argv.slice(2);
|
|
12
|
+
const option = (name, fallback) => { const index = args.indexOf(name); if (index < 0) return fallback; if (!args[index + 1] || args[index + 1].startsWith('--')) throw new Error(`A value is required for ${name}.`); return args[index + 1]; };
|
|
13
|
+
const command = args.includes('--help') || args.includes('-h') ? 'help' : args[0] && !args[0].startsWith('-') ? args[0] : 'remote';
|
|
14
|
+
const stateDir = path.resolve(option('--state-dir', statePath()));
|
|
15
|
+
const output = value => process.stdout.write(JSON.stringify(value) + '\n');
|
|
16
|
+
const manifest = await readJson(path.join(packageRoot, 'package.json'));
|
|
17
|
+
const locked = fn => withLock(path.join(stateDir, 'lifecycle.lock'), fn);
|
|
18
|
+
async function processLive(pid) {
|
|
19
|
+
if (!alive(pid)) return false;
|
|
20
|
+
if (process.platform !== 'linux') return true;
|
|
21
|
+
const stat = await fs.readFile(`/proc/${pid}/stat`, 'utf8').catch(error => { if (error.code === 'ENOENT' || error.code === 'ESRCH') return ''; throw error; });
|
|
22
|
+
return Boolean(stat) && !/\) [ZX] /.test(stat);
|
|
23
|
+
}
|
|
24
|
+
const versionParts = v => v.split('+')[0].split('-');
|
|
25
|
+
function compareVersion(a, b) {
|
|
26
|
+
const [ac, ap] = versionParts(a), [bc, bp] = versionParts(b);
|
|
27
|
+
const left = ac.split('.').map(Number), right = bc.split('.').map(Number);
|
|
28
|
+
for (let i = 0; i < 3; i++) if (left[i] !== right[i]) return left[i] > right[i] ? 1 : -1;
|
|
29
|
+
if (ap === bp) return 0;
|
|
30
|
+
if (ap === undefined || bp === undefined) return ap === undefined ? 1 : -1;
|
|
31
|
+
const aa = ap.split('.'), bb = bp.split('.');
|
|
32
|
+
for (let i = 0; i < Math.max(aa.length, bb.length); i++) { if (aa[i] === bb[i]) continue; if (aa[i] === undefined || bb[i] === undefined) return aa[i] === undefined ? -1 : 1; const an = /^\d+$/.test(aa[i]), bn = /^\d+$/.test(bb[i]); if (an !== bn) return an ? -1 : 1; return (an ? Number(aa[i]) > Number(bb[i]) : aa[i] > bb[i]) ? 1 : -1; }
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
async function ownedProcess(agent) {
|
|
36
|
+
if (!agent || !await processLive(agent.pid) || typeof agent.release !== 'string') return false;
|
|
37
|
+
if (process.platform === 'linux') {
|
|
38
|
+
const cmd = await fs.readFile(`/proc/${agent.pid}/cmdline`, 'utf8').catch(() => '');
|
|
39
|
+
const argv = cmd.split('\0'), index = argv.indexOf('--state-dir');
|
|
40
|
+
return argv.includes(path.join(agent.release, 'bin', 'cli.mjs')) && index >= 0 && argv[index + 1] === stateDir;
|
|
41
|
+
}
|
|
42
|
+
if (process.platform === 'win32') {
|
|
43
|
+
const argv = await windowsProcessArguments(agent.pid);
|
|
44
|
+
if (!argv) return false;
|
|
45
|
+
const samePath = (a, b) => typeof a === 'string' && path.win32.isAbsolute(a) && path.win32.normalize(a).toLowerCase() === path.win32.normalize(b).toLowerCase();
|
|
46
|
+
const script = argv.findIndex(value => samePath(value, path.join(agent.release, 'bin', 'cli.mjs')));
|
|
47
|
+
const state = argv.indexOf('--state-dir');
|
|
48
|
+
return script > 0 && argv[script + 1] === 'run' && state > script && samePath(argv[state + 1], stateDir);
|
|
49
|
+
}
|
|
50
|
+
const result = await run('ps', ['-p', String(agent.pid), '-o', 'command=']);
|
|
51
|
+
return result.code === 0 && result.stdout.includes(path.join(agent.release, 'bin', 'cli.mjs')) && result.stdout.includes(stateDir);
|
|
52
|
+
}
|
|
53
|
+
async function status() {
|
|
54
|
+
const current = await readJson(path.join(stateDir, 'status.json'), { state: 'uninstalled' });
|
|
55
|
+
const agent = await readJson(path.join(stateDir, 'agent.json'));
|
|
56
|
+
const running = Boolean(agent && await ownedProcess(agent));
|
|
57
|
+
return { ...current, running, stateDir, installation: await readJson(path.join(stateDir, 'installation.json')), service: await readJson(path.join(stateDir, 'service.json')), ...(current.state === 'ready' && !running ? { state: 'disconnected' } : {}), ...(agent && await processLive(agent.pid) && !running ? { identity_mismatch: true } : {}) };
|
|
58
|
+
}
|
|
59
|
+
async function stop() {
|
|
60
|
+
if (process.platform !== 'win32') await serviceAction(stateDir, 'stop');
|
|
61
|
+
const agent = await readJson(path.join(stateDir, 'agent.json'));
|
|
62
|
+
if (agent && await processLive(agent.pid)) {
|
|
63
|
+
if (!await ownedProcess(agent)) {
|
|
64
|
+
const present = await readJson(path.join(stateDir, 'agent.json'));
|
|
65
|
+
if (!present || present.instanceId !== agent.instanceId || !await processLive(agent.pid)) return status();
|
|
66
|
+
throw new Error('The recorded process identity no longer matches this installation.');
|
|
67
|
+
}
|
|
68
|
+
if (process.platform === 'win32') await writeJson(path.join(stateDir, 'agent-stop.json'), { instanceId: agent.instanceId, pid: agent.pid, requestedAt: Date.now() });
|
|
69
|
+
else process.kill(agent.pid, 'SIGTERM');
|
|
70
|
+
for (let count = 0; count < 150; count++) {
|
|
71
|
+
const current = await readJson(path.join(stateDir, 'agent.json'));
|
|
72
|
+
if (!current || current.instanceId !== agent.instanceId || !await processLive(agent.pid)) break;
|
|
73
|
+
await sleep(100);
|
|
74
|
+
}
|
|
75
|
+
const remaining = await readJson(path.join(stateDir, 'agent.json'));
|
|
76
|
+
if (remaining?.instanceId === agent.instanceId && await processLive(agent.pid)) throw new Error('The agent has not completed shutdown. Its process was not force-killed.');
|
|
77
|
+
}
|
|
78
|
+
if (process.platform === 'win32') await serviceAction(stateDir, 'stop');
|
|
79
|
+
return status();
|
|
80
|
+
}
|
|
81
|
+
async function launch(release, service) {
|
|
82
|
+
let manager = service ? await installService(stateDir, release) : null;
|
|
83
|
+
if (!manager?.installed) {
|
|
84
|
+
if (!service) await serviceAction(stateDir, 'uninstall');
|
|
85
|
+
const file = await fs.open(path.join(stateDir, 'agent.log'), 'a', 0o600);
|
|
86
|
+
const child = spawn(process.execPath, [path.join(release, 'bin', 'cli.mjs'), 'run', '--state-dir', stateDir], { detached: true, stdio: ['ignore', file.fd, file.fd], windowsHide: true });
|
|
87
|
+
try { await new Promise((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject); }); child.unref(); } finally { await file.close(); }
|
|
88
|
+
manager = { installed: false, mode: 'detached', pid: child.pid, stateDir, release, ...(manager?.reason ? { reason: manager.reason } : {}) };
|
|
89
|
+
await writeJson(path.join(stateDir, 'service.json'), manager);
|
|
90
|
+
}
|
|
91
|
+
return manager;
|
|
92
|
+
}
|
|
93
|
+
async function ready(prepared) {
|
|
94
|
+
const maximum = Number(option('--startup-timeout', 30000));
|
|
95
|
+
if (!Number.isSafeInteger(maximum) || maximum < 1) throw new Error('startup-timeout must be a positive number of milliseconds.');
|
|
96
|
+
const deadline = Date.now() + maximum;
|
|
97
|
+
while (Date.now() < deadline) {
|
|
98
|
+
const current = await status();
|
|
99
|
+
if (current.running && current.release === prepared.release && current.state === 'ready' && current.version === prepared.version) return current;
|
|
100
|
+
if (current.release === prepared.release && current.state === 'failed') throw new Error(current.error || 'The agent failed.');
|
|
101
|
+
if (current.identity_mismatch) throw new Error('The recorded process identity no longer matches this installation.');
|
|
102
|
+
await sleep(200);
|
|
103
|
+
}
|
|
104
|
+
throw Object.assign(new Error('The agent did not become ready before the startup deadline. Use status or diagnose to inspect the connection.'), { code: 'startup_timeout' });
|
|
105
|
+
}
|
|
106
|
+
async function transition(prepared, { service = true, restart = false } = {}) {
|
|
107
|
+
const current = await status(), journalFile = path.join(stateDir, 'transition.json');
|
|
108
|
+
const interrupted = await readJson(journalFile);
|
|
109
|
+
const previous = interrupted?.previous || current.installation;
|
|
110
|
+
const wasRunning = interrupted?.wasRunning ?? current.running;
|
|
111
|
+
const previousService = interrupted?.previousService || current.service;
|
|
112
|
+
if (current.running && current.release === prepared.release && current.state === 'ready' && !restart) {
|
|
113
|
+
await activateInstallation(stateDir, prepared); await fs.rm(journalFile, { force: true });
|
|
114
|
+
return { ...await status(), projection: prepared.projection, reused: true };
|
|
115
|
+
}
|
|
116
|
+
const check = await run(process.execPath, [path.join(prepared.release, 'bin', 'cli.mjs'), 'self-test']);
|
|
117
|
+
if (check.code !== 0) {
|
|
118
|
+
let detail; try { detail = JSON.parse(check.stdout.trim().split('\n').at(-1)).message; } catch {}
|
|
119
|
+
throw new Error('Candidate verification failed; the working installation is retained.' + (detail ? ' ' + detail : ''));
|
|
120
|
+
}
|
|
121
|
+
await writeJson(journalFile, { previous, next: prepared, wasRunning, previousService, service, startedAt: interrupted?.startedAt || Date.now() });
|
|
122
|
+
try {
|
|
123
|
+
let manager = current.service;
|
|
124
|
+
if (!current.running || current.release !== prepared.release || restart) {
|
|
125
|
+
if (current.running) await stop();
|
|
126
|
+
await activateInstallation(stateDir, prepared);
|
|
127
|
+
await writeJson(path.join(stateDir, 'status.json'), { state: 'connecting', release: prepared.release, version: prepared.version, updatedAt: Date.now() });
|
|
128
|
+
manager = await launch(prepared.release, service);
|
|
129
|
+
}
|
|
130
|
+
const result = await ready(prepared);
|
|
131
|
+
await activateInstallation(stateDir, prepared);
|
|
132
|
+
await fs.rm(journalFile, { force: true });
|
|
133
|
+
return { ...result, manager, projection: prepared.projection, reused: false };
|
|
134
|
+
} catch (error) {
|
|
135
|
+
let recovery = { state: 'not_available' };
|
|
136
|
+
try {
|
|
137
|
+
await stop();
|
|
138
|
+
if (previous?.release && previous.release !== prepared.release) {
|
|
139
|
+
await writeJson(path.join(stateDir, 'installation.json'), previous);
|
|
140
|
+
await materializeProjection(previous.release, stateDir, previous.version, prepared.projection, previous.release);
|
|
141
|
+
if (wasRunning) {
|
|
142
|
+
await writeJson(path.join(stateDir, 'status.json'), { state: 'connecting', release: previous.release, version: previous.version, updatedAt: Date.now() });
|
|
143
|
+
await launch(previous.release, Boolean(previousService?.installed));
|
|
144
|
+
const restored = await ready(previous);
|
|
145
|
+
recovery = { state: 'ready', version: restored.version, release: previous.release };
|
|
146
|
+
} else recovery = { state: 'configured', version: previous.version, release: previous.release };
|
|
147
|
+
await fs.rm(journalFile, { force: true });
|
|
148
|
+
} else recovery = { state: 'failed', message: 'No different previously selected release is available.' };
|
|
149
|
+
} catch (restoreError) { recovery = { state: 'failed', message: restoreError.message }; }
|
|
150
|
+
throw Object.assign(new Error(error.message), { code: error.code || 'startup_failed', recovery });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
async function start({ service = true, restart = false, source = packageRoot, allowDowngrade = false } = {}) {
|
|
154
|
+
if (!await readJson(path.join(stateDir, 'session.json'))) await login(stateDir, { issuer: option('--issuer', 'https://rootrework.com') });
|
|
155
|
+
const selected = await readJson(path.join(source, 'package.json'));
|
|
156
|
+
const installed = await readJson(path.join(stateDir, 'installation.json'));
|
|
157
|
+
if (!allowDowngrade && installed && compareVersion(installed.version, selected.version) > 0 && await readJson(path.join(installed.release, 'package.json'))) source = installed.release;
|
|
158
|
+
const prepared = await materialize(stateDir, source, { activate: false, projectionRoot: packageRoot });
|
|
159
|
+
if (args.includes('--foreground')) {
|
|
160
|
+
const current = await status();
|
|
161
|
+
if (current.running) return { ...current, projection: prepared.projection, reused: true };
|
|
162
|
+
await activateInstallation(stateDir, prepared);
|
|
163
|
+
return { foreground: prepared.release, projection: prepared.projection };
|
|
164
|
+
}
|
|
165
|
+
return transition(prepared, { service, restart });
|
|
166
|
+
}
|
|
167
|
+
async function ownerInitialize(file) {
|
|
168
|
+
const result = await initializeOwner(stateDir, file);
|
|
169
|
+
return { ...result, ...await materialize(stateDir) };
|
|
170
|
+
}
|
|
171
|
+
async function npmInstall(candidate, version) {
|
|
172
|
+
const installArgs = ['install', '--prefix', candidate, '--ignore-scripts', '--no-audit', '--no-fund', `${manifest.name}@${version}`];
|
|
173
|
+
if (process.platform !== 'win32') return run('npm', installArgs, { timeout_ms: 300000 });
|
|
174
|
+
const locations = [path.dirname(process.execPath), ...(process.env.PATH || '').split(path.delimiter)];
|
|
175
|
+
for (const location of locations) { const cli = path.join(location, 'node_modules', 'npm', 'bin', 'npm-cli.js'); if (await fs.stat(cli).then(info => info.isFile(), () => false)) return run(process.execPath, [cli, ...installArgs], { timeout_ms: 300000 }); }
|
|
176
|
+
throw new Error('The installed npm executable could not be resolved.');
|
|
177
|
+
}
|
|
178
|
+
async function update() {
|
|
179
|
+
if (!['@rootenterprise/rootrework', 'rootrework'].includes(manifest.name)) throw new Error('The installed package identity is not supported.');
|
|
180
|
+
const metadata = await requestJson('https://registry.npmjs.org/' + encodeURIComponent(manifest.name));
|
|
181
|
+
const version = metadata['dist-tags']?.latest;
|
|
182
|
+
if (typeof version !== 'string' || !/^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/.test(version) || !metadata.versions?.[version]) throw new Error('No valid published latest release is available.');
|
|
183
|
+
const prior = await readJson(path.join(stateDir, 'installation.json'));
|
|
184
|
+
if (prior?.version === version) return { ...await start({ service: !args.includes('--no-service') }), update: 'current' };
|
|
185
|
+
let candidate = path.join(stateDir, 'candidates', version);
|
|
186
|
+
const candidates = await readJson(path.join(stateDir, 'candidates.json'), []);
|
|
187
|
+
if (!candidates.includes(candidate) && await fs.stat(candidate).catch(() => null)) candidate += '-' + randomUUID();
|
|
188
|
+
if (!candidates.includes(candidate)) { candidates.push(candidate); await writeJson(path.join(stateDir, 'candidates.json'), candidates); }
|
|
189
|
+
await fs.mkdir(candidate, { recursive: true, mode: 0o700 });
|
|
190
|
+
const result = await npmInstall(candidate, version);
|
|
191
|
+
if (result.code !== 0) throw new Error('Candidate acquisition failed; the working installation is retained.');
|
|
192
|
+
const source = path.join(candidate, 'node_modules', ...manifest.name.split('/'));
|
|
193
|
+
const acquired = await readJson(path.join(source, 'package.json'));
|
|
194
|
+
if (acquired?.name !== manifest.name || acquired.version !== version) throw new Error('The acquired candidate does not match the selected registry release.');
|
|
195
|
+
return { ...await start({ service: !args.includes('--no-service'), source, allowDowngrade: true }), update: 'completed' };
|
|
196
|
+
}
|
|
197
|
+
async function selfTest() {
|
|
198
|
+
const testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'rootrework-'));
|
|
199
|
+
let backend;
|
|
200
|
+
try {
|
|
201
|
+
const { createBackend } = await import('../src/backend.mjs');
|
|
202
|
+
backend = await createBackend({ stateDir: testDir, packageRoot, deviceId: randomUUID() });
|
|
203
|
+
const info = await backend.call('system_info', {});
|
|
204
|
+
const task = await backend.call('exec', { executable: process.execPath, args: ['-e', 'process.stdout.write("ready")'], cwd: testDir, wait_ms: 10000, timeout_ms: 10000 });
|
|
205
|
+
const data = await backend.call('task_events', { task_id: task.task_id });
|
|
206
|
+
if (task.state !== 'completed_success' || task.exit_code !== 0 || Buffer.from(data.data, 'base64').toString() !== 'ready') throw new Error(`The candidate execution backend failed its readback check: state ${task.state}, exit ${task.exit_code ?? 'unknown'}, supervisor exit ${task.supervisor_exit?.code ?? 'unknown'}.`);
|
|
207
|
+
return { state: 'verified', version: manifest.version, tools: backend.tools.length, platform: info };
|
|
208
|
+
} finally { await backend?.close(); await fs.rm(testDir, { recursive: true, force: true }); }
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
let result;
|
|
212
|
+
if (['--version', 'version', '-v'].includes(command) || args.includes('--version')) result = { version: manifest.version };
|
|
213
|
+
else if (command === 'stdio') { const { runStdio } = await import('../src/stdio.mjs'); await runStdio(path.join(stateDir, 'stdio')); }
|
|
214
|
+
else if (command === 'run') result = await runAgent(stateDir);
|
|
215
|
+
else if (command === 'owner-init') result = await locked(() => ownerInitialize(args[1]));
|
|
216
|
+
else if (command === 'login') { const s = await login(stateDir, { issuer: option('--issuer', 'https://rootrework.com') }); result = { state: 'configured', device_id: s.deviceId }; }
|
|
217
|
+
else if (command === 'status') result = await status();
|
|
218
|
+
else if (command === 'stop') result = await locked(stop);
|
|
219
|
+
else if (command === 'restart') result = await locked(() => start({ service: !args.includes('--no-service'), restart: true }));
|
|
220
|
+
else if (command === 'repair') result = await locked(async () => {
|
|
221
|
+
const previous = await readJson(path.join(stateDir, 'session.json'));
|
|
222
|
+
if (previous) {
|
|
223
|
+
try { await api(stateDir, '/agent/identity', undefined, { method: 'GET', body: undefined }); }
|
|
224
|
+
catch (error) {
|
|
225
|
+
if (!['invalid_client', 'invalid_grant', 'login_required'].includes(error.code)) throw error;
|
|
226
|
+
if (previous.ownerInput !== undefined) throw new Error('The private owner provisioning needs recovery. Its existing state was retained.');
|
|
227
|
+
await login(stateDir, { issuer: previous.issuer, replaceRegistration: error.code === 'invalid_client' });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return { ...await start({ service: !args.includes('--no-service') }), repair: 'completed' };
|
|
231
|
+
});
|
|
232
|
+
else if (command === 'update') result = await locked(update);
|
|
233
|
+
else if (command === 'verify' || command === 'diagnose') {
|
|
234
|
+
const s = await status();
|
|
235
|
+
const health = await requestJson('https://rootrework.com/health').catch(error => ({ error: error.message }));
|
|
236
|
+
result = { ...s, platform: { os: process.platform, arch: process.arch, node: process.version }, health, session_present: Boolean(await readJson(path.join(stateDir, 'session.json'))) };
|
|
237
|
+
} else if (command === 'session') {
|
|
238
|
+
result = await api(stateDir, '/oauth/session', { return_to: '/verify' });
|
|
239
|
+
if (result.verification_uri && !args.includes('--no-browser')) await openBrowser(result.verification_uri);
|
|
240
|
+
result = { verification_uri: result.verification_uri };
|
|
241
|
+
} else if (command === 'project') {
|
|
242
|
+
const destination = option('--destination');
|
|
243
|
+
if (!destination) throw new Error('A destination is required.');
|
|
244
|
+
result = await locked(async () => { const installed = await readJson(path.join(stateDir, 'installation.json')); return { path: await materializeProjection(installed?.release || packageRoot, stateDir, installed?.version || manifest.version, path.resolve(destination), installed?.release || packageRoot) }; });
|
|
245
|
+
} else if (command === 'uninstall') result = await locked(async () => {
|
|
246
|
+
await stop(); await serviceAction(stateDir, 'uninstall');
|
|
247
|
+
const removal = await removeOwnedCode(stateDir, { purge: args.includes('--purge') });
|
|
248
|
+
if (removal.retained_data) await writeJson(path.join(stateDir, 'status.json'), { state: 'uninstalled', updatedAt: Date.now(), retainedData: true });
|
|
249
|
+
return { state: 'uninstalled', stateDir, ...removal };
|
|
250
|
+
});
|
|
251
|
+
else if (command === 'self-test') result = await selfTest();
|
|
252
|
+
else if (['remote', 'install', 'start'].includes(command)) result = await locked(() => start({ service: !args.includes('--no-service') }));
|
|
253
|
+
else if (['help', '--help', '-h'].includes(command)) result = { usage: 'rootrework [remote|stdio|status|diagnose|repair|restart|stop|update|uninstall]', options: ['--state-dir PATH', '--foreground', '--no-service', '--startup-timeout MILLISECONDS', 'uninstall --purge'], default: 'remote' };
|
|
254
|
+
else throw new Error(`Unknown command: ${command}`);
|
|
255
|
+
if (result?.foreground) {
|
|
256
|
+
const child = spawn(process.execPath, [path.join(result.foreground, 'bin', 'cli.mjs'), 'run', '--state-dir', stateDir], { stdio: 'inherit', windowsHide: true });
|
|
257
|
+
const interrupt = () => { if (!child.killed) child.kill('SIGINT'); }, terminate = () => { if (!child.killed) child.kill('SIGTERM'); };
|
|
258
|
+
process.on('SIGINT', interrupt); process.on('SIGTERM', terminate);
|
|
259
|
+
const outcome = await new Promise((resolve, reject) => { child.once('error', reject); child.once('exit', (code, signal) => resolve({ code, signal })); });
|
|
260
|
+
process.removeListener('SIGINT', interrupt); process.removeListener('SIGTERM', terminate);
|
|
261
|
+
process.exitCode = outcome.code ?? (outcome.signal === 'SIGINT' ? 130 : 143);
|
|
262
|
+
} else {
|
|
263
|
+
if (command === 'uninstall' && result.retained_data === false) await fs.rmdir(stateDir).catch(error => { if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error; });
|
|
264
|
+
if (command !== 'stdio') output(result);
|
|
265
|
+
}
|
|
266
|
+
if (result?.state === 'failed' && !(command === 'run' && result.recoverable === false)) process.exitCode = 1;
|
|
267
|
+
} catch (error) { const value = { state: 'failed', code: error.code || 'operation_failed', message: error.message, ...(error.recovery ? { recovery: error.recovery } : {}) }; if (command === 'stdio') process.stderr.write(JSON.stringify(value) + '\n'); else output(value); process.exitCode = 1; }
|
package/package.json
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
{
|
|
2
|
-
"
|
|
2
|
+
"name": "rootrework",
|
|
3
|
+
"version": "1.0.4",
|
|
3
4
|
"description": "Remote computer tools with OAuth registration and persistent execution.",
|
|
4
5
|
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"rootrework": "bin/cli.mjs"
|
|
8
|
+
},
|
|
5
9
|
"engines": {
|
|
6
10
|
"node": ">=22.13.0"
|
|
7
11
|
},
|
|
@@ -12,19 +16,34 @@
|
|
|
12
16
|
"type": "git",
|
|
13
17
|
"url": "https://github.com/rootEnterprise/rootRework.git"
|
|
14
18
|
},
|
|
15
|
-
"publishConfig": {
|
|
16
|
-
"access": "public",
|
|
17
|
-
"registry": "https://registry.npmjs.org/"
|
|
18
|
-
},
|
|
19
|
-
"name": "rootrework",
|
|
20
|
-
"bin": {
|
|
21
|
-
"rootrework": "bin/cli.mjs"
|
|
22
|
-
},
|
|
23
19
|
"files": [
|
|
24
20
|
"bin",
|
|
25
|
-
"
|
|
21
|
+
"src/client.mjs",
|
|
22
|
+
"src/service.mjs",
|
|
23
|
+
"src/agent.mjs",
|
|
24
|
+
"src/retention.mjs",
|
|
25
|
+
"src/backend.mjs",
|
|
26
|
+
"src/process.mjs",
|
|
27
|
+
"src/supervisor.mjs",
|
|
28
|
+
"src/search-worker.mjs",
|
|
29
|
+
"src/extended.mjs",
|
|
30
|
+
"config/client.json",
|
|
31
|
+
"LICENSE",
|
|
32
|
+
"src/stdio.mjs",
|
|
33
|
+
"README.md"
|
|
26
34
|
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"test": "node --test tests/*.test.mjs",
|
|
37
|
+
"check": "node --check bin/cli.mjs && node --check src/worker.mjs"
|
|
38
|
+
},
|
|
27
39
|
"dependencies": {
|
|
28
|
-
"@
|
|
40
|
+
"@lydell/node-pty": "1.2.0-beta.15",
|
|
41
|
+
"fflate": "0.8.3",
|
|
42
|
+
"@xmldom/xmldom": "0.9.12",
|
|
43
|
+
"pdfjs-dist": "6.4.299"
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public",
|
|
47
|
+
"registry": "https://registry.npmjs.org/"
|
|
29
48
|
}
|
|
30
49
|
}
|
package/src/agent.mjs
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { createBackend } from './backend.mjs';
|
|
6
|
+
import { createExtensions } from './extended.mjs';
|
|
7
|
+
import { createRetainer } from './retention.mjs';
|
|
8
|
+
import { api, readJson, writeJson, alive, sleep, packageRoot } from './client.mjs';
|
|
9
|
+
|
|
10
|
+
export async function runAgent(stateDir) {
|
|
11
|
+
const lockFile = path.join(stateDir, 'agent.json');
|
|
12
|
+
await fs.mkdir(stateDir, { recursive: true, mode: 0o700 });
|
|
13
|
+
const existing = await readJson(lockFile);
|
|
14
|
+
if (existing && alive(existing.pid)) return { state: 'already_running', pid: existing.pid, device_id: existing.deviceId };
|
|
15
|
+
const handle = await fs.open(lockFile, 'wx', 0o600).catch(async error => {
|
|
16
|
+
if (error.code !== 'EEXIST') throw error;
|
|
17
|
+
const stale = await readJson(lockFile);
|
|
18
|
+
if (stale && alive(stale.pid)) throw new Error('The agent is already running.');
|
|
19
|
+
await fs.rm(lockFile, { force: true });
|
|
20
|
+
return fs.open(lockFile, 'wx', 0o600);
|
|
21
|
+
});
|
|
22
|
+
const session = await readJson(path.join(stateDir, 'session.json'));
|
|
23
|
+
if (!session) { await handle.close(); await fs.rm(lockFile, { force: true }); throw new Error('Sign in before starting the agent.'); }
|
|
24
|
+
const identity = { pid: process.pid, instanceId: randomUUID(), deviceId: session.deviceId, startedAt: Date.now(), release: packageRoot };
|
|
25
|
+
await handle.writeFile(JSON.stringify(identity)); await handle.close();
|
|
26
|
+
let stopped = false;
|
|
27
|
+
const controller = new AbortController();
|
|
28
|
+
const inflight = new Map();
|
|
29
|
+
let backend, extensions, retainer, retentionLoop;
|
|
30
|
+
const status = async (state, detail = {}) => writeJson(path.join(stateDir, 'status.json'), { state, ...identity, updatedAt: Date.now(), ...detail });
|
|
31
|
+
const stop = () => { stopped = true; controller.abort(); };
|
|
32
|
+
process.once('SIGTERM', stop); process.once('SIGINT', stop);
|
|
33
|
+
const stopFile = path.join(stateDir, 'agent-stop.json');
|
|
34
|
+
let checkingStop = false;
|
|
35
|
+
const stopTimer = setInterval(() => {
|
|
36
|
+
if (checkingStop || stopped) return;
|
|
37
|
+
checkingStop = true;
|
|
38
|
+
readJson(stopFile).then(request => {
|
|
39
|
+
if (request?.instanceId === identity.instanceId && request.pid === identity.pid) stop();
|
|
40
|
+
}).catch(() => {}).finally(() => { checkingStop = false; });
|
|
41
|
+
}, 100);
|
|
42
|
+
stopTimer.unref();
|
|
43
|
+
try {
|
|
44
|
+
await status('materialising');
|
|
45
|
+
backend = await createBackend({ stateDir, packageRoot, deviceId: session.deviceId });
|
|
46
|
+
extensions = await createExtensions({ stateDir, packageRoot, deviceId: session.deviceId, backend });
|
|
47
|
+
const tools = [...backend.tools, ...extensions.tools];
|
|
48
|
+
const info = await backend.call('system_info', {});
|
|
49
|
+
const probe = path.join(stateDir, `.probe-${identity.instanceId}`);
|
|
50
|
+
await fs.writeFile(probe, identity.instanceId);
|
|
51
|
+
if (await fs.readFile(probe, 'utf8') !== identity.instanceId) throw new Error('State filesystem readback failed.');
|
|
52
|
+
await fs.rm(probe);
|
|
53
|
+
const executionProbe = await backend.call('exec', { executable: process.execPath, args: ['-e', 'process.stdout.write("ready")'], cwd: stateDir, wait_ms: 10000, timeout_ms: 10000 });
|
|
54
|
+
if (executionProbe.state !== 'completed_success' || executionProbe.exit_code !== 0) throw new Error('The local execution backend did not complete its startup check.');
|
|
55
|
+
const probeOutput = await backend.call('task_events', { task_id: executionProbe.task_id });
|
|
56
|
+
if (Buffer.from(probeOutput.data, 'base64').toString() !== 'ready') throw new Error('The local execution backend output did not pass readback.');
|
|
57
|
+
await fs.rm(path.join(stateDir, 'tasks', executionProbe.task_id), { recursive: true, force: true });
|
|
58
|
+
const manifest = await readJson(path.join(packageRoot, 'package.json'));
|
|
59
|
+
retainer = await createRetainer({ stateDir, deviceId: session.deviceId, backend, extensions, signal: controller.signal });
|
|
60
|
+
const register = async () => api(stateDir, '/agent/register', { deviceId: session.deviceId, name: session.deviceName || os.hostname(), version: manifest.version, platform: { os: process.platform, arch: process.arch }, capabilities: { ...info, ...extensions.capabilities, backendReady: true }, tools }, { signal: controller.signal });
|
|
61
|
+
const execute = async work => {
|
|
62
|
+
const file = path.join(stateDir, 'operations', work.id + '.json');
|
|
63
|
+
let operation = await readJson(file);
|
|
64
|
+
if (operation?.state === 'completed') {
|
|
65
|
+
await retainer.notify(work.name, operation.result, work.args);
|
|
66
|
+
await api(stateDir, '/agent/result', { deviceId: session.deviceId, id: work.id, result: operation.result, error: operation.error }, { signal: controller.signal });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (operation?.state === 'started' && !['exec', 'terminal_open', 'system_info', 'task_get', 'task_events', 'task_list', 'search_get', 'artifact_get', 'artifact_read', 'schedule_list', 'schedule_history'].includes(work.name)) {
|
|
70
|
+
operation = { ...operation, state: 'completed', result: { state: 'unknown_outcome', message: 'The agent stopped before the operation result was saved. Inspect the target before repeating it.' } };
|
|
71
|
+
} else {
|
|
72
|
+
await writeJson(file, { id: work.id, name: work.name, state: 'started', startedAt: Date.now() });
|
|
73
|
+
try {
|
|
74
|
+
const args = ['exec', 'terminal_open'].includes(work.name) ? { ...work.args, request_id: work.id } : work.args;
|
|
75
|
+
const target = backend.tools.some(t => t.name === work.name) ? backend : extensions;
|
|
76
|
+
const result = await target.call(work.name, args);
|
|
77
|
+
operation = { id: work.id, state: 'completed', result, finishedAt: Date.now() };
|
|
78
|
+
} catch (error) { operation = { id: work.id, state: 'completed', error: { code: error.code || 'operation_failed', message: error.message }, finishedAt: Date.now() }; }
|
|
79
|
+
}
|
|
80
|
+
await writeJson(file, operation);
|
|
81
|
+
await retainer.notify(work.name, operation.result, work.args);
|
|
82
|
+
await api(stateDir, '/agent/result', { deviceId: session.deviceId, id: work.id, result: operation.result, error: operation.error }, { signal: controller.signal });
|
|
83
|
+
};
|
|
84
|
+
let failures = 0, registered = false;
|
|
85
|
+
while (!stopped) {
|
|
86
|
+
try {
|
|
87
|
+
if (!registered) {
|
|
88
|
+
await status('connecting'); const attached = await register(); registered = true;
|
|
89
|
+
if (attached.cloud_retention && !retentionLoop) retentionLoop = retainer.run().catch(error => writeJson(path.join(stateDir, 'retention', 'status.json'), { state: 'failed', error: error.message, at: Date.now() }));
|
|
90
|
+
}
|
|
91
|
+
await status('ready', { version: manifest.version, tools: tools.length });
|
|
92
|
+
const batch = await api(stateDir, '/agent/poll', { deviceId: session.deviceId }, { signal: controller.signal });
|
|
93
|
+
for (const work of batch.operations || []) if (!inflight.has(work.id)) {
|
|
94
|
+
const task = execute(work).catch(async error => { if (!stopped) await status('reconnecting', { error: error.message }); }).finally(() => inflight.delete(work.id));
|
|
95
|
+
inflight.set(work.id, task);
|
|
96
|
+
}
|
|
97
|
+
failures = 0;
|
|
98
|
+
if (inflight.size) await Promise.race([...inflight.values(), sleep(1000)]);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (stopped) break;
|
|
101
|
+
const recoverable = !['invalid_grant', 'invalid_client', 'device_revoked', 'device_not_found', 'login_required'].includes(error.code) && error.status !== 403;
|
|
102
|
+
await status(recoverable ? 'reconnecting' : 'failed', { recoverable, error: error.message });
|
|
103
|
+
if (!recoverable) break;
|
|
104
|
+
registered = false;
|
|
105
|
+
await sleep(Math.min(30000, 500 * 2 ** Math.min(failures++, 6)) + Math.floor(Math.random() * 300));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
await Promise.allSettled([...inflight.values()]);
|
|
109
|
+
} finally {
|
|
110
|
+
clearInterval(stopTimer);
|
|
111
|
+
controller.abort();
|
|
112
|
+
await retentionLoop;
|
|
113
|
+
const lastSession = await readJson(path.join(stateDir, 'session.json'));
|
|
114
|
+
await fetch(`${lastSession.issuer}/agent/state`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${lastSession.token.access_token}` }, body: JSON.stringify({ deviceId: session.deviceId, state: 'stopped' }), signal: AbortSignal.timeout(3000) }).catch(() => {});
|
|
115
|
+
await extensions?.close?.(); await backend?.close?.();
|
|
116
|
+
const prior = await readJson(path.join(stateDir, 'status.json'));
|
|
117
|
+
if (prior?.state !== 'failed') await status('stopped');
|
|
118
|
+
const requested = await readJson(stopFile).catch(() => null);
|
|
119
|
+
if (requested?.instanceId === identity.instanceId && requested.pid === identity.pid) await fs.rm(stopFile, { force: true });
|
|
120
|
+
const current = await readJson(lockFile);
|
|
121
|
+
if (current?.instanceId === identity.instanceId) await fs.rm(lockFile, { force: true });
|
|
122
|
+
process.removeListener('SIGTERM', stop); process.removeListener('SIGINT', stop);
|
|
123
|
+
}
|
|
124
|
+
return await readJson(path.join(stateDir, 'status.json'));
|
|
125
|
+
}
|