@claw-link/gateway-host 0.2.5 → 0.2.7

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
@@ -81,6 +81,7 @@ After install the `clhost` command is available (aliases: `gateway-host`, `claw-
81
81
  clhost install # install the background service (one-time)
82
82
  clhost add-agent # add an agent by pasting its Host Token
83
83
  clhost rm-agent <id># remove a mapped agent
84
+ clhost retoken <id> # re-enter a new token after rotating (keeps workspace/binary)
84
85
  clhost agents # list mapped agents
85
86
  clhost status # bridge + service state + mapped agents
86
87
  clhost start # start the background service
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.5",
3
+ "version": "0.2.7",
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;
@@ -339,6 +382,6 @@ function status() {
339
382
  }
340
383
 
341
384
  module.exports = {
342
- run, installOnly, addAgentCmd, removeAgentCmd, listAgents, status,
385
+ run, installOnly, addAgentCmd, removeAgentCmd, retokenCmd, listAgents, status,
343
386
  serviceControl, serviceControlCmd, installService, SERVICE_LABEL,
344
387
  };
@@ -4,6 +4,7 @@
4
4
  // The operator can override binary + args_template in config.
5
5
 
6
6
  const fs = require('fs');
7
+ const path = require('path');
7
8
  const { spawnStreaming, buildArgv } = require('./base');
8
9
  const sessionStore = require('../session-store');
9
10
 
@@ -46,8 +47,42 @@ function makeStreamJsonParser(capture) {
46
47
  };
47
48
  }
48
49
 
50
+ // Codex `exec --json` JSONL events. The assistant's text is in
51
+ // item.completed → item.type 'agent_message' → item.text (there can be several:
52
+ // narration + final). thread.started.thread_id is the resume id. command/file/mcp
53
+ // items become tool/log lines for the activity log.
54
+ function makeCodexJsonParser(capture) {
55
+ return function parse(line) {
56
+ const s = line.trim();
57
+ if (!s || s[0] !== '{') return []; // skip non-JSON (e.g. "Reading additional input…")
58
+ let ev;
59
+ try { ev = JSON.parse(s); } catch { return []; }
60
+ const out = [];
61
+ if (ev.type === 'thread.started') {
62
+ if (ev.thread_id && capture) capture.sessionId = ev.thread_id;
63
+ } else if (ev.type === 'item.completed' && ev.item) {
64
+ const it = ev.item;
65
+ if (it.type === 'agent_message' && typeof it.text === 'string') {
66
+ out.push({ type: 'chunk', content: (capture && capture.sawText ? '\n\n' : '') + it.text });
67
+ if (capture) capture.sawText = true;
68
+ } else if (it.type === 'command_execution' && it.command) {
69
+ out.push({ type: 'tool', content: `run: ${String(it.command).replace(/^\/bin\/\w+ -lc /, '').slice(0, 80)}` });
70
+ } else if (it.type === 'file_change' && Array.isArray(it.changes)) {
71
+ out.push({ type: 'tool', content: it.changes.map((c) => `${c.kind || 'edit'} ${path.basename(c.path || '')}`).join(', ') || 'file_change' });
72
+ } else if (it.type === 'mcp_tool_call') {
73
+ out.push({ type: 'tool', content: it.tool || it.name || 'mcp' });
74
+ }
75
+ } else if (ev.type === 'turn.failed') {
76
+ const m = (ev.error && (ev.error.message || ev.error)) || 'turn failed';
77
+ out.push({ type: 'log', content: `error: ${String(m).slice(0, 200)}` });
78
+ }
79
+ return out;
80
+ };
81
+ }
82
+
49
83
  /**
50
- * @param cfg { name, binaries:[], defaultArgs(fullPrompt, resumeId):[], streamJson?:bool,
84
+ * @param cfg { name, binaries:[], defaultArgs(fullPrompt, resumeId, promptViaStdin, permArgs):[],
85
+ * streamJson?:bool, makeParser?:(capture)=>parseFn, permissions?:(scope)=>[],
51
86
  * promptViaStdin?:bool, timeoutMs?:number }
52
87
  */
53
88
  function makeCliAdapter(cfg) {
@@ -90,7 +125,9 @@ function makeCliAdapter(cfg) {
90
125
  let emittedAny = false;
91
126
  const runOnce = async function* (rid) {
92
127
  const capture = {};
93
- const parseLine = cfg.streamJson ? makeStreamJsonParser(capture) : genericLineParser;
128
+ const parseLine = cfg.makeParser ? cfg.makeParser(capture)
129
+ : cfg.streamJson ? makeStreamJsonParser(capture)
130
+ : genericLineParser;
94
131
  for await (const evt of spawnStreaming({
95
132
  binary, argv: argvFor(rid), cwd: agent.work_dir, timeoutMs,
96
133
  stdinInput: cfg.promptViaStdin ? fullPrompt : null, parseLine,
@@ -122,4 +159,4 @@ function makeCliAdapter(cfg) {
122
159
  };
123
160
  }
124
161
 
125
- module.exports = { makeCliAdapter, genericLineParser, makeStreamJsonParser };
162
+ module.exports = { makeCliAdapter, genericLineParser, makeStreamJsonParser, makeCodexJsonParser };
@@ -1,22 +1,33 @@
1
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');
2
+ // OpenAI Codex CLI (`codex exec`), non-interactive. Verified against codex-cli
3
+ // 0.142.x. Notes that drove this implementation:
4
+ // - `exec` has NO `--ask-for-approval` flag (passing it = exit code 2). Approval is
5
+ // set via the `-c approval_policy=...` config override; in exec it never prompts.
6
+ // - Sandbox: `exec` accepts `-s/--sandbox`, but `exec resume` does NOT — so we set
7
+ // the sandbox via `-c sandbox_mode=...`, which BOTH accept (uniform args).
8
+ // - `--skip-git-repo-check` so a non-git workspace doesn't fail.
9
+ // - `--json` emits JSONL events; the answer is item.completed→agent_message→text.
10
+ // - Resume is a SUBCOMMAND: `codex exec resume [OPTIONS] <SESSION_ID> -- <prompt>`,
11
+ // and the session id is thread.started.thread_id from the previous run.
12
+ const { makeCliAdapter, makeCodexJsonParser } = require('./cli');
5
13
 
6
14
  module.exports = makeCliAdapter({
7
15
  name: 'codex',
8
16
  binaries: ['codex'],
9
17
  promptViaStdin: false,
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'],
18
+ makeParser: makeCodexJsonParser,
19
+ defaultArgs: (fullPrompt, resumeId, _stdin, perm = []) => {
20
+ const opts = ['--json', '--skip-git-repo-check', ...perm];
21
+ // `--` ends option parsing so a prompt starting with '-' is positional, never a flag.
22
+ return resumeId
23
+ ? ['exec', 'resume', ...opts, resumeId, '--', fullPrompt]
24
+ : ['exec', ...opts, '--', fullPrompt];
25
+ },
26
+ // Admin "configure" → workspace-write (may write its work dir); customer/discovery
27
+ // read-only. approval_policy=never so it never blocks on an approval prompt.
28
+ // Set via `-c` config overrides (the only form `exec resume` also accepts).
29
+ permissions: (scope) => {
30
+ const mode = (scope === 'configure' || scope === 'sub_configure') ? 'workspace-write' : 'read-only';
31
+ return ['-c', `sandbox_mode="${mode}"`, '-c', 'approval_policy="never"'];
32
+ },
22
33
  });