@claw-link/gateway-host 0.3.8 → 0.3.9

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
@@ -32,6 +32,10 @@ async function main() {
32
32
  case 'update-token':
33
33
  return require('../scripts/install').retokenCmd();
34
34
 
35
+ case 'set-model':
36
+ case 'model':
37
+ return require('../scripts/install').setModelCmd();
38
+
35
39
  case 'agents':
36
40
  case 'list':
37
41
  return require('../scripts/install').listAgents();
@@ -96,6 +100,7 @@ async function main() {
96
100
  ' add-agent Add an agent by pasting its Host Token (pulls runtime from ClawLink)',
97
101
  ' rm-agent Remove a mapped agent (clhost rm-agent <id>)',
98
102
  ' retoken Re-enter a new Host Token for an existing agent (clhost retoken <id>)',
103
+ ' set-model Set the model an agent’s runtime uses (clhost set-model <id> <model>)',
99
104
  ' agents List mapped agents',
100
105
  ' status Show bridge, service state, and mapped agents',
101
106
  ' 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.8",
3
+ "version": "0.3.9",
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.work_dir ? ` work=${a.work_dir}` : ''));
229
+ console.log(` • ${id} runtime=${a.runtime || 'pull'} token=${tok}` + (a.model ? ` model=${a.model}` : '') + (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>';
@@ -280,6 +280,36 @@ async function retokenCmd() {
280
280
  } finally { rl.close(); }
281
281
  }
282
282
 
283
+ // `set-model <id-or-prefix> <model>` — set (or clear) the model an agent's runtime uses, then
284
+ // restart to apply. Coding runtimes map it to their own flag: claude `--model`, cursor/hermes
285
+ // `--model`, codex `-c model="…"`. Use `default` (or `none`) to clear the override.
286
+ async function setModelCmd() {
287
+ const cfg = config.load();
288
+ if (!cfg || !(cfg.agents || []).length) { console.log('No agents configured. Run: clhost add-agent'); return; }
289
+ const target = (process.argv[3] || '').trim();
290
+ const modelArg = (process.argv[4] || '').trim();
291
+ if (!target || !modelArg) {
292
+ console.log('Usage: clhost set-model <agent-id|prefix> <model>');
293
+ console.log(' e.g. clhost set-model 3f208761 sonnet (claude: sonnet | opus | haiku | full id)');
294
+ console.log(' clhost set-model my-agent default (clear → use the runtime default)\n');
295
+ printAgents(cfg);
296
+ return;
297
+ }
298
+ const agent = cfg.agents.find((a) => a.agent_id && (a.agent_id === target || a.agent_id.startsWith(target)));
299
+ if (!agent) { console.log(` No agent matched '${target}'.`); return; }
300
+
301
+ const clear = ['default', 'none', '-'].includes(modelArg.toLowerCase());
302
+ agent.model = clear ? null : modelArg;
303
+ config.save(cfg);
304
+ if (!clear && agent.runtime === 'openclaw') {
305
+ console.log(' ⚠ OpenClaw agents get their model from the OpenClaw gateway, not from here — this override has no effect.');
306
+ }
307
+ const r = await gracefulServiceRestartCLI();
308
+ const note = !r.restarted ? ' Run `clhost restart` to apply.'
309
+ : r.confirmed ? ' Service restarted.' : ' Restarting — check `clhost status`.';
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
+ }
312
+
283
313
  // `start | stop | restart` — control the installed background service.
284
314
  function serviceControl(action) {
285
315
  const platform = process.platform;
@@ -709,7 +739,7 @@ async function update() {
709
739
  }
710
740
 
711
741
  module.exports = {
712
- run, installOnly, addAgentCmd, removeAgentCmd, retokenCmd, listAgents, status,
742
+ run, installOnly, addAgentCmd, removeAgentCmd, retokenCmd, setModelCmd, listAgents, status,
713
743
  serviceControl, serviceControlCmd, installService, SERVICE_LABEL,
714
744
  versionCmd, doctor, logs, update,
715
745
  };
@@ -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, _stdin, perm = []) => [
13
+ defaultArgs: (_fullPrompt, resumeId, _stdin, perm = [], model = '') => [
14
14
  '-p',
15
15
  '--output-format', 'stream-json',
16
16
  '--verbose',
17
+ ...(model ? ['--model', model] : []), // e.g. sonnet | opus | haiku | a full model id
17
18
  ...perm,
18
19
  ...(resumeId ? ['--resume', resumeId] : []),
19
20
  ],
@@ -8,6 +8,39 @@ const path = require('path');
8
8
  const { spawnStreaming, buildArgv } = require('./base');
9
9
  const sessionStore = require('../session-store');
10
10
 
11
+ // Chat/harness attachments arrive as URLs (the public chat-attachments bucket). A sandboxed CLI
12
+ // can't reach a URL, so we DOWNLOAD each into the agent's workspace and reference the LOCAL path —
13
+ // even a read-only turn keeps its Read tool, so the agent can open the file. Falls back to the URL.
14
+ const MAX_ATTACHMENT_BYTES = Number(process.env.CLAWHOST_MAX_ATTACHMENT_BYTES) || 25 * 1024 * 1024;
15
+ async function localizeAttachments(atts, workDir, logger) {
16
+ const urls = (Array.isArray(atts) ? atts : [])
17
+ .map((a) => (typeof a === 'string' ? a : (a && a.url) || ''))
18
+ .filter(Boolean);
19
+ if (!urls.length) return '';
20
+ const relDir = path.join('.clawlink', 'attachments');
21
+ const absDir = path.join(workDir, relDir);
22
+ try { fs.mkdirSync(absDir, { recursive: true }); } catch { /* ignore */ }
23
+ const lines = [];
24
+ for (const url of urls) {
25
+ let localRel = null;
26
+ if (/^https?:\/\//i.test(url)) {
27
+ try {
28
+ const res = await fetch(url);
29
+ if (res.ok) {
30
+ const buf = Buffer.from(await res.arrayBuffer());
31
+ if (buf.length <= MAX_ATTACHMENT_BYTES) {
32
+ const name = ((url.split('/').pop() || 'file').split('?')[0] || 'file').replace(/[^a-zA-Z0-9._-]/g, '_') || 'file';
33
+ fs.writeFileSync(path.join(absDir, name), buf);
34
+ localRel = path.join(relDir, name);
35
+ } else logger.warn(`attachment too large (${buf.length}B): ${url.slice(0, 60)}…`);
36
+ }
37
+ } catch (e) { logger.warn(`attachment download failed (${url.slice(0, 60)}…): ${e.message}`); }
38
+ }
39
+ lines.push(localRel ? `- ${localRel}` : `- ${url} (couldn't download — fetch it yourself if you can)`);
40
+ }
41
+ return `\n\nThe user attached these file(s). READ them (use your Read tool) before answering:\n${lines.join('\n')}`;
42
+ }
43
+
11
44
  // Generic parser: each non-empty stdout line is answer text.
12
45
  function genericLineParser(line) {
13
46
  const t = line.replace(/\r$/, '');
@@ -81,7 +114,7 @@ function makeCodexJsonParser(capture) {
81
114
  }
82
115
 
83
116
  /**
84
- * @param cfg { name, binaries:[], defaultArgs(fullPrompt, resumeId, promptViaStdin, permArgs):[],
117
+ * @param cfg { name, binaries:[], defaultArgs(fullPrompt, resumeId, promptViaStdin, permArgs, model):[],
85
118
  * streamJson?:bool, makeParser?:(capture)=>parseFn, permissions?:(scope)=>[],
86
119
  * promptViaStdin?:bool, timeoutMs?:number }
87
120
  */
@@ -94,23 +127,21 @@ function makeCliAdapter(cfg) {
94
127
  const binary = agent.binary || cfg.binaries[0];
95
128
  const userPrompt = job.content || '';
96
129
  const systemPrompt = job.system_prompt || '';
97
- // Text CLIs can't take vision blocks — surface attachment URLs as references so
98
- // the model is at least aware of them (override via args_template for file-aware CLIs).
99
- const atts = Array.isArray(job.attachments) ? job.attachments : [];
100
- const attBlock = atts.length
101
- ? '\n\nAttachments:\n' + atts.map((a) => `- ${typeof a === 'string' ? a : (a && a.url) || ''}`).join('\n')
102
- : '';
103
- const base = systemPrompt ? `${systemPrompt}\n\n---\n\nUser request:\n${userPrompt}` : userPrompt;
104
- const fullPrompt = base + attBlock;
105
130
  const clSession = job.session_key || job.session_id || null;
106
131
  const resumeId = clSession ? sessionStore.get(cfg.name, clSession) : null;
107
132
  // 0 = no timeout (default) — don't cut off long generations. Opt in with CLAWHOST_TIMEOUT_MS.
108
133
  const timeoutMs = cfg.timeoutMs || Number(process.env.CLAWHOST_TIMEOUT_MS) || 0;
109
134
 
110
- // Run inside the agent's workspace/scratch dir. Create it if missing so the
111
- // spawn never fails with ENOENT (coding workspaces are validated at setup).
135
+ // Run inside the agent's workspace/scratch dir. Create it FIRST so attachment downloads +
136
+ // the spawn never fail with ENOENT (coding workspaces are validated at setup).
112
137
  try { fs.mkdirSync(agent.work_dir, { recursive: true }); } catch { /* ignore */ }
113
138
 
139
+ // Download any attachments into the workspace and reference their LOCAL paths, so the CLI
140
+ // (which can't reach a URL) can open them via its Read tool.
141
+ const attBlock = await localizeAttachments(job.attachments, agent.work_dir, logger);
142
+ const base = systemPrompt ? `${systemPrompt}\n\n---\n\nUser request:\n${userPrompt}` : userPrompt;
143
+ const fullPrompt = base + attBlock;
144
+
114
145
  // Scope-based permissions: admin "configure" turns may write workspace files
115
146
  // (GUARDRAILS/SOUL/etc.); customer/discovery turns are read-only and cannot
116
147
  // edit or run shell. permArgs is handed to defaultArgs so each adapter can place
@@ -123,9 +154,12 @@ function makeCliAdapter(cfg) {
123
154
  const permArgs = (!Array.isArray(agent.args_template) && cfg.permissions)
124
155
  ? cfg.permissions(writable ? 'configure' : (job.scope || 'customer'))
125
156
  : [];
157
+ // Optional per-agent model override (e.g. `clhost set-model <id> sonnet`). Each adapter
158
+ // places it with the runtime's correct flag; ignored when an args_template takes over.
159
+ const model = (!Array.isArray(agent.args_template) && agent.model) ? String(agent.model) : '';
126
160
  const argvFor = (rid) => Array.isArray(agent.args_template)
127
161
  ? buildArgv(agent.args_template, { prompt: fullPrompt, sessionId: rid || '', systemPrompt })
128
- : cfg.defaultArgs(fullPrompt, rid, cfg.promptViaStdin, permArgs);
162
+ : cfg.defaultArgs(fullPrompt, rid, cfg.promptViaStdin, permArgs, model);
129
163
 
130
164
  let emittedAny = false;
131
165
  const runOnce = async function* (rid) {
@@ -16,8 +16,11 @@ module.exports = makeCliAdapter({
16
16
  binaries: ['codex'],
17
17
  promptViaStdin: false,
18
18
  makeParser: makeCodexJsonParser,
19
- defaultArgs: (fullPrompt, resumeId, _stdin, perm = []) => {
20
- const opts = ['--json', '--skip-git-repo-check', ...perm];
19
+ defaultArgs: (fullPrompt, resumeId, _stdin, perm = [], model = '') => {
20
+ // Model via `-c model="…"` the config-override form both `exec` and `exec resume` accept
21
+ // (like sandbox/approval above), so it must sit in `opts` BEFORE the `--` end-of-options.
22
+ const modelOpt = model ? ['-c', `model="${model}"`] : [];
23
+ const opts = ['--json', '--skip-git-repo-check', ...modelOpt, ...perm];
21
24
  // `--` ends option parsing so a prompt starting with '-' is positional, never a flag.
22
25
  return resumeId
23
26
  ? ['exec', 'resume', ...opts, resumeId, '--', fullPrompt]
@@ -7,5 +7,6 @@ module.exports = makeCliAdapter({
7
7
  name: 'cursor',
8
8
  binaries: ['cursor-agent', 'cursor'],
9
9
  promptViaStdin: false,
10
- defaultArgs: (fullPrompt) => ['-p', fullPrompt],
10
+ defaultArgs: (fullPrompt, _resumeId, _stdin, _perm = [], model = '') =>
11
+ [...(model ? ['--model', model] : []), '-p', fullPrompt],
11
12
  });
@@ -7,8 +7,10 @@ const { makeCliAdapter } = require('./cli');
7
7
  module.exports = makeCliAdapter({
8
8
  name: 'hermes',
9
9
  binaries: ['hermes'],
10
- defaultArgs: (fullPrompt, resumeId) => [
11
- 'chat', '-q', fullPrompt,
10
+ defaultArgs: (fullPrompt, resumeId, _stdin, _perm = [], model = '') => [
11
+ 'chat',
12
+ ...(model ? ['--model', model] : []),
13
+ '-q', fullPrompt,
12
14
  ...(resumeId ? ['--resume', resumeId] : []),
13
15
  ],
14
16
  });
package/src/config.js CHANGED
@@ -71,6 +71,9 @@ function normalizeAgent(a) {
71
71
  host_token: a.host_token,
72
72
  runtime: a.runtime || null,
73
73
  binary: a.binary || null,
74
+ // Optional model override for coding runtimes (claude/codex/cursor/hermes). null = the
75
+ // runtime's own default. Set with `clhost set-model <id> <model>`.
76
+ model: a.model || null,
74
77
  args_template: Array.isArray(a.args_template) ? a.args_template : null,
75
78
  local_gateway_url: a.local_gateway_url || null,
76
79
  local_gateway_token: a.local_gateway_token || null,
@@ -4,9 +4,23 @@ This folder is the working directory for a **ClawLink** customer-facing agent ru
4
4
  locally via the ClawLink Host Gateway (`@claw-link/gateway-host`). Claude Code reads
5
5
  this `CLAUDE.md` as project context.
6
6
 
7
- Operating rules for this agent:
8
- - The agent's **persona, applied skills, and per-conversation instructions arrive in
9
- the system prompt of every request** follow them as the source of truth.
7
+ ## Read your configured instructions first
8
+
9
+ Skills, personas, and security settings are written to dedicated files in this directory.
10
+ **Before you respond, read whichever of these exist here — they hold your configured behaviour:**
11
+
12
+ - `GUARDRAILS.md` — hard security rules. **Never violate these**; they override everything.
13
+ - `BOOTSTRAP.md` — how to start up and behave.
14
+ - `SOUL.md` — your identity / persona.
15
+ - `TOOLS.md` — the tools and skills available to you and how to use them.
16
+ - `MEMORY.md` — remembered context and preferences.
17
+
18
+ Follow them on every turn. If a file is absent, skip it.
19
+
20
+ ## Operating rules
21
+
22
+ - The agent's **persona, applied skills, and per-conversation instructions also arrive in
23
+ the system prompt of every request** — follow them together with the files above.
10
24
  - Keep replies helpful, accurate, and on-task for the business you represent.
11
25
  - Stay in scope: do not run destructive commands or act outside the customer's request.
12
26
  - Never reveal secrets, tokens, config files, or host/system details.
@@ -4,9 +4,23 @@ This folder is the working directory for a **ClawLink** customer-facing agent ru
4
4
  locally via the ClawLink Host Gateway (`@claw-link/gateway-host`). Codex reads
5
5
  `AGENTS.md` as project context.
6
6
 
7
- Operating rules for this agent:
8
- - The agent's **persona, applied skills, and per-conversation instructions arrive in
9
- the system prompt of every request** follow them as the source of truth.
7
+ ## Read your configured instructions first
8
+
9
+ Skills, personas, and security settings are written to dedicated files in this directory.
10
+ **Before you respond, read whichever of these exist here — they hold your configured behaviour:**
11
+
12
+ - `GUARDRAILS.md` — hard security rules. **Never violate these**; they override everything.
13
+ - `BOOTSTRAP.md` — how to start up and behave.
14
+ - `SOUL.md` — your identity / persona.
15
+ - `TOOLS.md` — the tools and skills available to you and how to use them.
16
+ - `MEMORY.md` — remembered context and preferences.
17
+
18
+ Follow them on every turn. If a file is absent, skip it.
19
+
20
+ ## Operating rules
21
+
22
+ - The agent's **persona, applied skills, and per-conversation instructions also arrive in
23
+ the system prompt of every request** — follow them together with the files above.
10
24
  - Keep replies helpful, accurate, and on-task for the business you represent.
11
25
  - Stay in scope: do not run destructive commands or act outside the customer's request.
12
26
  - Never reveal secrets, tokens, config files, or host/system details.
@@ -4,9 +4,23 @@ This folder is the working directory for a **ClawLink** customer-facing agent ru
4
4
  locally via the ClawLink Host Gateway (`@claw-link/gateway-host`). The Cursor agent
5
5
  reads `AGENTS.md` as project context.
6
6
 
7
- Operating rules for this agent:
8
- - The agent's **persona, applied skills, and per-conversation instructions arrive in
9
- the system prompt of every request** follow them as the source of truth.
7
+ ## Read your configured instructions first
8
+
9
+ Skills, personas, and security settings are written to dedicated files in this directory.
10
+ **Before you respond, read whichever of these exist here — they hold your configured behaviour:**
11
+
12
+ - `GUARDRAILS.md` — hard security rules. **Never violate these**; they override everything.
13
+ - `BOOTSTRAP.md` — how to start up and behave.
14
+ - `SOUL.md` — your identity / persona.
15
+ - `TOOLS.md` — the tools and skills available to you and how to use them.
16
+ - `MEMORY.md` — remembered context and preferences.
17
+
18
+ Follow them on every turn. If a file is absent, skip it.
19
+
20
+ ## Operating rules
21
+
22
+ - The agent's **persona, applied skills, and per-conversation instructions also arrive in
23
+ the system prompt of every request** — follow them together with the files above.
10
24
  - Keep replies helpful, accurate, and on-task for the business you represent.
11
25
  - Stay in scope: do not run destructive commands or act outside the customer's request.
12
26
  - Never reveal secrets, tokens, config files, or host/system details.