@claw-link/gateway-host 0.3.10 → 0.3.11

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/bin/cli.js CHANGED
@@ -36,6 +36,15 @@ async function main() {
36
36
  case 'model':
37
37
  return require('../scripts/install').setModelCmd();
38
38
 
39
+ case 'set-permission':
40
+ case 'set-perm':
41
+ case 'permission':
42
+ return require('../scripts/install').setPermissionCmd();
43
+
44
+ case 'set-concurrency':
45
+ case 'concurrency':
46
+ return require('../scripts/install').setConcurrencyCmd();
47
+
39
48
  case 'agents':
40
49
  case 'list':
41
50
  return require('../scripts/install').listAgents();
@@ -101,6 +110,8 @@ async function main() {
101
110
  ' rm-agent Remove a mapped agent (clhost rm-agent <id>)',
102
111
  ' retoken Re-enter a new Host Token for an existing agent (clhost retoken <id>)',
103
112
  ' set-model Set the model an agent’s runtime uses (clhost set-model <id> <model>)',
113
+ ' set-permission Permission profile for writable turns (clhost set-permission <id> trusted|standard)',
114
+ ' set-concurrency Parallel jobs for an agent, isolated per-job dirs (clhost set-concurrency <id> <n>)',
104
115
  ' agents List mapped agents',
105
116
  ' status Show bridge, service state, and mapped agents',
106
117
  ' doctor Diagnose config, runtime binaries, bridge + token/machine binding',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claw-link/gateway-host",
3
- "version": "0.3.10",
3
+ "version": "0.3.11",
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": {
@@ -226,7 +226,7 @@ function printAgents(cfg) {
226
226
  for (const a of agents) {
227
227
  const id = a.agent_id || '(unresolved)';
228
228
  const tok = a.host_token ? `…${String(a.host_token).slice(-4)}` : '—';
229
- console.log(` • ${id} runtime=${a.runtime || 'pull'} token=${tok}` + (a.model ? ` model=${a.model}` : '') + (a.work_dir ? ` work=${a.work_dir}` : ''));
229
+ console.log(` • ${id} runtime=${a.runtime || 'pull'} token=${tok}` + (a.model ? ` model=${a.model}` : '') + (a.permission_profile ? ` perm=${a.permission_profile}` : '') + (Number(a.concurrency) > 1 ? ` conc=${a.concurrency}` : '') + (a.work_dir ? ` work=${a.work_dir}` : ''));
230
230
  }
231
231
  // Show a concrete, copy-pasteable example (commands also accept any id prefix).
232
232
  const example = agents[0].agent_id || '<id>';
@@ -310,6 +310,61 @@ async function setModelCmd() {
310
310
  console.log(` ✓ ${clear ? `Cleared model for ${agent.agent_id} — using the ${agent.runtime} default.` : `Set ${agent.agent_id} model → ${modelArg} (${agent.runtime}).`}` + note);
311
311
  }
312
312
 
313
+ // `set-permission <id-or-prefix> <trusted|standard>` — set the agent's permission profile for
314
+ // WRITABLE (configure/harness) turns. 'trusted' lets the runtime run shell commands without
315
+ // prompts (claude bypassPermissions / codex danger-full-access) — required for dev/infra/CI
316
+ // agents that npm/mvn/docker/git. Customer chats stay read-only regardless. Restarts to apply.
317
+ async function setPermissionCmd() {
318
+ const cfg = config.load();
319
+ if (!cfg || !(cfg.agents || []).length) { console.log('No agents configured. Run: clhost add-agent'); return; }
320
+ const target = (process.argv[3] || '').trim();
321
+ const profile = (process.argv[4] || '').trim().toLowerCase();
322
+ if (!target || !['trusted', 'standard'].includes(profile)) {
323
+ console.log('Usage: clhost set-permission <agent-id|prefix> <trusted|standard>');
324
+ console.log(' trusted Writable turns run shell without prompts (dev/infra/CI agents).');
325
+ console.log(' standard Default: writable turns may edit files only; no unprompted shell.\n');
326
+ printAgents(cfg);
327
+ return;
328
+ }
329
+ const agent = cfg.agents.find((a) => a.agent_id && (a.agent_id === target || a.agent_id.startsWith(target)));
330
+ if (!agent) { console.log(` No agent matched '${target}'.`); return; }
331
+ agent.permission_profile = profile === 'trusted' ? 'trusted' : null;
332
+ config.save(cfg);
333
+ if (profile === 'trusted') {
334
+ console.log(' ⚠ trusted = this agent can run ANY shell command on this machine during harness/');
335
+ console.log(' configure turns. Only use for agents whose workspaces + briefs you control.');
336
+ }
337
+ const r = await gracefulServiceRestartCLI();
338
+ const note = !r.restarted ? ' Run `clhost restart` to apply.'
339
+ : r.confirmed ? ' Service restarted.' : ' Restarting — check `clhost status`.';
340
+ console.log(` ✓ ${agent.agent_id} permission profile → ${profile}.` + note);
341
+ }
342
+
343
+ // `set-concurrency <id-or-prefix> <n>` — how many jobs this agent runs IN PARALLEL. With n > 1,
344
+ // each job gets an isolated working dir (work_dir/.jobs/<job-id>) so parallel runs can't trample
345
+ // each other; skill files (CLAUDE.md/AGENTS.md etc.) still apply via the parent directory.
346
+ // Scale dev agents in a harness by pointing MULTIPLE nodes at the same agent + raising this.
347
+ async function setConcurrencyCmd() {
348
+ const cfg = config.load();
349
+ if (!cfg || !(cfg.agents || []).length) { console.log('No agents configured. Run: clhost add-agent'); return; }
350
+ const target = (process.argv[3] || '').trim();
351
+ const n = Number((process.argv[4] || '').trim());
352
+ if (!target || !Number.isInteger(n) || n < 1 || n > 16) {
353
+ console.log('Usage: clhost set-concurrency <agent-id|prefix> <1-16>');
354
+ console.log(' n parallel jobs for this agent. n>1 isolates each job in work_dir/.jobs/<id>.\n');
355
+ printAgents(cfg);
356
+ return;
357
+ }
358
+ const agent = cfg.agents.find((a) => a.agent_id && (a.agent_id === target || a.agent_id.startsWith(target)));
359
+ if (!agent) { console.log(` No agent matched '${target}'.`); return; }
360
+ agent.concurrency = n;
361
+ config.save(cfg);
362
+ const r = await gracefulServiceRestartCLI();
363
+ const note = !r.restarted ? ' Run `clhost restart` to apply.'
364
+ : r.confirmed ? ' Service restarted.' : ' Restarting — check `clhost status`.';
365
+ console.log(` ✓ ${agent.agent_id} concurrency → ${n}${n > 1 ? ' (per-job isolated working dirs)' : ''}.` + note);
366
+ }
367
+
313
368
  // `start | stop | restart` — control the installed background service.
314
369
  function serviceControl(action) {
315
370
  const platform = process.platform;
@@ -739,7 +794,7 @@ async function update() {
739
794
  }
740
795
 
741
796
  module.exports = {
742
- run, installOnly, addAgentCmd, removeAgentCmd, retokenCmd, setModelCmd, listAgents, status,
797
+ run, installOnly, addAgentCmd, removeAgentCmd, retokenCmd, setModelCmd, setPermissionCmd, setConcurrencyCmd, listAgents, status,
743
798
  serviceControl, serviceControlCmd, installService, SERVICE_LABEL,
744
799
  versionCmd, doctor, logs, update,
745
800
  };
@@ -21,8 +21,13 @@ module.exports = makeCliAdapter({
21
21
  // Admin "configure" turns may write workspace files (auto-accept edits, no shell
22
22
  // prompt); customer/discovery turns are strictly read-only (no Write/Edit/Bash) so
23
23
  // they can never modify the agent's config or hang on a permission prompt.
24
- permissions: (scope) =>
24
+ // 'trusted' profile (clhost set-permission <id> trusted) upgrades WRITABLE turns to
25
+ // bypassPermissions so dev/infra agents can run shell (npm/mvn/docker/git) without
26
+ // prompts — customer turns stay read-only regardless.
27
+ permissions: (scope, profile) =>
25
28
  (scope === 'configure' || scope === 'sub_configure')
26
- ? ['--permission-mode', 'acceptEdits']
29
+ ? (profile === 'trusted'
30
+ ? ['--permission-mode', 'bypassPermissions']
31
+ : ['--permission-mode', 'acceptEdits'])
27
32
  : ['--disallowedTools', 'Write Edit MultiEdit NotebookEdit Bash'],
28
33
  });
@@ -113,9 +113,26 @@ function makeCodexJsonParser(capture) {
113
113
  };
114
114
  }
115
115
 
116
+ // Best-effort cleanup of per-job working dirs older than 48h (parallel workers create one per job).
117
+ const JOB_DIR_TTL_MS = 48 * 60 * 60 * 1000;
118
+ let lastPruneAt = 0;
119
+ function pruneJobDirs(jobsRoot, logger) {
120
+ const now = Date.now();
121
+ if (now - lastPruneAt < 10 * 60 * 1000) return; // at most every 10 min across all agents
122
+ lastPruneAt = now;
123
+ try {
124
+ for (const name of fs.readdirSync(jobsRoot)) {
125
+ const p = path.join(jobsRoot, name);
126
+ try {
127
+ if (now - fs.statSync(p).mtimeMs > JOB_DIR_TTL_MS) fs.rmSync(p, { recursive: true, force: true });
128
+ } catch { /* skip entry */ }
129
+ }
130
+ } catch { /* no .jobs dir yet */ }
131
+ }
132
+
116
133
  /**
117
134
  * @param cfg { name, binaries:[], defaultArgs(fullPrompt, resumeId, promptViaStdin, permArgs, model):[],
118
- * streamJson?:bool, makeParser?:(capture)=>parseFn, permissions?:(scope)=>[],
135
+ * streamJson?:bool, makeParser?:(capture)=>parseFn, permissions?:(scope, profile)=>[],
119
136
  * promptViaStdin?:bool, timeoutMs?:number }
120
137
  */
121
138
  function makeCliAdapter(cfg) {
@@ -136,9 +153,21 @@ function makeCliAdapter(cfg) {
136
153
  // the spawn never fail with ENOENT (coding workspaces are validated at setup).
137
154
  try { fs.mkdirSync(agent.work_dir, { recursive: true }); } catch { /* ignore */ }
138
155
 
139
- // Download any attachments into the workspace and reference their LOCAL paths, so the CLI
156
+ // Parallel workers: when the agent runs jobs CONCURRENTLY (concurrency > 1), each job gets an
157
+ // ISOLATED working dir under .jobs/<job-id> so two runs never trample each other's files
158
+ // (git index, node_modules, build output). Claude/Codex read CLAUDE.md / AGENTS.md up the
159
+ // directory tree, so the workspace skill files still apply. Serial agents keep the shared
160
+ // work_dir (cache warmth). Old job dirs are pruned best-effort after 48h.
161
+ let runDir = agent.work_dir;
162
+ if (Number(agent.concurrency) > 1) {
163
+ runDir = path.join(agent.work_dir, '.jobs', String(job.id).replace(/[^a-zA-Z0-9-]/g, '').slice(0, 12) || 'job');
164
+ try { fs.mkdirSync(runDir, { recursive: true }); } catch { /* fall back to shared dir */ }
165
+ pruneJobDirs(path.join(agent.work_dir, '.jobs'), logger);
166
+ }
167
+
168
+ // Download any attachments into the working dir and reference their LOCAL paths, so the CLI
140
169
  // (which can't reach a URL) can open them via its Read tool.
141
- const attBlock = await localizeAttachments(job.attachments, agent.work_dir, logger);
170
+ const attBlock = await localizeAttachments(job.attachments, runDir, logger);
142
171
  const base = systemPrompt ? `${systemPrompt}\n\n---\n\nUser request:\n${userPrompt}` : userPrompt;
143
172
  const fullPrompt = base + attBlock;
144
173
 
@@ -151,8 +180,10 @@ function makeCliAdapter(cfg) {
151
180
  // workspace-write like an admin "configure" turn — even though they're carried on the
152
181
  // 'customer' scope. Real customer chats stay read-only.
153
182
  const writable = !!job.harness || job.scope === 'configure' || job.scope === 'sub_configure';
183
+ // 'trusted' profile only ever applies to WRITABLE turns (harness/configure) — customer
184
+ // chats stay read-only regardless, so a public conversation can never run shell commands.
154
185
  const permArgs = (!Array.isArray(agent.args_template) && cfg.permissions)
155
- ? cfg.permissions(writable ? 'configure' : (job.scope || 'customer'))
186
+ ? cfg.permissions(writable ? 'configure' : (job.scope || 'customer'), agent.permission_profile || null)
156
187
  : [];
157
188
  // Optional per-agent model override (e.g. `clhost set-model <id> sonnet`). Each adapter
158
189
  // places it with the runtime's correct flag; ignored when an args_template takes over.
@@ -168,7 +199,7 @@ function makeCliAdapter(cfg) {
168
199
  : cfg.streamJson ? makeStreamJsonParser(capture)
169
200
  : genericLineParser;
170
201
  for await (const evt of spawnStreaming({
171
- binary, argv: argvFor(rid), cwd: agent.work_dir, timeoutMs,
202
+ binary, argv: argvFor(rid), cwd: runDir, timeoutMs,
172
203
  stdinInput: cfg.promptViaStdin ? fullPrompt : null, parseLine, signal,
173
204
  })) {
174
205
  if (evt.type === 'chunk') emittedAny = true;
@@ -29,8 +29,11 @@ module.exports = makeCliAdapter({
29
29
  // Admin "configure" → workspace-write (may write its work dir); customer/discovery
30
30
  // → read-only. approval_policy=never so it never blocks on an approval prompt.
31
31
  // Set via `-c` config overrides (the only form `exec resume` also accepts).
32
- permissions: (scope) => {
33
- const mode = (scope === 'configure' || scope === 'sub_configure') ? 'workspace-write' : 'read-only';
32
+ // 'trusted' profile upgrades WRITABLE turns to danger-full-access (network + full fs) so
33
+ // dev agents can npm/mvn/docker customer turns stay read-only regardless.
34
+ permissions: (scope, profile) => {
35
+ const writable = scope === 'configure' || scope === 'sub_configure';
36
+ const mode = writable ? (profile === 'trusted' ? 'danger-full-access' : 'workspace-write') : 'read-only';
34
37
  return ['-c', `sandbox_mode="${mode}"`, '-c', 'approval_policy="never"'];
35
38
  },
36
39
  });
package/src/config.js CHANGED
@@ -74,6 +74,11 @@ function normalizeAgent(a) {
74
74
  // Optional model override for coding runtimes (claude/codex/cursor/hermes). null = the
75
75
  // runtime's own default. Set with `clhost set-model <id> <model>`.
76
76
  model: a.model || null,
77
+ // Permission profile for writable (configure/harness) turns: 'trusted' lets the runtime run
78
+ // shell commands without prompts (claude --permission-mode bypassPermissions, codex
79
+ // danger-full-access) — needed for dev/infra/CI agents that npm/mvn/docker/git. Customer
80
+ // chats stay read-only REGARDLESS of profile. Set with `clhost set-permission <id> trusted`.
81
+ permission_profile: a.permission_profile === 'trusted' ? 'trusted' : null,
77
82
  args_template: Array.isArray(a.args_template) ? a.args_template : null,
78
83
  local_gateway_url: a.local_gateway_url || null,
79
84
  local_gateway_token: a.local_gateway_token || null,
package/src/worker.js CHANGED
@@ -159,6 +159,9 @@ async function runAgentLoop(agentCfg, cfg, p2p) {
159
159
  let inFlight = 0;
160
160
  const starts = []; // job start timestamps for the rolling rate-limit window
161
161
  let claimFails = 0; // consecutive claim() errors → exponential backoff w/ jitter (transient 5xx)
162
+ // Hard job ceiling: config `job_max_ms` overrides the env/default — long dev/build turns
163
+ // (harness agents running mvn/npm/docker) can legitimately exceed the 15-min default.
164
+ const jobMaxMs = Number(cfg.job_max_ms) > 0 ? Number(cfg.job_max_ms) : JOB_MAX_MS;
162
165
 
163
166
  while (!stopping) {
164
167
  const now = Date.now();
@@ -178,12 +181,12 @@ async function runAgentLoop(agentCfg, cfg, p2p) {
178
181
  inFlight++; starts.push(Date.now()); activeJobIds.add(job.id);
179
182
  // Watchdog: guarantees the slot is reclaimed and the job is failed back even
180
183
  // if the adapter never settles (no per-adapter timeout by default).
181
- const watchdog = new Promise((resolve) => setTimeout(() => resolve('__watchdog__'), JOB_MAX_MS).unref());
184
+ const watchdog = new Promise((resolve) => setTimeout(() => resolve('__watchdog__'), jobMaxMs).unref());
182
185
  Promise.race([processJob(bridge, agentCfg, job, p2p), watchdog])
183
186
  .then(async (r) => {
184
187
  if (r === '__watchdog__') {
185
- logger.error(`job ${job.id} exceeded ${JOB_MAX_MS}ms — reclaiming slot`);
186
- try { await bridge.fail(job.id, `host watchdog: job exceeded ${JOB_MAX_MS}ms`); } catch { /* ignore */ }
188
+ logger.error(`job ${job.id} exceeded ${jobMaxMs}ms — reclaiming slot`);
189
+ try { await bridge.fail(job.id, `host watchdog: job exceeded ${jobMaxMs}ms`); } catch { /* ignore */ }
187
190
  }
188
191
  })
189
192
  .finally(() => { inFlight--; activeJobIds.delete(job.id); });