omniforge-daemon 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -0
- package/omniforge.mjs +328 -0
- package/package.json +17 -0
package/README.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# omniforge-daemon
|
|
2
|
+
|
|
3
|
+
Connects a computer to your [Omniforge](https://omniforge.noahzhang.dev) agent.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npx omniforge-daemon pair ABCD-1234 # code comes from the console's "Add device"
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The daemon opens an **outbound** WebSocket — no ports to open, nothing
|
|
10
|
+
inbound. It runs the commands your agent asks for and streams back the output.
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
npx omniforge-daemon # run with saved credentials
|
|
14
|
+
npx omniforge-daemon status # show what's configured
|
|
15
|
+
npx omniforge-daemon logout # forget this machine's credentials
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Safety
|
|
19
|
+
|
|
20
|
+
- Confined to `~/omniforge` unless started with `OMNIFORGE_UNSAFE=1`.
|
|
21
|
+
- Credentials live in `~/.omniforge/credentials.json` (0600) and are scoped to
|
|
22
|
+
this one device; revoking it in the console cuts the connection immediately.
|
|
23
|
+
- Approval mode (on by default) makes every command wait for a click in the
|
|
24
|
+
console before it runs.
|
|
25
|
+
|
|
26
|
+
Zero dependencies, requires Node ≥ 21. Source: one file, ~300 lines — read it
|
|
27
|
+
before you run it.
|
|
28
|
+
|
|
29
|
+
## Environment
|
|
30
|
+
|
|
31
|
+
| Variable | Default | Meaning |
|
|
32
|
+
| --- | --- | --- |
|
|
33
|
+
| `OMNIFORGE_ROOT` | `~/omniforge` | workspace the agent is confined to |
|
|
34
|
+
| `OMNIFORGE_UNSAFE` | unset | `1` allows paths outside the workspace |
|
|
35
|
+
| `OMNIFORGE_LABEL` | hostname | name shown in the console |
|
|
36
|
+
| `OMNIFORGE_URL` | hosted deployment | point at your own self-hosted worker |
|
|
37
|
+
|
|
38
|
+
MIT
|
package/omniforge.mjs
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Omniforge daemon — connects this computer to your Omniforge agent.
|
|
3
|
+
// Zero dependencies; requires Node.js >= 21 (global WebSocket, global fetch).
|
|
4
|
+
//
|
|
5
|
+
// npx omniforge-daemon pair ABCD-1234 pair this machine, then run
|
|
6
|
+
// npx omniforge-daemon run with saved credentials
|
|
7
|
+
// npx omniforge-daemon status show what's configured
|
|
8
|
+
// npx omniforge-daemon logout forget this machine's credentials
|
|
9
|
+
//
|
|
10
|
+
// Optional env:
|
|
11
|
+
// OMNIFORGE_URL deployment to talk to (default: the hosted one)
|
|
12
|
+
// OMNIFORGE_ROOT workspace the agent works in (default: ~/omniforge)
|
|
13
|
+
// OMNIFORGE_UNSAFE set to 1 to allow commands/files outside the workspace
|
|
14
|
+
// OMNIFORGE_LABEL name shown in the console (default: hostname)
|
|
15
|
+
//
|
|
16
|
+
// The credentials file is the only secret on disk. It is written 0600 and holds
|
|
17
|
+
// a token scoped to this one device; revoking the device in the console makes
|
|
18
|
+
// it useless immediately.
|
|
19
|
+
|
|
20
|
+
import { spawn } from "node:child_process";
|
|
21
|
+
import { hostname, userInfo, platform, release, homedir } from "node:os";
|
|
22
|
+
import { mkdir, readFile, writeFile, readdir, stat, rm, chmod } from "node:fs/promises";
|
|
23
|
+
import { resolve, dirname, join, sep } from "node:path";
|
|
24
|
+
|
|
25
|
+
const VERSION = "1.0.0";
|
|
26
|
+
const DEFAULT_URL = "https://omniforge.noahzhang.dev";
|
|
27
|
+
const CONFIG_DIR = join(homedir(), ".omniforge");
|
|
28
|
+
const CREDENTIALS = join(CONFIG_DIR, "credentials.json");
|
|
29
|
+
const OUTPUT_CAP = 200_000;
|
|
30
|
+
const CLOSE_REVOKED = 4001;
|
|
31
|
+
|
|
32
|
+
const ROOT = resolve(process.env.OMNIFORGE_ROOT || join(homedir(), "omniforge"));
|
|
33
|
+
const UNSAFE = process.env.OMNIFORGE_UNSAFE === "1";
|
|
34
|
+
|
|
35
|
+
const log = (...args) => console.log("omniforge:", ...args);
|
|
36
|
+
const die = (msg, code = 1) => {
|
|
37
|
+
console.error("omniforge:", msg);
|
|
38
|
+
process.exit(code);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------- credentials
|
|
42
|
+
|
|
43
|
+
async function readCredentials() {
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(await readFile(CREDENTIALS, "utf8"));
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function writeCredentials(creds) {
|
|
52
|
+
await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
53
|
+
await writeFile(CREDENTIALS, JSON.stringify(creds, null, 2), { mode: 0o600 });
|
|
54
|
+
await chmod(CREDENTIALS, 0o600);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function clearCredentials() {
|
|
58
|
+
await rm(CREDENTIALS, { force: true });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------- workspace
|
|
62
|
+
|
|
63
|
+
function confine(p) {
|
|
64
|
+
const abs = resolve(ROOT, p ?? ".");
|
|
65
|
+
if (!UNSAFE && abs !== ROOT && !abs.startsWith(ROOT + sep)) {
|
|
66
|
+
throw new Error(`path "${p}" escapes workspace root ${ROOT} (start with OMNIFORGE_UNSAFE=1 to allow)`);
|
|
67
|
+
}
|
|
68
|
+
return abs;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function cap(s) {
|
|
72
|
+
if (s.length <= OUTPUT_CAP) return { text: s, truncated: false };
|
|
73
|
+
return { text: s.slice(0, OUTPUT_CAP), truncated: true };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function runExec({ cmd, cwd, timeoutMs }) {
|
|
77
|
+
return new Promise((done) => {
|
|
78
|
+
let workdir;
|
|
79
|
+
try {
|
|
80
|
+
workdir = confine(cwd);
|
|
81
|
+
} catch (e) {
|
|
82
|
+
return done({ ok: false, error: e.message });
|
|
83
|
+
}
|
|
84
|
+
const child = spawn("bash", ["-lc", cmd], { cwd: workdir, env: process.env });
|
|
85
|
+
let out = "";
|
|
86
|
+
let err = "";
|
|
87
|
+
let killed = false;
|
|
88
|
+
const timer = setTimeout(() => {
|
|
89
|
+
killed = true;
|
|
90
|
+
child.kill("SIGKILL");
|
|
91
|
+
}, Math.min(timeoutMs ?? 120_000, 280_000));
|
|
92
|
+
child.stdout.on("data", (d) => (out += d));
|
|
93
|
+
child.stderr.on("data", (d) => (err += d));
|
|
94
|
+
child.on("error", (e) => {
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
done({ ok: false, error: e.message });
|
|
97
|
+
});
|
|
98
|
+
child.on("close", (code) => {
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
const o = cap(out);
|
|
101
|
+
const e = cap(err);
|
|
102
|
+
done({
|
|
103
|
+
ok: !killed && code === 0,
|
|
104
|
+
exitCode: killed ? -1 : code,
|
|
105
|
+
stdout: o.text,
|
|
106
|
+
stderr: killed ? e.text + "\n[killed: timeout]" : e.text,
|
|
107
|
+
truncated: o.truncated || e.truncated,
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function handle(msg) {
|
|
114
|
+
switch (msg.type) {
|
|
115
|
+
case "exec":
|
|
116
|
+
return runExec(msg);
|
|
117
|
+
case "read_file": {
|
|
118
|
+
const content = await readFile(confine(msg.path), "utf8");
|
|
119
|
+
const c = cap(content);
|
|
120
|
+
return { ok: true, content: c.text, truncated: c.truncated };
|
|
121
|
+
}
|
|
122
|
+
case "write_file": {
|
|
123
|
+
const abs = confine(msg.path);
|
|
124
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
125
|
+
await writeFile(abs, msg.content ?? "", "utf8");
|
|
126
|
+
return { ok: true };
|
|
127
|
+
}
|
|
128
|
+
case "list_dir": {
|
|
129
|
+
const abs = confine(msg.path);
|
|
130
|
+
const names = await readdir(abs);
|
|
131
|
+
const entries = await Promise.all(
|
|
132
|
+
names.slice(0, 500).map(async (name) => {
|
|
133
|
+
try {
|
|
134
|
+
const s = await stat(resolve(abs, name));
|
|
135
|
+
return { name, type: s.isDirectory() ? "dir" : "file", size: s.size };
|
|
136
|
+
} catch {
|
|
137
|
+
return { name, type: "unknown", size: 0 };
|
|
138
|
+
}
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
return { ok: true, entries };
|
|
142
|
+
}
|
|
143
|
+
default:
|
|
144
|
+
return { ok: false, error: `unknown request type "${msg.type}"` };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------- commands
|
|
149
|
+
|
|
150
|
+
async function cmdPair(code, url) {
|
|
151
|
+
if (!code) die("usage: omniforge pair <CODE> (get a code from the console's Add device button)");
|
|
152
|
+
const normalized = code.trim().toUpperCase();
|
|
153
|
+
log(`pairing with ${url}…`);
|
|
154
|
+
|
|
155
|
+
let res;
|
|
156
|
+
try {
|
|
157
|
+
res = await fetch(`${url}/machine/pair`, {
|
|
158
|
+
method: "POST",
|
|
159
|
+
headers: { "Content-Type": "application/json" },
|
|
160
|
+
body: JSON.stringify({
|
|
161
|
+
code: normalized,
|
|
162
|
+
hostname: process.env.OMNIFORGE_LABEL || hostname(),
|
|
163
|
+
os: `${platform()} ${release()}`,
|
|
164
|
+
root: ROOT,
|
|
165
|
+
version: VERSION,
|
|
166
|
+
}),
|
|
167
|
+
});
|
|
168
|
+
} catch (e) {
|
|
169
|
+
die(`could not reach ${url}: ${e.message}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const body = await res.json().catch(() => ({}));
|
|
173
|
+
if (!res.ok) die(body.error || `pairing failed (HTTP ${res.status})`);
|
|
174
|
+
|
|
175
|
+
await writeCredentials({ url, deviceId: body.deviceId, deviceToken: body.deviceToken, label: body.label });
|
|
176
|
+
log(`paired as "${body.label}". Credentials saved to ${CREDENTIALS} (0600).`);
|
|
177
|
+
await cmdRun();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function cmdStatus() {
|
|
181
|
+
const creds = await readCredentials();
|
|
182
|
+
console.log(`omniforge-daemon ${VERSION}`);
|
|
183
|
+
console.log(` workspace: ${ROOT}${UNSAFE ? " (UNSAFE: confinement off)" : ""}`);
|
|
184
|
+
console.log(` credentials: ${creds ? CREDENTIALS : "none — run: omniforge pair <CODE>"}`);
|
|
185
|
+
if (creds) {
|
|
186
|
+
console.log(` deployment: ${creds.url}`);
|
|
187
|
+
console.log(` label: ${creds.label}`);
|
|
188
|
+
console.log(` device id: ${creds.deviceId}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function cmdLogout() {
|
|
193
|
+
await clearCredentials();
|
|
194
|
+
log("credentials removed. This machine can no longer connect until you pair it again.");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Trade the stored device token for a 60-second ticket. A 401 here is terminal:
|
|
199
|
+
* it means the device was revoked from the console, so we stop instead of
|
|
200
|
+
* hammering the endpoint forever.
|
|
201
|
+
*/
|
|
202
|
+
async function fetchTicket(creds) {
|
|
203
|
+
const res = await fetch(`${creds.url}/machine/ticket`, {
|
|
204
|
+
method: "POST",
|
|
205
|
+
headers: { Authorization: `Bearer ${creds.deviceToken}` },
|
|
206
|
+
});
|
|
207
|
+
if (res.status === 401) {
|
|
208
|
+
await clearCredentials();
|
|
209
|
+
die("this device has been revoked from the console. Pair again with: omniforge pair <CODE>");
|
|
210
|
+
}
|
|
211
|
+
if (!res.ok) throw new Error(`ticket request failed (HTTP ${res.status})`);
|
|
212
|
+
return res.json();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function cmdRun() {
|
|
216
|
+
const creds = await readCredentials();
|
|
217
|
+
if (!creds) die("no credentials. Get a code from the console and run: omniforge pair <CODE>");
|
|
218
|
+
|
|
219
|
+
await mkdir(ROOT, { recursive: true });
|
|
220
|
+
let backoff = 1000;
|
|
221
|
+
let stopped = false;
|
|
222
|
+
|
|
223
|
+
const connect = async () => {
|
|
224
|
+
if (stopped) return;
|
|
225
|
+
let ticket;
|
|
226
|
+
try {
|
|
227
|
+
ticket = await fetchTicket(creds);
|
|
228
|
+
} catch (e) {
|
|
229
|
+
log(`${e.message}; retrying in ${Math.round(backoff / 1000)}s`);
|
|
230
|
+
setTimeout(connect, backoff);
|
|
231
|
+
backoff = Math.min(backoff * 2, 30_000);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const wsUrl =
|
|
236
|
+
creds.url.replace(/^http/, "ws").replace(/\/$/, "") +
|
|
237
|
+
`/machine/ws?ticket=${encodeURIComponent(ticket.ticket)}`;
|
|
238
|
+
|
|
239
|
+
log(
|
|
240
|
+
`connecting as "${ticket.label}" (workspace ${ROOT}${UNSAFE ? ", UNSAFE mode" : ""}` +
|
|
241
|
+
`${ticket.approvalMode ? ", approval required" : ""})`,
|
|
242
|
+
);
|
|
243
|
+
const ws = new WebSocket(wsUrl);
|
|
244
|
+
let pingTimer;
|
|
245
|
+
|
|
246
|
+
ws.onopen = () => {
|
|
247
|
+
backoff = 1000;
|
|
248
|
+
log("connected");
|
|
249
|
+
ws.send(
|
|
250
|
+
JSON.stringify({
|
|
251
|
+
type: "hello",
|
|
252
|
+
host: hostname(),
|
|
253
|
+
user: userInfo().username,
|
|
254
|
+
root: ROOT,
|
|
255
|
+
version: VERSION,
|
|
256
|
+
}),
|
|
257
|
+
);
|
|
258
|
+
pingTimer = setInterval(() => {
|
|
259
|
+
if (ws.readyState === WebSocket.OPEN) ws.send('{"type":"ping"}');
|
|
260
|
+
}, 25_000);
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
ws.onmessage = async (event) => {
|
|
264
|
+
let msg;
|
|
265
|
+
try {
|
|
266
|
+
msg = JSON.parse(String(event.data));
|
|
267
|
+
} catch {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (msg.type === "pong" || !msg.id) return;
|
|
271
|
+
log(`${msg.type}${msg.cmd ? ` $ ${msg.cmd}` : msg.path ? ` ${msg.path}` : ""}`);
|
|
272
|
+
let result;
|
|
273
|
+
try {
|
|
274
|
+
result = await handle(msg);
|
|
275
|
+
} catch (e) {
|
|
276
|
+
result = { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
277
|
+
}
|
|
278
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
279
|
+
ws.send(JSON.stringify({ type: "result", id: msg.id, ...result }));
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
ws.onclose = async (event) => {
|
|
284
|
+
clearInterval(pingTimer);
|
|
285
|
+
if (event.code === CLOSE_REVOKED) {
|
|
286
|
+
stopped = true;
|
|
287
|
+
await clearCredentials();
|
|
288
|
+
die("this device was revoked from the console. Pair again with: omniforge pair <CODE>");
|
|
289
|
+
}
|
|
290
|
+
log(`disconnected, retrying in ${Math.round(backoff / 1000)}s`);
|
|
291
|
+
setTimeout(connect, backoff);
|
|
292
|
+
backoff = Math.min(backoff * 2, 30_000);
|
|
293
|
+
};
|
|
294
|
+
ws.onerror = () => {};
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
connect();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ---------------------------------------------------------------- entrypoint
|
|
301
|
+
|
|
302
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
303
|
+
const urlFlagIndex = rest.indexOf("--url");
|
|
304
|
+
const urlFromFlag = urlFlagIndex >= 0 ? rest[urlFlagIndex + 1] : null;
|
|
305
|
+
const BASE = (urlFromFlag || process.env.OMNIFORGE_URL || DEFAULT_URL).replace(/\/$/, "");
|
|
306
|
+
|
|
307
|
+
process.on("SIGINT", () => {
|
|
308
|
+
console.log("\nomniforge: bye");
|
|
309
|
+
process.exit(0);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
switch (command) {
|
|
313
|
+
case "pair":
|
|
314
|
+
await cmdPair(rest[0], BASE);
|
|
315
|
+
break;
|
|
316
|
+
case "status":
|
|
317
|
+
await cmdStatus();
|
|
318
|
+
break;
|
|
319
|
+
case "logout":
|
|
320
|
+
await cmdLogout();
|
|
321
|
+
break;
|
|
322
|
+
case undefined:
|
|
323
|
+
case "run":
|
|
324
|
+
await cmdRun();
|
|
325
|
+
break;
|
|
326
|
+
default:
|
|
327
|
+
die(`unknown command "${command}". Try: pair <CODE> | run | status | logout`);
|
|
328
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "omniforge-daemon",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Connect a computer to your Omniforge agent. Opens an outbound WebSocket — no ports, no inbound access.",
|
|
5
|
+
"keywords": ["omniforge", "agent", "cloudflare", "daemon"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"bin": { "omniforge": "./omniforge.mjs" },
|
|
9
|
+
"files": ["omniforge.mjs", "README.md"],
|
|
10
|
+
"engines": { "node": ">=21" },
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/iujab/omniforge.git",
|
|
14
|
+
"directory": "packages/daemon"
|
|
15
|
+
},
|
|
16
|
+
"homepage": "https://omniforge.noahzhang.dev"
|
|
17
|
+
}
|