@claw-link/gateway-host 0.2.4 → 0.2.6

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 CHANGED
@@ -64,13 +64,14 @@ tool's own settings).
64
64
  **Permissions by scope** — each job carries a scope, and the Claude adapter runs with
65
65
  matching permissions automatically:
66
66
 
67
- | Trigger | Scope | Claude permissions |
68
- |---------|-------|--------------------|
69
- | Admin **Setup via Chat** / apply skill | `configure` | `--permission-mode acceptEdits` may write workspace files (GUARDRAILS/SOUL/BOOTSTRAP/TOOLS/AGENTS) |
70
- | Customer chat + discovery | `customer` | `--disallowedTools "Write Edit MultiEdit NotebookEdit Bash"` **read-only**, cannot edit config or run shell |
67
+ | Trigger | Scope | Claude | Codex |
68
+ |---------|-------|--------|-------|
69
+ | Admin **Setup via Chat** / apply skill | `configure` | `--permission-mode acceptEdits` (may write workspace files) | `--sandbox workspace-write --ask-for-approval never` |
70
+ | Customer chat + discovery | `customer` | `--disallowedTools "Write Edit MultiEdit NotebookEdit Bash"` (**read-only**) | `--sandbox read-only --ask-for-approval never` |
71
71
 
72
72
  So an agent's files are editable from the admin panel but never from customer/discovery
73
- chat. (Operator `args_template` overrides this for advanced setups.)
73
+ chat. (Operator `args_template` overrides this for advanced setups. Hermes/Cursor/OpenClaw
74
+ manage their own permissions.)
74
75
 
75
76
  ## Commands
76
77
 
@@ -80,6 +81,7 @@ After install the `clhost` command is available (aliases: `gateway-host`, `claw-
80
81
  clhost install # install the background service (one-time)
81
82
  clhost add-agent # add an agent by pasting its Host Token
82
83
  clhost rm-agent <id># remove a mapped agent
84
+ clhost retoken <id> # re-enter a new token after rotating (keeps workspace/binary)
83
85
  clhost agents # list mapped agents
84
86
  clhost status # bridge + service state + mapped agents
85
87
  clhost start # start the background service
@@ -111,11 +113,18 @@ cached back in). `~/.clawlink-host/config.json` (chmod 600):
111
113
  }
112
114
  ```
113
115
 
116
+ ## Platforms
117
+
118
+ macOS (launchd), Linux (systemd user service), and **Windows** (a hidden, auto-restarting
119
+ Scheduled Task; logs to `%USERPROFILE%\.clawlink-host\host.log`). On Windows the worker
120
+ handles npm `.cmd` CLI shims automatically; for the strongest isolation prefer Claude
121
+ (prompt via stdin) or a native `.exe` runtime.
122
+
114
123
  ## Security
115
124
 
116
- Outbound-only, per-agent token (stored server-side as a hash only), **machine-bound**
117
- on first connect (a stolen token fails from any other host), no shell injection, runs
118
- only the configured binary. See [SECURITY.md](./SECURITY.md).
125
+ Outbound-only, per-agent token (stored server-side as a hash only), **machine-bound** on
126
+ first connect to a **stable hardware id** (a stolen token fails from any other host), no
127
+ shell injection, runs only the configured binary. See [SECURITY.md](./SECURITY.md).
119
128
 
120
129
  ---
121
130
 
package/SECURITY.md CHANGED
@@ -12,18 +12,25 @@ a secret, never run an attacker-controlled command, never open a port.
12
12
  exactly one `agent_id`. Rotate any time from the dashboard (the old token dies
13
13
  immediately).
14
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.)
15
+ **stable hardware machine id** (macOS `IOPlatformUUID`, Linux `/etc/machine-id`,
16
+ Windows `MachineGuid`; a persisted random id as fallback); ClawLink binds the agent
17
+ to that machine. Every later call must present the same id, so a stolen token is
18
+ rejected from any other host. Only the hash is sent the raw id never leaves the
19
+ machine. The id is **stable across reboots, network changes, and version upgrades**
20
+ (earlier builds hashed MAC addresses, which rotate and caused false mismatches).
21
+ Rotating the token clears the binding (also how you move to a new host). Treat this
22
+ as a strong second factor on top of the token, not a replacement.
21
23
  3. **Outbound-only** — the worker **dials out** to the `host-bridge` HTTPS endpoint
22
24
  and short-polls for work. It opens **no inbound port**, needs **no Tailscale
23
25
  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.
26
+ 4. **No shell injection** — on macOS/Linux every CLI is launched with
27
+ `child_process.spawn(binary, argvArray, { shell: false })`; untrusted prompt text is
28
+ a single argv token or comes via stdin — **never** concatenated into a command
29
+ string. On Windows, npm CLI shims are `.cmd`/`.bat` files that Node can only spawn
30
+ through the shell, so those go through `cmd.exe` with Node's hardened argument
31
+ escaping; native `.exe` runtimes still use `shell: false`. For Windows, prefer a
32
+ runtime that reads the prompt via **stdin** (Claude) or a native `.exe` so untrusted
33
+ text never enters a Windows command line.
27
34
  5. **No arbitrary command execution** — the worker runs **only** the runtime binary
28
35
  you configured. The job payload it receives carries `{ content, system_prompt,
29
36
  attachments, session }` — it is structurally incapable of carrying a command, and
package/bin/cli.js CHANGED
@@ -27,6 +27,11 @@ async function main() {
27
27
  case 'rm':
28
28
  return require('../scripts/install').removeAgentCmd();
29
29
 
30
+ case 'retoken':
31
+ case 're-token':
32
+ case 'update-token':
33
+ return require('../scripts/install').retokenCmd();
34
+
30
35
  case 'agents':
31
36
  case 'list':
32
37
  return require('../scripts/install').listAgents();
@@ -48,7 +53,8 @@ async function main() {
48
53
  'To rotate a Host Token (also how you move an agent to a new machine):',
49
54
  ' 1. ClawLink → Agents → your agent → Host Gateway → "Rotate token"',
50
55
  ' (this clears the machine binding so a new host can claim it).',
51
- ' 2. Copy the new token, then run: clhost add-agent',
56
+ ' 2. Copy the new token, then run: clhost retoken <id>',
57
+ ' (keeps the agent\'s workspace/binary; or `clhost add-agent` for a new one).',
52
58
  ' The old token + old machine stop working immediately.',
53
59
  ].join('\n'));
54
60
  return;
@@ -63,6 +69,7 @@ async function main() {
63
69
  ' install Install the background service (one-time)',
64
70
  ' add-agent Add an agent by pasting its Host Token (pulls runtime from ClawLink)',
65
71
  ' rm-agent Remove a mapped agent (clhost rm-agent <id>)',
72
+ ' retoken Re-enter a new Host Token for an existing agent (clhost retoken <id>)',
66
73
  ' agents List mapped agents',
67
74
  ' status Show bridge, service state, and mapped agents',
68
75
  ' start Start the background service',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claw-link/gateway-host",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
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
5
  "homepage": "https://claw-link.co",
6
6
  "bin": {
@@ -175,11 +175,54 @@ function printAgents(cfg) {
175
175
  const tok = a.host_token ? `…${String(a.host_token).slice(-4)}` : '—';
176
176
  console.log(` • ${id}… runtime=${a.runtime || 'pull'} token=${tok}` + (a.work_dir ? ` work=${a.work_dir}` : ''));
177
177
  }
178
+ console.log('\n Update a token after rotating it: clhost retoken <id>');
178
179
  }
179
180
 
180
181
  // `agents` — list mapped agents.
181
182
  function listAgents() { printAgents(config.load() || {}); }
182
183
 
184
+ // `retoken [id-or-prefix]` — re-enter a new Host Token for an EXISTING agent after
185
+ // rotating it in the dashboard, keeping the same work_dir/binary/runtime. The new
186
+ // token must resolve to the same agent_id; re-binds this machine on verify.
187
+ async function retokenCmd() {
188
+ const cfg = config.load();
189
+ if (!cfg || !(cfg.agents || []).length) { console.log('No agents configured. Run: clhost add-agent'); return; }
190
+ ensureBridgeUrl(cfg);
191
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
192
+ try {
193
+ let target = (process.argv[3] || '').trim();
194
+ if (!target && cfg.agents.length === 1) target = cfg.agents[0].agent_id || '';
195
+ if (!target) { printAgents(cfg); target = await ask(rl, '\n Agent ID (or prefix) to re-token'); }
196
+ if (!target) { console.log(' (cancelled)'); return; }
197
+ const agent = cfg.agents.find((a) => a.agent_id && (a.agent_id === target || a.agent_id.startsWith(target)));
198
+ if (!agent) { console.log(` No agent matched '${target}'.`); return; }
199
+
200
+ const token = await ask(rl, ` New Host Token for ${String(agent.agent_id).slice(0, 8)}… (clk_host_…)`);
201
+ if (!token) { console.log(' (cancelled)'); return; }
202
+
203
+ console.log(' Verifying new token + binding this machine…');
204
+ let resp;
205
+ try {
206
+ resp = await new Bridge(cfg.bridge_url, { host_token: token }, cfg.instance_id).register(null, VERSION);
207
+ } catch (e) { console.log(` ✗ Could not verify: ${e.message}`); return; }
208
+
209
+ if (resp.agent_id && resp.agent_id !== agent.agent_id) {
210
+ console.log(` ✗ That token belongs to a different agent (${String(resp.agent_id).slice(0, 8)}…), not ${String(agent.agent_id).slice(0, 8)}….`);
211
+ console.log(' To map a different agent, use: clhost add-agent');
212
+ return;
213
+ }
214
+ agent.host_token = token;
215
+ if (resp.runtime && resp.runtime !== agent.runtime) {
216
+ console.log(` ℹ runtime changed: ${agent.runtime} → ${resp.runtime}`);
217
+ agent.runtime = resp.runtime;
218
+ }
219
+ config.save(cfg);
220
+ const restarted = serviceControl('restart');
221
+ console.log(`\n ✓ Updated token for ${agent.agent_id} (…${token.slice(-4)}).` +
222
+ (restarted ? ' Service restarted.' : ' Run `clhost restart` to apply.') + '\n');
223
+ } finally { rl.close(); }
224
+ }
225
+
183
226
  // `start | stop | restart` — control the installed background service.
184
227
  function serviceControl(action) {
185
228
  const platform = process.platform;
@@ -262,14 +305,59 @@ WantedBy=default.target
262
305
  return execSafe('systemctl --user enable --now clawlink-host') !== null;
263
306
  }
264
307
  if (platform === 'win32') {
265
- // Best effort: a scheduled task that runs at logon.
266
- const taskCmd = `"${nodeBin()}" "${cliEntry()}" run`;
267
- return execSafe(`schtasks /Create /F /SC ONLOGON /TN ClawLinkHost /TR ${JSON.stringify(taskCmd)}`) !== null;
308
+ return installServiceWindows();
268
309
  }
269
310
  } catch { /* fall through */ }
270
311
  return false;
271
312
  }
272
313
 
314
+ // Windows: a hidden, auto-restarting Scheduled Task created from an XML definition
315
+ // (avoids schtasks /TR quoting issues with spaces in paths). A .cmd launcher pipes
316
+ // the worker's output to host.log; a .vbs runs that launcher with NO console window.
317
+ function installServiceWindows() {
318
+ const logPath = path.join(config.HOME_DIR, 'host.log');
319
+ const cmdPath = path.join(config.HOME_DIR, 'run-host.cmd');
320
+ const vbsPath = path.join(config.HOME_DIR, 'run-host.vbs');
321
+ const xmlPath = path.join(config.HOME_DIR, 'task.xml');
322
+ fs.mkdirSync(config.HOME_DIR, { recursive: true });
323
+
324
+ // 1) launcher that runs the worker and redirects stdout+stderr to the log
325
+ fs.writeFileSync(cmdPath,
326
+ `@echo off\r\n"${nodeBin()}" "${cliEntry()}" run >> "${logPath}" 2>&1\r\n`);
327
+
328
+ // 2) VBScript that runs the launcher hidden (0) and waits (True) so the task
329
+ // process stays alive while the worker runs. "" -> a literal quote in VBScript.
330
+ fs.writeFileSync(vbsPath,
331
+ `CreateObject("WScript.Shell").Run "cmd /c ""${cmdPath}""", 0, True\r\n`);
332
+
333
+ // 3) Task XML: logon trigger, hidden, no time limit, restart-on-failure (parity
334
+ // with launchd KeepAlive / systemd Restart=always). UTF-16 + BOM (schtasks /XML).
335
+ const xml = `<?xml version="1.0" encoding="UTF-16"?>
336
+ <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
337
+ <RegistrationInfo><Description>ClawLink Host Gateway</Description></RegistrationInfo>
338
+ <Triggers><LogonTrigger><Enabled>true</Enabled></LogonTrigger></Triggers>
339
+ <Principals><Principal id="Author"><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>
340
+ <Settings>
341
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
342
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
343
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
344
+ <StartWhenAvailable>true</StartWhenAvailable>
345
+ <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
346
+ <Hidden>true</Hidden>
347
+ <Enabled>true</Enabled>
348
+ <RestartOnFailure><Interval>PT1M</Interval><Count>999</Count></RestartOnFailure>
349
+ </Settings>
350
+ <Actions Context="Author"><Exec><Command>wscript.exe</Command><Arguments>//B //Nologo "${vbsPath}"</Arguments></Exec></Actions>
351
+ </Task>`;
352
+ fs.writeFileSync(xmlPath, '' + xml, 'utf16le');
353
+
354
+ execSafe('schtasks /End /TN ClawLinkHost');
355
+ execSafe('schtasks /Delete /F /TN ClawLinkHost');
356
+ const created = execSafe(`schtasks /Create /F /TN ClawLinkHost /XML "${xmlPath}"`) !== null;
357
+ if (created) execSafe('schtasks /Run /TN ClawLinkHost');
358
+ return created;
359
+ }
360
+
273
361
  function serviceRunning() {
274
362
  const platform = process.platform;
275
363
  if (platform === 'darwin') {
@@ -294,6 +382,6 @@ function status() {
294
382
  }
295
383
 
296
384
  module.exports = {
297
- run, installOnly, addAgentCmd, removeAgentCmd, listAgents, status,
385
+ run, installOnly, addAgentCmd, removeAgentCmd, retokenCmd, listAgents, status,
298
386
  serviceControl, serviceControlCmd, installService, SERVICE_LABEL,
299
387
  };
@@ -1,11 +1,15 @@
1
1
  'use strict';
2
2
  // Shared spawn helper for CLI-wrapping adapters.
3
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.
4
+ // SECURITY: commands are invoked with an argv ARRAY (never a concatenated command
5
+ // string). Placeholders in args_template ({prompt}, {sessionId}, {systemPrompt}) are
6
+ // substituted PER ARRAY ELEMENT each becomes exactly one argv token. On POSIX we
7
+ // always use shell:false so untrusted prompt text can never reach a shell. On Windows,
8
+ // `.cmd`/`.bat` shims (e.g. npm-installed CLIs) MUST go through the shell because Node
9
+ // refuses to spawn them otherwise; Node then applies its hardened cmd.exe escaping to
10
+ // each argv token. For Windows, prefer a runtime that takes the prompt via STDIN
11
+ // (Claude does) or a native `.exe` so untrusted text never enters a Windows argv at
12
+ // all. The host only ever runs the configured runtime binary.
9
13
 
10
14
  const { spawn } = require('child_process');
11
15
  const logger = require('../logger');
@@ -44,11 +48,20 @@ function buildArgv(template, vars) {
44
48
  async function* spawnStreaming({ binary, argv, cwd, env, timeoutMs = 600000, stdinInput = null, parseLine }) {
45
49
  if (!binary) throw new Error('no runtime binary configured');
46
50
 
51
+ // Windows: npm CLIs are `.cmd`/`.bat` shims, and modern Node REFUSES to spawn those
52
+ // with shell:false (EINVAL, post-CVE-2024-27980). Such shims (and bare names that
53
+ // resolve via PATHEXT) must go through the shell; Node then applies its hardened
54
+ // cmd.exe argument escaping. Native `.exe`/`.com` are spawned directly (no shell).
55
+ // On POSIX we ALWAYS use shell:false (untrusted prompt can never reach a shell).
56
+ const isWin = process.platform === 'win32';
57
+ const useShell = isWin && !/\.(exe|com)$/i.test(String(binary));
58
+
47
59
  const child = spawn(binary, argv, {
48
60
  cwd: cwd || undefined,
49
61
  // Enriched PATH so a CLI in ~/.local/bin etc. resolves under a service's minimal env.
50
62
  env: enrichedEnv(env),
51
- shell: false, // CRITICAL: never use a shell
63
+ shell: useShell, // false on POSIX (no shell, ever); on Windows true only for .cmd/.bat shims
64
+ windowsHide: true, // no flashing console window on Windows
52
65
  stdio: ['pipe', 'pipe', 'pipe'],
53
66
  });
54
67
 
@@ -10,10 +10,11 @@ module.exports = makeCliAdapter({
10
10
  streamJson: true,
11
11
  // Prompt via stdin keeps it out of argv/process listings.
12
12
  promptViaStdin: true,
13
- defaultArgs: (_fullPrompt, resumeId) => [
13
+ defaultArgs: (_fullPrompt, resumeId, _stdin, perm = []) => [
14
14
  '-p',
15
15
  '--output-format', 'stream-json',
16
16
  '--verbose',
17
+ ...perm,
17
18
  ...(resumeId ? ['--resume', resumeId] : []),
18
19
  ],
19
20
  // Admin "configure" turns may write workspace files (auto-accept edits, no shell
@@ -78,12 +78,14 @@ function makeCliAdapter(cfg) {
78
78
 
79
79
  // Scope-based permissions: admin "configure" turns may write workspace files
80
80
  // (GUARDRAILS/SOUL/etc.); customer/discovery turns are read-only and cannot
81
- // edit or run shell. Operator args_template overrides take full control.
81
+ // edit or run shell. permArgs is handed to defaultArgs so each adapter can place
82
+ // it correctly (e.g. Codex must put flags BEFORE its `--` end-of-options).
83
+ // Operator args_template overrides take full control.
82
84
  const scope = job.scope || 'customer';
83
85
  const permArgs = (!Array.isArray(agent.args_template) && cfg.permissions) ? cfg.permissions(scope) : [];
84
86
  const argvFor = (rid) => Array.isArray(agent.args_template)
85
87
  ? buildArgv(agent.args_template, { prompt: fullPrompt, sessionId: rid || '', systemPrompt })
86
- : [...cfg.defaultArgs(fullPrompt, rid, cfg.promptViaStdin), ...permArgs];
88
+ : cfg.defaultArgs(fullPrompt, rid, cfg.promptViaStdin, permArgs);
87
89
 
88
90
  let emittedAny = false;
89
91
  const runOnce = async function* (rid) {
@@ -7,7 +7,16 @@ module.exports = makeCliAdapter({
7
7
  name: 'codex',
8
8
  binaries: ['codex'],
9
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],
10
+ // Flags go BEFORE `--`; `--` ends option parsing so an untrusted prompt starting
11
+ // with '-' is treated as a positional argument, never as a flag (arg-injection
12
+ // hardening). `--ask-for-approval never` ensures it never blocks waiting for an
13
+ // interactive approval in headless mode.
14
+ defaultArgs: (fullPrompt, _resumeId, _stdin, perm = []) => ['exec', ...perm, '--', fullPrompt],
15
+ // Admin "configure" → workspace-write sandbox (may write files in its work dir);
16
+ // customer/discovery → read-only sandbox (cannot modify anything). Never the
17
+ // danger/full-access sandbox.
18
+ permissions: (scope) =>
19
+ (scope === 'configure' || scope === 'sub_configure')
20
+ ? ['--sandbox', 'workspace-write', '--ask-for-approval', 'never']
21
+ : ['--sandbox', 'read-only', '--ask-for-approval', 'never'],
13
22
  });
package/src/paths.js CHANGED
@@ -10,17 +10,28 @@ const path = require('path');
10
10
 
11
11
  function enrichedPath() {
12
12
  const home = os.homedir();
13
- const extras = [
14
- path.dirname(process.execPath), // dir of the running node (npm-global CLIs)
15
- path.join(home, '.local', 'bin'),
16
- path.join(home, 'bin'),
17
- path.join(home, '.npm-global', 'bin'),
18
- path.join(home, '.bun', 'bin'),
19
- path.join(home, '.cargo', 'bin'),
20
- '/opt/homebrew/bin',
21
- '/usr/local/bin',
22
- '/usr/bin', '/bin', '/usr/sbin', '/sbin',
23
- ];
13
+ const extras = [path.dirname(process.execPath)]; // dir of the running node (npm-global CLIs)
14
+ if (process.platform === 'win32') {
15
+ const { APPDATA, LOCALAPPDATA, ProgramFiles, SystemRoot } = process.env;
16
+ if (APPDATA) extras.push(path.join(APPDATA, 'npm')); // npm global shims
17
+ if (LOCALAPPDATA) {
18
+ extras.push(path.join(LOCALAPPDATA, 'Programs')); // per-user installers (e.g. claude)
19
+ extras.push(path.join(LOCALAPPDATA, 'Microsoft', 'WindowsApps'));
20
+ }
21
+ if (ProgramFiles) extras.push(path.join(ProgramFiles, 'nodejs'));
22
+ if (SystemRoot) { extras.push(path.join(SystemRoot, 'System32')); extras.push(SystemRoot); }
23
+ } else {
24
+ extras.push(
25
+ path.join(home, '.local', 'bin'),
26
+ path.join(home, 'bin'),
27
+ path.join(home, '.npm-global', 'bin'),
28
+ path.join(home, '.bun', 'bin'),
29
+ path.join(home, '.cargo', 'bin'),
30
+ '/opt/homebrew/bin',
31
+ '/usr/local/bin',
32
+ '/usr/bin', '/bin', '/usr/sbin', '/sbin',
33
+ );
34
+ }
24
35
  const seen = new Set();
25
36
  const parts = [...extras, ...String(process.env.PATH || '').split(path.delimiter)]
26
37
  .filter((p) => p && !seen.has(p) && seen.add(p));