@claw-link/gateway-host 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +88 -0
- package/SECURITY.md +50 -0
- package/bin/cli.js +62 -0
- package/package.json +33 -0
- package/scripts/install.js +191 -0
- package/scripts/uninstall.js +30 -0
- package/src/adapters/base.js +115 -0
- package/src/adapters/claude.js +19 -0
- package/src/adapters/cli.js +110 -0
- package/src/adapters/codex.js +13 -0
- package/src/adapters/cursor.js +11 -0
- package/src/adapters/hermes.js +14 -0
- package/src/adapters/index.js +19 -0
- package/src/adapters/openclaw.js +59 -0
- package/src/bridge.js +61 -0
- package/src/config.js +84 -0
- package/src/detect.js +35 -0
- package/src/logger.js +28 -0
- package/src/machine.js +30 -0
- package/src/session-store.js +35 -0
- package/src/worker.js +116 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 ClawLink
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# @claw-link/gateway-host — ClawLink Host Gateway
|
|
2
|
+
|
|
3
|
+
Run **any local agent CLI** — OpenClaw, [Hermes](https://hermes-agent.nousresearch.com),
|
|
4
|
+
Claude, Codex, or Cursor — as a ClawLink agent. The host worker is a small,
|
|
5
|
+
**outbound-only** process: it dials out to ClawLink, claims customer/admin messages
|
|
6
|
+
for your agent, runs the chosen runtime locally, and streams the reply back. No
|
|
7
|
+
inbound ports, no Tailscale Funnel — just a per-agent **Host Token**.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
ClawLink (cloud) ──enqueues message──▶ host-bridge
|
|
11
|
+
▲ │ (your machine dials OUT)
|
|
12
|
+
└──── streams reply back ──── @claw-link/gateway-host ──▶ hermes | claude | codex | cursor | local OpenClaw
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npx @claw-link/gateway-host install # one-time: install the background service
|
|
19
|
+
npx @claw-link/gateway-host add-agent # paste an agent's Host Token — that's it
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
(`setup` does both in one flow.) **You only paste the Host Token.** The host verifies
|
|
23
|
+
it, **pulls the agent's runtime from ClawLink** (the dashboard is the source of truth),
|
|
24
|
+
auto-detects the runtime binary, and **binds this machine** so the token can't be used
|
|
25
|
+
from anywhere else. For coding runtimes (claude/codex/cursor) it also asks for the
|
|
26
|
+
project **workspace directory** (that part is host-specific). Config lands in
|
|
27
|
+
`~/.clawlink-host/config.json` (chmod 600).
|
|
28
|
+
|
|
29
|
+
Get a token from **ClawLink → Agents → your agent → Host Gateway → Generate token**, and
|
|
30
|
+
verify the live connection badge there.
|
|
31
|
+
|
|
32
|
+
## Runtimes
|
|
33
|
+
|
|
34
|
+
| Runtime | How it runs | Install |
|
|
35
|
+
|----------|----------------------------------------------|---------|
|
|
36
|
+
| OpenClaw | POSTs to your **local** OpenClaw gateway | (already running) |
|
|
37
|
+
| Hermes | `hermes chat -q "<prompt>" --resume <id>` | `curl -fsSL https://hermes-agent.nousresearch.com/install.sh \| bash` |
|
|
38
|
+
| Claude | `claude -p --output-format stream-json` | Claude Code CLI |
|
|
39
|
+
| Codex | `codex exec "<prompt>"` | Codex CLI |
|
|
40
|
+
| Cursor | `cursor-agent -p "<prompt>"` | Cursor CLI |
|
|
41
|
+
|
|
42
|
+
Each runtime's flags can be overridden per-agent via `args_template` in the config
|
|
43
|
+
(placeholders `{prompt}`, `{sessionId}`, `{systemPrompt}` are substituted **per argv
|
|
44
|
+
element** — never through a shell).
|
|
45
|
+
|
|
46
|
+
**Workspace directory** — Claude, Codex, and Cursor are coding agents that run inside
|
|
47
|
+
a project directory. The setup wizard asks for that **workspace directory** per agent
|
|
48
|
+
and the CLI is run there (`cwd`). OpenClaw (HTTP) and Hermes (its own `~/.hermes`
|
|
49
|
+
context) don't use one.
|
|
50
|
+
|
|
51
|
+
## Commands
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
npx @claw-link/gateway-host install # install the background service (one-time)
|
|
55
|
+
npx @claw-link/gateway-host add-agent # add an agent by pasting its Host Token
|
|
56
|
+
npx @claw-link/gateway-host setup # install + add-agent in one flow
|
|
57
|
+
npx @claw-link/gateway-host start # run in the foreground
|
|
58
|
+
npx @claw-link/gateway-host status # show configured agents (secrets redacted)
|
|
59
|
+
npx @claw-link/gateway-host rotate # rotate a Host Token / move to a new machine
|
|
60
|
+
npx @claw-link/gateway-host uninstall # stop + remove the service
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Config
|
|
64
|
+
|
|
65
|
+
`~/.clawlink-host/config.json` (chmod 600):
|
|
66
|
+
|
|
67
|
+
A minimal binding is just a token (everything else is resolved from ClawLink and
|
|
68
|
+
cached back in). `~/.clawlink-host/config.json` (chmod 600):
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"bridge_url": "https://<project-ref>.supabase.co/functions/v1/host-bridge",
|
|
73
|
+
"instance_id": "…",
|
|
74
|
+
"poll_interval_ms": 1500,
|
|
75
|
+
"agents": [
|
|
76
|
+
{ "host_token": "clk_host_…" },
|
|
77
|
+
{ "host_token": "clk_host_…", "runtime": "claude", "binary": "/usr/local/bin/claude", "work_dir": "/Users/me/projects/acme" }
|
|
78
|
+
]
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Security
|
|
83
|
+
|
|
84
|
+
Outbound-only, per-agent token (stored server-side as a hash only), **machine-bound**
|
|
85
|
+
on first connect (a stolen token fails from any other host), no shell injection, runs
|
|
86
|
+
only the configured binary. See [SECURITY.md](./SECURITY.md).
|
|
87
|
+
|
|
88
|
+
MIT
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# ClawLink Host Gateway — Security Model
|
|
2
|
+
|
|
3
|
+
The host worker is designed to expose **zero inbound attack surface** and to run
|
|
4
|
+
**only** the agent runtime you configure. The design goals, in order: never leak
|
|
5
|
+
a secret, never run an attacker-controlled command, never open a port.
|
|
6
|
+
|
|
7
|
+
## Guarantees
|
|
8
|
+
|
|
9
|
+
1. **Per-agent Host Token** — a ≥256-bit random secret generated in ClawLink. The
|
|
10
|
+
ClawLink server stores **only its SHA-256 hash**; the raw token is shown once.
|
|
11
|
+
The worker presents it on every `host-bridge` call. A token scopes the worker to
|
|
12
|
+
exactly one `agent_id`. Rotate any time from the dashboard (the old token dies
|
|
13
|
+
immediately).
|
|
14
|
+
2. **Machine binding** — on first connect the worker sends a **hash** of the host's
|
|
15
|
+
machine fingerprint (MAC address(es) + hostname); ClawLink binds the agent to that
|
|
16
|
+
machine. Every later call must present the same fingerprint, so a stolen token is
|
|
17
|
+
rejected from any other host. Only the hash is sent — raw MACs never leave the
|
|
18
|
+
machine. Rotating the token clears the binding (also how you move to a new host).
|
|
19
|
+
(A MAC can be spoofed by an attacker who *also* learns it, so treat this as a strong
|
|
20
|
+
second factor on top of the token, not a replacement.)
|
|
21
|
+
3. **Outbound-only** — the worker **dials out** to the `host-bridge` HTTPS endpoint
|
|
22
|
+
and short-polls for work. It opens **no inbound port**, needs **no Tailscale
|
|
23
|
+
Funnel**, and works behind NAT. There is nothing to attack from the network.
|
|
24
|
+
4. **No shell injection** — every CLI is launched with `child_process.spawn(binary,
|
|
25
|
+
argvArray, { shell: false })`. Untrusted prompt text is passed as a single argv
|
|
26
|
+
token or via stdin — it is **never** concatenated into a command string.
|
|
27
|
+
5. **No arbitrary command execution** — the worker runs **only** the runtime binary
|
|
28
|
+
you configured. The job payload it receives carries `{ content, system_prompt,
|
|
29
|
+
attachments, session }` — it is structurally incapable of carrying a command, and
|
|
30
|
+
the worker never `eval`s or spawns anything else. Known "auto-approve / no-safety"
|
|
31
|
+
flags (`--yolo`, `--dangerously-skip-permissions`, …) are stripped even if present
|
|
32
|
+
in a custom `args_template`.
|
|
33
|
+
5. **Secrets never logged** — a redaction logger replaces every configured token/key
|
|
34
|
+
with `***` in all output, including the runtime's stderr.
|
|
35
|
+
6. **File permissions** — `~/.clawlink-host/` is `0700`; `config.json` and
|
|
36
|
+
`sessions.json` are `0600`.
|
|
37
|
+
7. **Concurrency + rate limits** — per-agent caps bound how many jobs run at once and
|
|
38
|
+
per minute, so a flood can't exhaust the host.
|
|
39
|
+
8. **Heartbeat** — the worker reports liveness; the dashboard shows a live connection
|
|
40
|
+
badge and a stale host appears clearly offline.
|
|
41
|
+
|
|
42
|
+
## Your responsibilities
|
|
43
|
+
|
|
44
|
+
- Keep `~/.clawlink-host/config.json` private (it holds your Host Tokens).
|
|
45
|
+
- Run the worker as a **low-privilege user**, ideally one dedicated to it.
|
|
46
|
+
- Prefer a restricted `work_dir` (or a container) per agent.
|
|
47
|
+
- Apply the ClawLink **Security skills** to any public-facing agent so the runtime
|
|
48
|
+
itself refuses to leak secrets, read config files, or run out-of-scope commands.
|
|
49
|
+
|
|
50
|
+
Report vulnerabilities to the ClawLink maintainers privately.
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// ClawLink Host Gateway CLI. Commands: setup | start | status | rotate | uninstall
|
|
4
|
+
|
|
5
|
+
const cmd = (process.argv[2] || '').toLowerCase();
|
|
6
|
+
|
|
7
|
+
async function main() {
|
|
8
|
+
switch (cmd) {
|
|
9
|
+
case 'start':
|
|
10
|
+
return require('../src/worker').startWorker();
|
|
11
|
+
|
|
12
|
+
case 'setup':
|
|
13
|
+
case '':
|
|
14
|
+
return require('../scripts/install').run();
|
|
15
|
+
|
|
16
|
+
case 'install':
|
|
17
|
+
return require('../scripts/install').installOnly();
|
|
18
|
+
|
|
19
|
+
case 'add-agent':
|
|
20
|
+
case 'add':
|
|
21
|
+
return require('../scripts/install').addAgentCmd();
|
|
22
|
+
|
|
23
|
+
case 'uninstall':
|
|
24
|
+
return require('../scripts/uninstall').run();
|
|
25
|
+
|
|
26
|
+
case 'status':
|
|
27
|
+
return require('../scripts/install').status();
|
|
28
|
+
|
|
29
|
+
case 'rotate':
|
|
30
|
+
console.log([
|
|
31
|
+
'To rotate a Host Token (also how you move an agent to a new machine):',
|
|
32
|
+
' 1. Open ClawLink → Agents → your agent → Host Gateway → "Rotate token".',
|
|
33
|
+
' This also clears the machine binding, so a new host can claim it.',
|
|
34
|
+
' 2. Copy the new token, then run: npx @claw-link/gateway-host add-agent',
|
|
35
|
+
' (paste the new token). The old token + old machine stop working immediately.',
|
|
36
|
+
].join('\n'));
|
|
37
|
+
return;
|
|
38
|
+
|
|
39
|
+
case '--help':
|
|
40
|
+
case '-h':
|
|
41
|
+
case 'help':
|
|
42
|
+
console.log([
|
|
43
|
+
'ClawLink Host Gateway',
|
|
44
|
+
'',
|
|
45
|
+
'Usage: claw-host <command>',
|
|
46
|
+
' install Install the background service (one-time)',
|
|
47
|
+
' add-agent Add an agent by pasting its Host Token (pulls runtime from ClawLink)',
|
|
48
|
+
' setup install + add-agent in one flow (default)',
|
|
49
|
+
' start Run the worker in the foreground (used by the service)',
|
|
50
|
+
' status Show configured agents + last connection',
|
|
51
|
+
' rotate How to rotate a Host Token / move to a new machine',
|
|
52
|
+
' uninstall Stop + remove the background service',
|
|
53
|
+
].join('\n'));
|
|
54
|
+
return;
|
|
55
|
+
|
|
56
|
+
default:
|
|
57
|
+
console.error(`Unknown command '${cmd}'. Try: claw-host help`);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
main().catch((e) => { console.error(e && e.message ? e.message : e); process.exit(1); });
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@claw-link/gateway-host",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "ClawLink Host Gateway — a secure, outbound-only worker that bridges a local agent CLI (OpenClaw, Hermes, Claude, Codex, Cursor) to your ClawLink agents. No inbound ports; authenticated per-agent by a Host Token.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"claw-host": "bin/cli.js"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"start": "node bin/cli.js start"
|
|
10
|
+
},
|
|
11
|
+
"engines": {
|
|
12
|
+
"node": ">=18"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"bin/",
|
|
16
|
+
"scripts/",
|
|
17
|
+
"src/",
|
|
18
|
+
"README.md",
|
|
19
|
+
"SECURITY.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"keywords": [
|
|
23
|
+
"clawlink",
|
|
24
|
+
"openclaw",
|
|
25
|
+
"hermes",
|
|
26
|
+
"claude",
|
|
27
|
+
"codex",
|
|
28
|
+
"cursor",
|
|
29
|
+
"ai-agent",
|
|
30
|
+
"gateway"
|
|
31
|
+
],
|
|
32
|
+
"license": "MIT"
|
|
33
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Setup wizard + background-service installer for the ClawLink Host Gateway.
|
|
3
|
+
// Mirrors the launchd/systemd/sc service pattern used by @claw-link/clom.
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const readline = require('readline');
|
|
9
|
+
const { execSync } = require('child_process');
|
|
10
|
+
|
|
11
|
+
const config = require('../src/config');
|
|
12
|
+
const { detectRuntimes } = require('../src/detect');
|
|
13
|
+
const { Bridge } = require('../src/bridge');
|
|
14
|
+
|
|
15
|
+
const VERSION = (() => { try { return require('../package.json').version; } catch { return '0.0.0'; } })();
|
|
16
|
+
const PROJECT_REF_DEFAULT = 'rgzinqbdnesinmbshgtc';
|
|
17
|
+
const RUNTIMES = ['openclaw', 'hermes', 'claude', 'codex', 'cursor'];
|
|
18
|
+
const SERVICE_LABEL = 'co.clawlink.host';
|
|
19
|
+
|
|
20
|
+
function ask(rl, q, def) {
|
|
21
|
+
return new Promise((res) => rl.question(def ? `${q} [${def}]: ` : `${q}: `, (a) => res((a || '').trim() || def || '')));
|
|
22
|
+
}
|
|
23
|
+
function execSafe(cmd) { try { return execSync(cmd, { stdio: ['ignore', 'pipe', 'ignore'] }).toString(); } catch { return null; } }
|
|
24
|
+
|
|
25
|
+
async function ensureBridgeUrl(cfg, rl) {
|
|
26
|
+
if (cfg.bridge_url) return;
|
|
27
|
+
const ref = await ask(rl, ' Supabase project ref', PROJECT_REF_DEFAULT);
|
|
28
|
+
cfg.bridge_url = `https://${ref}.supabase.co/functions/v1/host-bridge`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Add one agent with ONLY its Host Token. The token resolves the agent + runtime
|
|
32
|
+
// from Supabase (the dashboard is the source of truth); the host fills in the rest.
|
|
33
|
+
async function addAgentInteractive(cfg, rl) {
|
|
34
|
+
const token = await ask(rl, ' Host Token (clk_host_… — from ClawLink → Agents → Host Gateway)');
|
|
35
|
+
if (!token) { console.log(' (skipped)'); return null; }
|
|
36
|
+
|
|
37
|
+
console.log(' Verifying token + binding this machine…');
|
|
38
|
+
let resp;
|
|
39
|
+
try {
|
|
40
|
+
resp = await new Bridge(cfg.bridge_url, { host_token: token }, cfg.instance_id).register(null, VERSION);
|
|
41
|
+
} catch (e) {
|
|
42
|
+
console.log(` ✗ Could not verify: ${e.message}`);
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const runtime = resp.runtime;
|
|
46
|
+
const agent = { agent_id: resp.agent_id, host_token: token, runtime };
|
|
47
|
+
console.log(` ✓ Agent ${resp.agent_id} — runtime: ${runtime}`);
|
|
48
|
+
|
|
49
|
+
const detected = detectRuntimes();
|
|
50
|
+
if (runtime === 'openclaw') {
|
|
51
|
+
agent.local_gateway_url = await ask(rl, ' Local OpenClaw gateway URL', 'http://localhost:3000');
|
|
52
|
+
agent.local_gateway_token = await ask(rl, ' Local OpenClaw gateway token (optional)', '');
|
|
53
|
+
} else if (!detected[runtime]) {
|
|
54
|
+
agent.binary = await ask(rl, ` '${runtime}' not found on PATH — full path to the binary`, runtime);
|
|
55
|
+
} else {
|
|
56
|
+
console.log(` using ${runtime}: ${detected[runtime]}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Coding runtimes run inside a project workspace (host-specific, can't be pulled).
|
|
60
|
+
if (config.WORKSPACE_RUNTIMES.includes(runtime)) {
|
|
61
|
+
let ws = '';
|
|
62
|
+
while (!ws) {
|
|
63
|
+
ws = await ask(rl, ` Workspace directory for ${runtime} (the project it runs in)`, process.cwd());
|
|
64
|
+
if (ws && !fs.existsSync(config.expandHome(ws))) { console.log(` ⚠ ${ws} does not exist.`); ws = ''; }
|
|
65
|
+
}
|
|
66
|
+
agent.work_dir = ws;
|
|
67
|
+
}
|
|
68
|
+
return agent;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function saveAgent(cfg, agent) {
|
|
72
|
+
const byId = new Map((cfg.agents || []).map((a) => [a.agent_id || a.host_token, a]));
|
|
73
|
+
byId.set(agent.agent_id || agent.host_token, agent);
|
|
74
|
+
cfg.agents = [...byId.values()];
|
|
75
|
+
config.save(cfg);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// `setup` — install the service (if needed) + add one or more agents.
|
|
79
|
+
async function run() {
|
|
80
|
+
console.log('\n ClawLink Host Gateway — setup\n ─────────────────────────────\n');
|
|
81
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
82
|
+
const cfg = config.load() || config.defaultConfig();
|
|
83
|
+
await ensureBridgeUrl(cfg, rl);
|
|
84
|
+
config.save(cfg);
|
|
85
|
+
|
|
86
|
+
let added = 0, more = true;
|
|
87
|
+
while (more) {
|
|
88
|
+
console.log('\n Add an agent:');
|
|
89
|
+
const agent = await addAgentInteractive(cfg, rl);
|
|
90
|
+
if (agent) { saveAgent(cfg, agent); added++; }
|
|
91
|
+
more = (await ask(rl, '\n Add another agent? (y/N)', 'N')).toLowerCase().startsWith('y');
|
|
92
|
+
}
|
|
93
|
+
rl.close();
|
|
94
|
+
|
|
95
|
+
if (added === 0) { console.log('\n No agents added.\n'); return; }
|
|
96
|
+
console.log(`\n ✓ Saved config (${config.CONFIG_PATH}, chmod 600)`);
|
|
97
|
+
|
|
98
|
+
const installed = installService();
|
|
99
|
+
console.log(installed
|
|
100
|
+
? ' ✓ Installed + started the background service.'
|
|
101
|
+
: ' ⚠ Could not install a background service automatically.\n Run it yourself with: npx @claw-link/gateway-host start');
|
|
102
|
+
console.log('\n Check status in ClawLink → Agents → Host Gateway (connection badge).\n');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// `install` — install the service only (configure agents later with `add-agent`).
|
|
106
|
+
async function installOnly() {
|
|
107
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
108
|
+
const cfg = config.load() || config.defaultConfig();
|
|
109
|
+
await ensureBridgeUrl(cfg, rl);
|
|
110
|
+
config.save(cfg);
|
|
111
|
+
rl.close();
|
|
112
|
+
const installed = installService();
|
|
113
|
+
console.log(installed ? ' ✓ Service installed.' : ' ⚠ Could not install the service automatically.');
|
|
114
|
+
console.log(' Next: npx @claw-link/gateway-host add-agent\n');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// `add-agent` — add one agent by token (no service reinstall).
|
|
118
|
+
async function addAgentCmd() {
|
|
119
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
120
|
+
const cfg = config.load() || config.defaultConfig();
|
|
121
|
+
await ensureBridgeUrl(cfg, rl);
|
|
122
|
+
const agent = await addAgentInteractive(cfg, rl);
|
|
123
|
+
rl.close();
|
|
124
|
+
if (agent) { saveAgent(cfg, agent); console.log(`\n ✓ Added agent ${agent.agent_id}. Restart the service or it will pick it up shortly.\n`); }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function nodeBin() { return process.execPath; }
|
|
128
|
+
function cliEntry() { return path.join(__dirname, '..', 'bin', 'cli.js'); }
|
|
129
|
+
|
|
130
|
+
function installService() {
|
|
131
|
+
const platform = process.platform;
|
|
132
|
+
try {
|
|
133
|
+
if (platform === 'darwin') {
|
|
134
|
+
const plistDir = path.join(os.homedir(), 'Library', 'LaunchAgents');
|
|
135
|
+
fs.mkdirSync(plistDir, { recursive: true });
|
|
136
|
+
const plistPath = path.join(plistDir, `${SERVICE_LABEL}.plist`);
|
|
137
|
+
const logPath = path.join(config.HOME_DIR, 'host.log');
|
|
138
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
139
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
140
|
+
<plist version="1.0"><dict>
|
|
141
|
+
<key>Label</key><string>${SERVICE_LABEL}</string>
|
|
142
|
+
<key>ProgramArguments</key>
|
|
143
|
+
<array><string>${nodeBin()}</string><string>${cliEntry()}</string><string>start</string></array>
|
|
144
|
+
<key>RunAtLoad</key><true/>
|
|
145
|
+
<key>KeepAlive</key><true/>
|
|
146
|
+
<key>StandardOutPath</key><string>${logPath}</string>
|
|
147
|
+
<key>StandardErrorPath</key><string>${logPath}</string>
|
|
148
|
+
</dict></plist>`;
|
|
149
|
+
fs.writeFileSync(plistPath, plist, { mode: 0o644 });
|
|
150
|
+
const uid = (execSafe('id -u') || '').trim();
|
|
151
|
+
execSafe(`launchctl bootout gui/${uid}/${SERVICE_LABEL}`);
|
|
152
|
+
if (execSafe(`launchctl bootstrap gui/${uid} "${plistPath}"`) === null) execSafe(`launchctl load "${plistPath}"`);
|
|
153
|
+
execSafe(`launchctl enable gui/${uid}/${SERVICE_LABEL}`);
|
|
154
|
+
execSafe(`launchctl kickstart -k "gui/${uid}/${SERVICE_LABEL}"`);
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
if (platform === 'linux') {
|
|
158
|
+
const unitDir = path.join(os.homedir(), '.config', 'systemd', 'user');
|
|
159
|
+
fs.mkdirSync(unitDir, { recursive: true });
|
|
160
|
+
const unit = `[Unit]
|
|
161
|
+
Description=ClawLink Host Gateway
|
|
162
|
+
After=network-online.target
|
|
163
|
+
|
|
164
|
+
[Service]
|
|
165
|
+
ExecStart=${nodeBin()} ${cliEntry()} start
|
|
166
|
+
Restart=always
|
|
167
|
+
RestartSec=5
|
|
168
|
+
|
|
169
|
+
[Install]
|
|
170
|
+
WantedBy=default.target
|
|
171
|
+
`;
|
|
172
|
+
fs.writeFileSync(path.join(unitDir, 'clawlink-host.service'), unit, { mode: 0o644 });
|
|
173
|
+
execSafe('systemctl --user daemon-reload');
|
|
174
|
+
return execSafe('systemctl --user enable --now clawlink-host') !== null;
|
|
175
|
+
}
|
|
176
|
+
if (platform === 'win32') {
|
|
177
|
+
// Best effort: a scheduled task that runs at logon.
|
|
178
|
+
const taskCmd = `"${nodeBin()}" "${cliEntry()}" start`;
|
|
179
|
+
return execSafe(`schtasks /Create /F /SC ONLOGON /TN ClawLinkHost /TR ${JSON.stringify(taskCmd)}`) !== null;
|
|
180
|
+
}
|
|
181
|
+
} catch { /* fall through */ }
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function status() {
|
|
186
|
+
const cfg = config.load();
|
|
187
|
+
if (!cfg) { console.log('Not configured. Run: npx @claw-link/gateway-host setup'); return; }
|
|
188
|
+
console.log(JSON.stringify(config.redacted(cfg), null, 2));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
module.exports = { run, installOnly, addAgentCmd, status, installService, SERVICE_LABEL };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Stop + remove the ClawLink Host Gateway background service.
|
|
3
|
+
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const { execSync } = require('child_process');
|
|
8
|
+
const { SERVICE_LABEL } = require('./install');
|
|
9
|
+
|
|
10
|
+
function execSafe(cmd) { try { return execSync(cmd, { stdio: ['ignore', 'pipe', 'ignore'] }).toString(); } catch { return null; } }
|
|
11
|
+
|
|
12
|
+
function run() {
|
|
13
|
+
const platform = process.platform;
|
|
14
|
+
if (platform === 'darwin') {
|
|
15
|
+
const uid = (execSafe('id -u') || '').trim();
|
|
16
|
+
execSafe(`launchctl bootout gui/${uid}/${SERVICE_LABEL}`);
|
|
17
|
+
const plist = path.join(os.homedir(), 'Library', 'LaunchAgents', `${SERVICE_LABEL}.plist`);
|
|
18
|
+
try { fs.unlinkSync(plist); } catch { /* ignore */ }
|
|
19
|
+
} else if (platform === 'linux') {
|
|
20
|
+
execSafe('systemctl --user disable --now clawlink-host');
|
|
21
|
+
try { fs.unlinkSync(path.join(os.homedir(), '.config', 'systemd', 'user', 'clawlink-host.service')); } catch { /* ignore */ }
|
|
22
|
+
execSafe('systemctl --user daemon-reload');
|
|
23
|
+
} else if (platform === 'win32') {
|
|
24
|
+
execSafe('schtasks /Delete /F /TN ClawLinkHost');
|
|
25
|
+
}
|
|
26
|
+
console.log('ClawLink Host Gateway service removed. Your config (~/.clawlink-host) was left intact —');
|
|
27
|
+
console.log('delete it manually to remove stored Host Tokens.');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = { run };
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Shared spawn helper for CLI-wrapping adapters.
|
|
3
|
+
//
|
|
4
|
+
// SECURITY: commands are ALWAYS invoked with an argv ARRAY and shell:false, so
|
|
5
|
+
// untrusted prompt text can never be interpreted by a shell. Placeholders in the
|
|
6
|
+
// configured args_template ({prompt}, {sessionId}, {systemPrompt}) are substituted
|
|
7
|
+
// PER ARRAY ELEMENT — each becomes exactly one argv token, never concatenated into
|
|
8
|
+
// a command string. The host only ever runs the configured runtime binary.
|
|
9
|
+
|
|
10
|
+
const { spawn } = require('child_process');
|
|
11
|
+
const logger = require('../logger');
|
|
12
|
+
|
|
13
|
+
// A small set of flags we refuse to pass through (auto-approve / no-safety modes).
|
|
14
|
+
const FORBIDDEN_FLAGS = [
|
|
15
|
+
'--yolo', '--dangerously-skip-permissions', '--no-sandbox', '--allow-all',
|
|
16
|
+
'--auto-approve', '--dangerously-allow', '--full-auto', '--yes-to-all',
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
function buildArgv(template, vars) {
|
|
20
|
+
const out = [];
|
|
21
|
+
for (const part of template) {
|
|
22
|
+
let v = String(part);
|
|
23
|
+
for (const [k, val] of Object.entries(vars)) {
|
|
24
|
+
v = v.split(`{${k}}`).join(val == null ? '' : String(val));
|
|
25
|
+
}
|
|
26
|
+
out.push(v);
|
|
27
|
+
}
|
|
28
|
+
// Defense in depth: strip any forbidden auto-approve flags an operator may have
|
|
29
|
+
// pasted into args_template.
|
|
30
|
+
return out.filter((a) => !FORBIDDEN_FLAGS.includes(a.toLowerCase()));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Run `binary` with an argv array; stream stdout lines as they arrive.
|
|
35
|
+
* Yields { type, content } events. Caller decides how to map/forward them.
|
|
36
|
+
* onStdoutLine(line) -> array of events (so adapters can parse runtime markers)
|
|
37
|
+
* Resolves the final accumulated answer text from the events the parser tags
|
|
38
|
+
* as { type: 'chunk' }.
|
|
39
|
+
*
|
|
40
|
+
* This returns an async iterator of events. The prompt is passed via stdin when
|
|
41
|
+
* `stdin` is true (preferred — keeps it out of argv/process listings entirely).
|
|
42
|
+
*/
|
|
43
|
+
async function* spawnStreaming({ binary, argv, cwd, env, timeoutMs = 600000, stdinInput = null, parseLine }) {
|
|
44
|
+
if (!binary) throw new Error('no runtime binary configured');
|
|
45
|
+
|
|
46
|
+
const child = spawn(binary, argv, {
|
|
47
|
+
cwd: cwd || undefined,
|
|
48
|
+
env: { ...process.env, ...(env || {}) },
|
|
49
|
+
shell: false, // CRITICAL: never use a shell
|
|
50
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
let killed = false;
|
|
54
|
+
const timer = setTimeout(() => {
|
|
55
|
+
killed = true;
|
|
56
|
+
try { child.kill('SIGKILL'); } catch { /* already gone */ }
|
|
57
|
+
}, timeoutMs);
|
|
58
|
+
|
|
59
|
+
if (stdinInput != null) {
|
|
60
|
+
try { child.stdin.write(stdinInput); } catch { /* ignore */ }
|
|
61
|
+
}
|
|
62
|
+
try { child.stdin.end(); } catch { /* ignore */ }
|
|
63
|
+
|
|
64
|
+
const queue = [];
|
|
65
|
+
let resolveNext = null;
|
|
66
|
+
let done = false;
|
|
67
|
+
let exitErr = null;
|
|
68
|
+
|
|
69
|
+
const push = (evt) => {
|
|
70
|
+
if (!evt) return;
|
|
71
|
+
queue.push(evt);
|
|
72
|
+
if (resolveNext) { resolveNext(); resolveNext = null; }
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
let stdoutBuf = '';
|
|
76
|
+
const handleChunk = (buf) => {
|
|
77
|
+
stdoutBuf += buf.toString();
|
|
78
|
+
let idx;
|
|
79
|
+
while ((idx = stdoutBuf.indexOf('\n')) >= 0) {
|
|
80
|
+
const line = stdoutBuf.slice(0, idx);
|
|
81
|
+
stdoutBuf = stdoutBuf.slice(idx + 1);
|
|
82
|
+
for (const evt of (parseLine(line) || [])) push(evt);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
child.stdout.on('data', handleChunk);
|
|
87
|
+
child.stderr.on('data', (b) => {
|
|
88
|
+
const text = logger.redact(b.toString()).trim();
|
|
89
|
+
if (text) push({ type: 'log', content: text.slice(0, 500) });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
child.on('close', (code) => {
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
if (stdoutBuf.trim()) for (const evt of (parseLine(stdoutBuf) || [])) push(evt);
|
|
95
|
+
if (killed) exitErr = new Error(`runtime timed out after ${timeoutMs}ms`);
|
|
96
|
+
else if (code !== 0) exitErr = new Error(`runtime exited with code ${code}`);
|
|
97
|
+
done = true;
|
|
98
|
+
if (resolveNext) { resolveNext(); resolveNext = null; }
|
|
99
|
+
});
|
|
100
|
+
child.on('error', (e) => {
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
exitErr = e;
|
|
103
|
+
done = true;
|
|
104
|
+
if (resolveNext) { resolveNext(); resolveNext = null; }
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
while (true) {
|
|
108
|
+
if (queue.length > 0) { yield queue.shift(); continue; }
|
|
109
|
+
if (done) break;
|
|
110
|
+
await new Promise((r) => { resolveNext = r; });
|
|
111
|
+
}
|
|
112
|
+
if (exitErr) throw exitErr;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = { spawnStreaming, buildArgv, FORBIDDEN_FLAGS };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Anthropic Claude Code CLI in print/stream mode. Emits stream-json events we map
|
|
3
|
+
// to tool/chunk; captures Claude's session_id for --resume on the next turn.
|
|
4
|
+
// VERIFY exact flags against the installed CLI version.
|
|
5
|
+
const { makeCliAdapter } = require('./cli');
|
|
6
|
+
|
|
7
|
+
module.exports = makeCliAdapter({
|
|
8
|
+
name: 'claude',
|
|
9
|
+
binaries: ['claude'],
|
|
10
|
+
streamJson: true,
|
|
11
|
+
// Prompt via stdin keeps it out of argv/process listings.
|
|
12
|
+
promptViaStdin: true,
|
|
13
|
+
defaultArgs: (_fullPrompt, resumeId) => [
|
|
14
|
+
'-p',
|
|
15
|
+
'--output-format', 'stream-json',
|
|
16
|
+
'--verbose',
|
|
17
|
+
...(resumeId ? ['--resume', resumeId] : []),
|
|
18
|
+
],
|
|
19
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Factory for CLI-wrapping adapters (hermes / claude / codex / cursor). Each
|
|
3
|
+
// runtime supplies a default binary, a default argv template, and a line parser.
|
|
4
|
+
// The operator can override binary + args_template in config.
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const { spawnStreaming, buildArgv } = require('./base');
|
|
8
|
+
const sessionStore = require('../session-store');
|
|
9
|
+
|
|
10
|
+
// Generic parser: each non-empty stdout line is answer text.
|
|
11
|
+
function genericLineParser(line) {
|
|
12
|
+
const t = line.replace(/\r$/, '');
|
|
13
|
+
if (t.trim() === '') return [];
|
|
14
|
+
return [{ type: 'chunk', content: t + '\n' }];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Claude Code `--output-format stream-json`: one JSON object per line.
|
|
18
|
+
// Maps tool_use → tool events, assistant text → chunks, captures session_id.
|
|
19
|
+
function makeStreamJsonParser(capture) {
|
|
20
|
+
return function parse(line) {
|
|
21
|
+
const s = line.trim();
|
|
22
|
+
if (!s || s[0] !== '{') return [];
|
|
23
|
+
let ev;
|
|
24
|
+
try { ev = JSON.parse(s); } catch { return [{ type: 'chunk', content: line + '\n' }]; }
|
|
25
|
+
const out = [];
|
|
26
|
+
if (ev.session_id && capture) capture.sessionId = ev.session_id;
|
|
27
|
+
const type = ev.type || ev.event;
|
|
28
|
+
if (type === 'assistant' || type === 'message') {
|
|
29
|
+
const content = ev.message?.content ?? ev.content;
|
|
30
|
+
if (Array.isArray(content)) {
|
|
31
|
+
for (const block of content) {
|
|
32
|
+
if (block.type === 'text' && block.text) { out.push({ type: 'chunk', content: block.text }); capture.sawText = true; }
|
|
33
|
+
else if (block.type === 'tool_use') out.push({ type: 'tool', content: block.name || 'tool' });
|
|
34
|
+
}
|
|
35
|
+
} else if (typeof content === 'string') {
|
|
36
|
+
out.push({ type: 'chunk', content }); capture.sawText = true;
|
|
37
|
+
}
|
|
38
|
+
} else if (type === 'tool_use' && ev.name) {
|
|
39
|
+
out.push({ type: 'tool', content: ev.name });
|
|
40
|
+
} else if (type === 'result' && typeof ev.result === 'string') {
|
|
41
|
+
// `result` repeats the full answer — only use it if we never saw streamed
|
|
42
|
+
// assistant text (otherwise it duplicates, e.g. "PONGPONG").
|
|
43
|
+
if (!capture.sawText) { out.push({ type: 'chunk', content: ev.result }); capture.sawText = true; }
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param cfg { name, binaries:[], defaultArgs(fullPrompt, resumeId):[], streamJson?:bool,
|
|
51
|
+
* promptViaStdin?:bool, timeoutMs?:number }
|
|
52
|
+
*/
|
|
53
|
+
function makeCliAdapter(cfg) {
|
|
54
|
+
return {
|
|
55
|
+
name: cfg.name,
|
|
56
|
+
binaries: cfg.binaries,
|
|
57
|
+
|
|
58
|
+
async *run({ agent, job, logger }) {
|
|
59
|
+
const binary = agent.binary || cfg.binaries[0];
|
|
60
|
+
const userPrompt = job.content || '';
|
|
61
|
+
const systemPrompt = job.system_prompt || '';
|
|
62
|
+
const fullPrompt = systemPrompt ? `${systemPrompt}\n\n---\n\nUser request:\n${userPrompt}` : userPrompt;
|
|
63
|
+
const clSession = job.session_key || job.session_id || null;
|
|
64
|
+
const resumeId = clSession ? sessionStore.get(cfg.name, clSession) : null;
|
|
65
|
+
const timeoutMs = cfg.timeoutMs || Number(process.env.CLAWHOST_TIMEOUT_MS) || 600000;
|
|
66
|
+
|
|
67
|
+
// Run inside the agent's workspace/scratch dir. Create it if missing so the
|
|
68
|
+
// spawn never fails with ENOENT (coding workspaces are validated at setup).
|
|
69
|
+
try { fs.mkdirSync(agent.work_dir, { recursive: true }); } catch { /* ignore */ }
|
|
70
|
+
|
|
71
|
+
const argvFor = (rid) => Array.isArray(agent.args_template)
|
|
72
|
+
? buildArgv(agent.args_template, { prompt: fullPrompt, sessionId: rid || '', systemPrompt })
|
|
73
|
+
: cfg.defaultArgs(fullPrompt, rid, cfg.promptViaStdin);
|
|
74
|
+
|
|
75
|
+
let emittedAny = false;
|
|
76
|
+
const runOnce = async function* (rid) {
|
|
77
|
+
const capture = {};
|
|
78
|
+
const parseLine = cfg.streamJson ? makeStreamJsonParser(capture) : genericLineParser;
|
|
79
|
+
for await (const evt of spawnStreaming({
|
|
80
|
+
binary, argv: argvFor(rid), cwd: agent.work_dir, timeoutMs,
|
|
81
|
+
stdinInput: cfg.promptViaStdin ? fullPrompt : null, parseLine,
|
|
82
|
+
})) {
|
|
83
|
+
if (evt.type === 'chunk') emittedAny = true;
|
|
84
|
+
yield evt;
|
|
85
|
+
}
|
|
86
|
+
// Persist the captured session id only on a successful turn.
|
|
87
|
+
if (capture.sessionId && clSession) {
|
|
88
|
+
try { sessionStore.set(cfg.name, clSession, capture.sessionId); }
|
|
89
|
+
catch (e) { logger.warn('session-store write failed:', e.message); }
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
yield* runOnce(resumeId);
|
|
95
|
+
} catch (e) {
|
|
96
|
+
// A stale/invalid --resume id shouldn't break the conversation: if it failed
|
|
97
|
+
// before producing any output, drop the session and retry fresh.
|
|
98
|
+
if (resumeId && !emittedAny) {
|
|
99
|
+
logger.warn(`${cfg.name}: resume failed (${e.message}); retrying with a fresh session`);
|
|
100
|
+
try { sessionStore.set(cfg.name, clSession, null); } catch { /* ignore */ }
|
|
101
|
+
yield* runOnce(null);
|
|
102
|
+
} else {
|
|
103
|
+
throw e;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = { makeCliAdapter, genericLineParser, makeStreamJsonParser };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// OpenAI Codex CLI, non-interactive. VERIFY exact subcommand/flags + resume
|
|
3
|
+
// mechanism against the installed CLI; operators can override args_template.
|
|
4
|
+
const { makeCliAdapter } = require('./cli');
|
|
5
|
+
|
|
6
|
+
module.exports = makeCliAdapter({
|
|
7
|
+
name: 'codex',
|
|
8
|
+
binaries: ['codex'],
|
|
9
|
+
promptViaStdin: false,
|
|
10
|
+
// `--` ends option parsing so an untrusted prompt starting with '-' is treated
|
|
11
|
+
// as a positional argument, never as a flag (argument-injection hardening).
|
|
12
|
+
defaultArgs: (fullPrompt) => ['exec', '--', fullPrompt],
|
|
13
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Cursor agent CLI (headless). VERIFY exact binary name (`cursor-agent`?) and
|
|
3
|
+
// headless/print + resume flags; operators can override binary + args_template.
|
|
4
|
+
const { makeCliAdapter } = require('./cli');
|
|
5
|
+
|
|
6
|
+
module.exports = makeCliAdapter({
|
|
7
|
+
name: 'cursor',
|
|
8
|
+
binaries: ['cursor-agent', 'cursor'],
|
|
9
|
+
promptViaStdin: false,
|
|
10
|
+
defaultArgs: (fullPrompt) => ['-p', fullPrompt],
|
|
11
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Nous Research Hermes — driven by `hermes chat -q "<prompt>"` (full agent: SOUL,
|
|
3
|
+
// memory, skills). Sessions are local SQLite, resumable by id via --resume.
|
|
4
|
+
// VERIFY: exact streaming behaviour of `hermes chat`; `hermes -z` is final-only.
|
|
5
|
+
const { makeCliAdapter } = require('./cli');
|
|
6
|
+
|
|
7
|
+
module.exports = makeCliAdapter({
|
|
8
|
+
name: 'hermes',
|
|
9
|
+
binaries: ['hermes'],
|
|
10
|
+
defaultArgs: (fullPrompt, resumeId) => [
|
|
11
|
+
'chat', '-q', fullPrompt,
|
|
12
|
+
...(resumeId ? ['--resume', resumeId] : []),
|
|
13
|
+
],
|
|
14
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Runtime → adapter registry. Each adapter exposes `run(ctx)` yielding
|
|
3
|
+
// { type:'chunk'|'log'|'status'|'tool', content } events.
|
|
4
|
+
|
|
5
|
+
const adapters = {
|
|
6
|
+
openclaw: require('./openclaw'),
|
|
7
|
+
hermes: require('./hermes'),
|
|
8
|
+
claude: require('./claude'),
|
|
9
|
+
codex: require('./codex'),
|
|
10
|
+
cursor: require('./cursor'),
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function getAdapter(runtime) {
|
|
14
|
+
const a = adapters[runtime];
|
|
15
|
+
if (!a) throw new Error(`unknown runtime '${runtime}'`);
|
|
16
|
+
return a;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
module.exports = { adapters, getAdapter };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// OpenClaw adapter — talks to the LOCAL OpenClaw gateway over localhost so an
|
|
3
|
+
// OpenClaw agent can run through the host worker (no public Funnel needed). It's
|
|
4
|
+
// the only HTTP adapter; the others wrap CLIs. Reads url/token from config or
|
|
5
|
+
// ~/.openclaw/openclaw.json.
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
|
|
11
|
+
function resolveLocalGateway(agent) {
|
|
12
|
+
let url = agent.local_gateway_url;
|
|
13
|
+
let token = agent.local_gateway_token;
|
|
14
|
+
if (!url || !token) {
|
|
15
|
+
try {
|
|
16
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.openclaw', 'openclaw.json'), 'utf8'));
|
|
17
|
+
token = token || cfg?.gateway?.auth?.token || null;
|
|
18
|
+
} catch { /* ignore */ }
|
|
19
|
+
}
|
|
20
|
+
url = (url || 'http://localhost:3000').replace(/\/+$/, '');
|
|
21
|
+
return { url, token };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
module.exports = {
|
|
25
|
+
name: 'openclaw',
|
|
26
|
+
binaries: [],
|
|
27
|
+
|
|
28
|
+
async *run({ agent, job }) {
|
|
29
|
+
const { url, token } = resolveLocalGateway(agent);
|
|
30
|
+
const messages = [];
|
|
31
|
+
if (job.system_prompt) messages.push({ role: 'system', content: job.system_prompt });
|
|
32
|
+
messages.push({ role: 'user', content: job.content || '' });
|
|
33
|
+
|
|
34
|
+
const timeoutMs = Number(process.env.CLAWHOST_TIMEOUT_MS) || 600000; // 10-min cap
|
|
35
|
+
const res = await fetch(`${url}/v1/chat/completions`, {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers: {
|
|
38
|
+
'Content-Type': 'application/json',
|
|
39
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
40
|
+
},
|
|
41
|
+
body: JSON.stringify({
|
|
42
|
+
model: agent.openclaw_agent_id ? `openclaw/${agent.openclaw_agent_id}` : 'openclaw',
|
|
43
|
+
messages,
|
|
44
|
+
stream: false,
|
|
45
|
+
user: job.session_key || job.session_id || undefined,
|
|
46
|
+
}),
|
|
47
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
48
|
+
});
|
|
49
|
+
if (!res.ok) throw new Error(`local OpenClaw gateway HTTP ${res.status}`);
|
|
50
|
+
const data = await res.json();
|
|
51
|
+
const content = data?.choices?.[0]?.message?.content ?? '';
|
|
52
|
+
const toolCalls = data?.choices?.[0]?.message?.tool_calls ?? [];
|
|
53
|
+
for (const tc of toolCalls) {
|
|
54
|
+
const name = tc.function?.name || tc.name;
|
|
55
|
+
if (name) yield { type: 'tool', content: name };
|
|
56
|
+
}
|
|
57
|
+
if (content) yield { type: 'chunk', content };
|
|
58
|
+
},
|
|
59
|
+
};
|
package/src/bridge.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Outbound client for the host-bridge edge function. Every call carries the
|
|
3
|
+
// agent's Host Token as a Bearer credential. Uses global fetch (Node 18+).
|
|
4
|
+
|
|
5
|
+
const logger = require('./logger');
|
|
6
|
+
const { machineId } = require('./machine');
|
|
7
|
+
|
|
8
|
+
class Bridge {
|
|
9
|
+
constructor(bridgeUrl, agent, instanceId) {
|
|
10
|
+
this.url = bridgeUrl;
|
|
11
|
+
this.agentId = agent.agent_id || null; // may be unknown until register resolves it
|
|
12
|
+
this.token = agent.host_token;
|
|
13
|
+
this.instanceId = instanceId;
|
|
14
|
+
this.machineId = machineId();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async _post(action, extra) {
|
|
18
|
+
const res = await fetch(this.url, {
|
|
19
|
+
method: 'POST',
|
|
20
|
+
headers: {
|
|
21
|
+
'Content-Type': 'application/json',
|
|
22
|
+
Authorization: `Bearer ${this.token}`,
|
|
23
|
+
},
|
|
24
|
+
// agent_id is only a hint (the token resolves the agent); machine_id binds the host.
|
|
25
|
+
body: JSON.stringify({ action, agent_id: this.agentId || undefined, machine_id: this.machineId, ...extra }),
|
|
26
|
+
});
|
|
27
|
+
let data = null;
|
|
28
|
+
try { data = await res.json(); } catch { /* non-json */ }
|
|
29
|
+
if (!res.ok) {
|
|
30
|
+
const msg = (data && data.error) || `host-bridge ${action} → HTTP ${res.status}`;
|
|
31
|
+
const err = new Error(msg);
|
|
32
|
+
err.status = res.status;
|
|
33
|
+
throw err;
|
|
34
|
+
}
|
|
35
|
+
return data;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
register(runtime, version) { return this._post('register', { runtime, version }); }
|
|
39
|
+
heartbeat() { return this._post('heartbeat', {}); }
|
|
40
|
+
claim() { return this._post('claim', { instance_id: this.instanceId }); }
|
|
41
|
+
stream(jobId, seq, type, content) { return this._post('stream', { job_id: jobId, seq, type, content }); }
|
|
42
|
+
complete(jobId, finalContent, metadata) { return this._post('complete', { job_id: jobId, final_content: finalContent, metadata: metadata || {} }); }
|
|
43
|
+
fail(jobId, error) { return this._post('fail', { job_id: jobId, error: String(error).slice(0, 1000) }); }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Retry wrapper for transient network errors (not auth errors).
|
|
47
|
+
async function withRetry(fn, attempts = 3) {
|
|
48
|
+
let lastErr;
|
|
49
|
+
for (let i = 0; i < attempts; i++) {
|
|
50
|
+
try { return await fn(); }
|
|
51
|
+
catch (e) {
|
|
52
|
+
lastErr = e;
|
|
53
|
+
if (e.status === 401 || e.status === 403 || e.status === 404) throw e; // don't retry auth/not-found
|
|
54
|
+
await new Promise((r) => setTimeout(r, 500 * (i + 1)));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
logger.warn('bridge call failed after retries:', lastErr && lastErr.message);
|
|
58
|
+
throw lastErr;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = { Bridge, withRetry };
|
package/src/config.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Config load/save for the ClawLink Host Gateway.
|
|
3
|
+
// Stored at ~/.clawlink-host/config.json with 0600 perms (secrets live here).
|
|
4
|
+
// The host only ever talks OUTBOUND to the host-bridge edge function using each
|
|
5
|
+
// agent's Host Token — there is no Supabase key and no inbound listener.
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
|
|
11
|
+
const HOME_DIR = path.join(os.homedir(), '.clawlink-host');
|
|
12
|
+
const CONFIG_PATH = path.join(HOME_DIR, 'config.json');
|
|
13
|
+
|
|
14
|
+
// Runtimes whose CLI runs inside a project WORKSPACE directory (coding agents).
|
|
15
|
+
// OpenClaw (HTTP) and Hermes (~/.hermes context) don't need one.
|
|
16
|
+
const WORKSPACE_RUNTIMES = ['claude', 'codex', 'cursor'];
|
|
17
|
+
|
|
18
|
+
function expandHome(p) {
|
|
19
|
+
if (typeof p !== 'string' || !p) return p;
|
|
20
|
+
if (p === '~') return os.homedir();
|
|
21
|
+
if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
|
|
22
|
+
return p;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function ensureHomeDir() {
|
|
26
|
+
fs.mkdirSync(HOME_DIR, { recursive: true });
|
|
27
|
+
try { fs.chmodSync(HOME_DIR, 0o700); } catch { /* best effort (e.g. Windows) */ }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function defaultConfig() {
|
|
31
|
+
return {
|
|
32
|
+
bridge_url: '',
|
|
33
|
+
instance_id: require('crypto').randomUUID(),
|
|
34
|
+
poll_interval_ms: 1500,
|
|
35
|
+
heartbeat_ms: 10000,
|
|
36
|
+
agents: [],
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function load() {
|
|
41
|
+
if (!fs.existsSync(CONFIG_PATH)) return null;
|
|
42
|
+
const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
|
|
43
|
+
return JSON.parse(raw);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function save(cfg) {
|
|
47
|
+
ensureHomeDir();
|
|
48
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), { mode: 0o600 });
|
|
49
|
+
try { fs.chmodSync(CONFIG_PATH, 0o600); } catch { /* best effort */ }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Validate and normalize one agent binding. Only the token is required — the
|
|
53
|
+
// agent_id and runtime are resolved from Supabase at register (the dashboard is
|
|
54
|
+
// the source of truth) and cached here by `add-agent`.
|
|
55
|
+
function normalizeAgent(a) {
|
|
56
|
+
if (!a.host_token) throw new Error('host_token required for each agent');
|
|
57
|
+
return {
|
|
58
|
+
agent_id: a.agent_id || null,
|
|
59
|
+
host_token: a.host_token,
|
|
60
|
+
runtime: a.runtime || null,
|
|
61
|
+
binary: a.binary || null,
|
|
62
|
+
args_template: Array.isArray(a.args_template) ? a.args_template : null,
|
|
63
|
+
local_gateway_url: a.local_gateway_url || null,
|
|
64
|
+
local_gateway_token: a.local_gateway_token || null,
|
|
65
|
+
openclaw_agent_id: a.openclaw_agent_id || null,
|
|
66
|
+
// Coding runtimes (claude/codex/cursor) run inside this WORKSPACE directory;
|
|
67
|
+
// other runtimes get a private scratch dir (resolved once agent_id is known).
|
|
68
|
+
work_dir: expandHome(a.work_dir) || null,
|
|
69
|
+
concurrency: Number(a.concurrency) > 0 ? Number(a.concurrency) : 1,
|
|
70
|
+
rate_limit_per_min: Number(a.rate_limit_per_min) > 0 ? Number(a.rate_limit_per_min) : 60,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Returns a copy with all secrets redacted — safe to print/log.
|
|
75
|
+
function redacted(cfg) {
|
|
76
|
+
const clone = JSON.parse(JSON.stringify(cfg));
|
|
77
|
+
for (const a of clone.agents || []) {
|
|
78
|
+
if (a.host_token) a.host_token = `…${String(a.host_token).slice(-4)}`;
|
|
79
|
+
if (a.local_gateway_token) a.local_gateway_token = '***';
|
|
80
|
+
}
|
|
81
|
+
return clone;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = { HOME_DIR, CONFIG_PATH, WORKSPACE_RUNTIMES, expandHome, ensureHomeDir, defaultConfig, load, save, normalizeAgent, redacted };
|
package/src/detect.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Detect which runtime CLIs are installed, to pre-fill the setup wizard.
|
|
3
|
+
|
|
4
|
+
const { execSync } = require('child_process');
|
|
5
|
+
|
|
6
|
+
function which(cmd) {
|
|
7
|
+
const probe = process.platform === 'win32' ? `where ${cmd}` : `command -v ${cmd}`;
|
|
8
|
+
try {
|
|
9
|
+
const out = execSync(probe, { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
10
|
+
return out.split(/\r?\n/)[0] || null;
|
|
11
|
+
} catch { return null; }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const RUNTIME_BINARIES = {
|
|
15
|
+
openclaw: ['openclaw'],
|
|
16
|
+
hermes: ['hermes'],
|
|
17
|
+
claude: ['claude'],
|
|
18
|
+
codex: ['codex'],
|
|
19
|
+
cursor: ['cursor-agent', 'cursor'],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Returns { runtime: resolvedPath|null }
|
|
23
|
+
function detectRuntimes() {
|
|
24
|
+
const found = {};
|
|
25
|
+
for (const [runtime, bins] of Object.entries(RUNTIME_BINARIES)) {
|
|
26
|
+
found[runtime] = null;
|
|
27
|
+
for (const b of bins) {
|
|
28
|
+
const p = which(b);
|
|
29
|
+
if (p) { found[runtime] = p; break; }
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return found;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = { detectRuntimes, which, RUNTIME_BINARIES };
|
package/src/logger.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Redaction-aware logger. Every secret registered here is replaced with *** in
|
|
3
|
+
// ALL output (including adapter stderr passthrough) so tokens never hit the logs.
|
|
4
|
+
|
|
5
|
+
const secrets = new Set();
|
|
6
|
+
|
|
7
|
+
function addSecret(s) {
|
|
8
|
+
if (s && typeof s === 'string' && s.length >= 6) secrets.add(s);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function redact(line) {
|
|
12
|
+
let out = String(line);
|
|
13
|
+
for (const s of secrets) {
|
|
14
|
+
if (!s) continue;
|
|
15
|
+
out = out.split(s).join('***');
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function ts() {
|
|
21
|
+
return new Date().toISOString();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function info(...args) { console.log(`[claw-host ${ts()}]`, redact(args.join(' '))); }
|
|
25
|
+
function warn(...args) { console.warn(`[claw-host ${ts()}] WARN`, redact(args.join(' '))); }
|
|
26
|
+
function error(...args) { console.error(`[claw-host ${ts()}] ERROR`, redact(args.join(' '))); }
|
|
27
|
+
|
|
28
|
+
module.exports = { addSecret, redact, info, warn, error };
|
package/src/machine.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Stable machine fingerprint for host binding. Derived from the host's physical
|
|
3
|
+
// MAC address(es) + hostname, then hashed — we send only the HASH to ClawLink, so
|
|
4
|
+
// raw MACs never leave the machine. Supabase binds this on first connect; after
|
|
5
|
+
// that, only this machine may act for the agent (a stolen token from elsewhere
|
|
6
|
+
// fails the binding check).
|
|
7
|
+
|
|
8
|
+
const os = require('os');
|
|
9
|
+
const crypto = require('crypto');
|
|
10
|
+
|
|
11
|
+
function macAddresses() {
|
|
12
|
+
const macs = [];
|
|
13
|
+
const ifaces = os.networkInterfaces();
|
|
14
|
+
for (const name of Object.keys(ifaces)) {
|
|
15
|
+
for (const ni of ifaces[name] || []) {
|
|
16
|
+
if (ni.internal) continue;
|
|
17
|
+
if (!ni.mac || ni.mac === '00:00:00:00:00:00') continue;
|
|
18
|
+
macs.push(ni.mac.toLowerCase());
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return [...new Set(macs)].sort();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function machineId() {
|
|
25
|
+
const macs = macAddresses();
|
|
26
|
+
const basis = (macs.length ? macs.join(',') : 'no-mac') + '|' + os.hostname();
|
|
27
|
+
return crypto.createHash('sha256').update(basis).digest('hex');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = { machineId };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Maps a ClawLink session id → the runtime CLI's own resumable session id, so
|
|
3
|
+
// multi-turn conversations keep context (e.g. `hermes chat --resume <id>`,
|
|
4
|
+
// `claude --resume <id>`). Persisted to ~/.clawlink-host/sessions.json (0600).
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { HOME_DIR, ensureHomeDir } = require('./config');
|
|
9
|
+
|
|
10
|
+
const STORE_PATH = path.join(HOME_DIR, 'sessions.json');
|
|
11
|
+
|
|
12
|
+
function read() {
|
|
13
|
+
try { return JSON.parse(fs.readFileSync(STORE_PATH, 'utf8')); } catch { return {}; }
|
|
14
|
+
}
|
|
15
|
+
function write(obj) {
|
|
16
|
+
ensureHomeDir();
|
|
17
|
+
fs.writeFileSync(STORE_PATH, JSON.stringify(obj, null, 2), { mode: 0o600 });
|
|
18
|
+
try { fs.chmodSync(STORE_PATH, 0o600); } catch { /* best effort */ }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Composite key so the same ClawLink session under different runtimes never clashes.
|
|
22
|
+
function keyFor(runtime, clawlinkSessionId) {
|
|
23
|
+
return `${runtime}:${clawlinkSessionId || 'default'}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function get(runtime, clawlinkSessionId) {
|
|
27
|
+
return read()[keyFor(runtime, clawlinkSessionId)] || null;
|
|
28
|
+
}
|
|
29
|
+
function set(runtime, clawlinkSessionId, runtimeSessionId) {
|
|
30
|
+
const all = read();
|
|
31
|
+
all[keyFor(runtime, clawlinkSessionId)] = runtimeSessionId;
|
|
32
|
+
write(all);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = { get, set };
|
package/src/worker.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// The long-lived worker. For each configured agent it registers with host-bridge,
|
|
3
|
+
// heartbeats, and polls `claim` for jobs (RLS blocks anon Realtime on the queue,
|
|
4
|
+
// so short-polling — token-only, outbound — is both simplest and most secure).
|
|
5
|
+
// Each job runs the agent's runtime adapter; output is streamed back chunk-by-chunk.
|
|
6
|
+
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const logger = require('./logger');
|
|
9
|
+
const config = require('./config');
|
|
10
|
+
const { Bridge, withRetry } = require('./bridge');
|
|
11
|
+
const { getAdapter } = require('./adapters');
|
|
12
|
+
const { detectRuntimes } = require('./detect');
|
|
13
|
+
|
|
14
|
+
const VERSION = (() => { try { return require('../package.json').version; } catch { return '0.0.0'; } })();
|
|
15
|
+
|
|
16
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
17
|
+
let stopping = false;
|
|
18
|
+
|
|
19
|
+
async function processJob(bridge, agentCfg, job) {
|
|
20
|
+
logger.info(`job ${job.id} claimed (${agentCfg.runtime})`);
|
|
21
|
+
const adapter = getAdapter(agentCfg.runtime);
|
|
22
|
+
let seq = 0;
|
|
23
|
+
let finalText = '';
|
|
24
|
+
try {
|
|
25
|
+
for await (const evt of adapter.run({ agent: agentCfg, job, logger })) {
|
|
26
|
+
if (!evt || !evt.type) continue;
|
|
27
|
+
if (evt.type === 'chunk') finalText += evt.content || '';
|
|
28
|
+
try { await bridge.stream(job.id, seq++, evt.type, String(evt.content ?? '')); }
|
|
29
|
+
catch (e) { logger.warn(`stream seq ${seq} failed: ${e.message}`); }
|
|
30
|
+
}
|
|
31
|
+
await withRetry(() => bridge.complete(job.id, finalText));
|
|
32
|
+
logger.info(`job ${job.id} done (${finalText.length} chars)`);
|
|
33
|
+
} catch (e) {
|
|
34
|
+
logger.error(`job ${job.id} failed: ${e.message}`);
|
|
35
|
+
try { await bridge.fail(job.id, e.message); } catch { /* ignore */ }
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function runAgentLoop(agentCfg, cfg) {
|
|
40
|
+
const bridge = new Bridge(cfg.bridge_url, agentCfg, cfg.instance_id);
|
|
41
|
+
logger.addSecret(agentCfg.host_token);
|
|
42
|
+
if (agentCfg.local_gateway_token) logger.addSecret(agentCfg.local_gateway_token);
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
// Token-only register: the server resolves agent_id + runtime (dashboard is
|
|
46
|
+
// the source of truth) and binds this machine on first connect.
|
|
47
|
+
const resp = await withRetry(() => bridge.register(agentCfg.runtime, VERSION));
|
|
48
|
+
agentCfg.agent_id = resp.agent_id || agentCfg.agent_id;
|
|
49
|
+
if (resp.runtime) agentCfg.runtime = resp.runtime;
|
|
50
|
+
bridge.agentId = agentCfg.agent_id;
|
|
51
|
+
|
|
52
|
+
// Resolve binary + work_dir now that the runtime is known.
|
|
53
|
+
if (!agentCfg.binary && agentCfg.runtime !== 'openclaw') {
|
|
54
|
+
const found = detectRuntimes()[agentCfg.runtime];
|
|
55
|
+
if (found) agentCfg.binary = found;
|
|
56
|
+
else logger.warn(`no '${agentCfg.runtime}' binary found on PATH — set "binary" in config for ${agentCfg.agent_id}`);
|
|
57
|
+
}
|
|
58
|
+
if (!agentCfg.work_dir) agentCfg.work_dir = path.join(config.HOME_DIR, 'work', agentCfg.agent_id || 'default');
|
|
59
|
+
|
|
60
|
+
logger.info(`registered ${agentCfg.agent_id} as '${agentCfg.runtime}'`);
|
|
61
|
+
} catch (e) {
|
|
62
|
+
logger.error(`register failed — check the Host Token & machine binding (rotate the token in the dashboard to re-bind a new machine). ${e.message}`);
|
|
63
|
+
return; // bad token / machine mismatch: stop this agent's loop (don't spin)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const hb = setInterval(() => { bridge.heartbeat().catch(() => {}); }, cfg.heartbeat_ms || 10000);
|
|
67
|
+
|
|
68
|
+
let inFlight = 0;
|
|
69
|
+
const starts = []; // job start timestamps for the rolling rate-limit window
|
|
70
|
+
|
|
71
|
+
while (!stopping) {
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
while (starts.length && now - starts[0] > 60000) starts.shift();
|
|
74
|
+
|
|
75
|
+
const canRun = inFlight < agentCfg.concurrency && starts.length < agentCfg.rate_limit_per_min;
|
|
76
|
+
if (canRun) {
|
|
77
|
+
let job = null;
|
|
78
|
+
try { const r = await bridge.claim(); job = r && r.job; }
|
|
79
|
+
catch (e) { logger.warn(`claim failed (${agentCfg.agent_id}): ${e.message}`); }
|
|
80
|
+
if (job) {
|
|
81
|
+
inFlight++; starts.push(Date.now());
|
|
82
|
+
processJob(bridge, agentCfg, job).finally(() => { inFlight--; });
|
|
83
|
+
continue; // try to fill remaining concurrency immediately
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
await sleep(cfg.poll_interval_ms || 1500);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
clearInterval(hb);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function startWorker() {
|
|
93
|
+
const raw = config.load();
|
|
94
|
+
if (!raw) {
|
|
95
|
+
logger.error('No config found. Run: npx @claw-link/gateway-host setup');
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
if (!raw.bridge_url) { logger.error('config.bridge_url is missing.'); process.exit(1); }
|
|
99
|
+
const agents = (raw.agents || []).map(config.normalizeAgent);
|
|
100
|
+
if (agents.length === 0) { logger.error('No agents configured. Run setup again.'); process.exit(1); }
|
|
101
|
+
|
|
102
|
+
logger.info(`ClawLink Host Gateway v${VERSION} — ${agents.length} agent(s): ${agents.map((a) => `${(a.agent_id || 'pending').slice(0, 8)}…(${a.runtime || 'resolving'})`).join(', ')}`);
|
|
103
|
+
|
|
104
|
+
const onStop = (sig) => {
|
|
105
|
+
if (stopping) return;
|
|
106
|
+
stopping = true;
|
|
107
|
+
logger.info(`${sig} received — draining in-flight jobs…`);
|
|
108
|
+
setTimeout(() => process.exit(0), 8000).unref();
|
|
109
|
+
};
|
|
110
|
+
process.on('SIGINT', () => onStop('SIGINT'));
|
|
111
|
+
process.on('SIGTERM', () => onStop('SIGTERM'));
|
|
112
|
+
|
|
113
|
+
await Promise.all(agents.map((a) => runAgentLoop(a, raw)));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = { startWorker };
|