@claw-link/gateway-host 0.3.18 → 0.4.1
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/package.json +1 -1
- package/src/adapters/base.js +86 -4
- package/src/bridge.js +3 -0
- package/src/worker.js +27 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@claw-link/gateway-host",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
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": {
|
package/src/adapters/base.js
CHANGED
|
@@ -15,6 +15,71 @@ const { spawn } = require('child_process');
|
|
|
15
15
|
const logger = require('../logger');
|
|
16
16
|
const { enrichedEnv } = require('../paths');
|
|
17
17
|
|
|
18
|
+
// ── Claude session/usage-limit detection ─────────────────────────────────────
|
|
19
|
+
// When the CLI hits its subscription window it prints something like
|
|
20
|
+
// "You've hit your session limit · resets 1:20pm (Asia/Singapore)" (as output OR
|
|
21
|
+
// stderr) and exits non-zero — or exits 0 with the notice AS the whole answer.
|
|
22
|
+
// The worker turns a detection into a bridge `pause` so the run waits for the
|
|
23
|
+
// reset instead of failing.
|
|
24
|
+
//
|
|
25
|
+
// Precision matters: a false positive parks a legitimate reply for up to an hour.
|
|
26
|
+
// So we require the NOTICE PHRASING ("hit/reached your … limit", "limit reached",
|
|
27
|
+
// "…limit · resets"), not merely the topical words "rate limit" — an agent that
|
|
28
|
+
// mentions rate limits in its answer must not be mistaken for a hit.
|
|
29
|
+
const LIMIT_NOTICE_RE = /\b(?:hit|reached|exceeded)\s+(?:your\s+)?(?:\w+\s+){0,2}(?:session|usage|rate|message|token)\s+limit\b|\b(?:session|usage|rate|message|token)\s+limit\s+(?:reached|exceeded|hit)\b|\blimit\s*[·|]\s*resets?\b/i;
|
|
30
|
+
const RESET_RE = /\bresets?\b/i;
|
|
31
|
+
|
|
32
|
+
/** Parse "resets 1:20pm (Asia/Singapore)" → ISO timestamp of the NEXT such wall-clock
|
|
33
|
+
* time in that timezone (+2 min safety margin). Null when unparseable. */
|
|
34
|
+
function parseResetAt(text) {
|
|
35
|
+
const m = String(text || '').match(/resets?\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?(?:\s*\(([^)]+)\))?/i);
|
|
36
|
+
if (!m) return null;
|
|
37
|
+
let hour = parseInt(m[1], 10);
|
|
38
|
+
const minute = m[2] ? parseInt(m[2], 10) : 0;
|
|
39
|
+
const ampm = (m[3] || '').toLowerCase();
|
|
40
|
+
if (ampm === 'pm' && hour < 12) hour += 12;
|
|
41
|
+
if (ampm === 'am' && hour === 12) hour = 0;
|
|
42
|
+
if (!Number.isFinite(hour) || hour > 23 || minute > 59) return null;
|
|
43
|
+
const tz = (m[4] || '').trim();
|
|
44
|
+
try {
|
|
45
|
+
// Current wall-clock time in the target tz → minutes until the reset time.
|
|
46
|
+
const now = new Date();
|
|
47
|
+
const fmt = new Intl.DateTimeFormat('en-GB', {
|
|
48
|
+
...(tz ? { timeZone: tz } : {}), hour: '2-digit', minute: '2-digit', hour12: false,
|
|
49
|
+
});
|
|
50
|
+
const parts = fmt.formatToParts(now);
|
|
51
|
+
const curH = parseInt(parts.find((p) => p.type === 'hour')?.value ?? '0', 10);
|
|
52
|
+
const curM = parseInt(parts.find((p) => p.type === 'minute')?.value ?? '0', 10);
|
|
53
|
+
let delta = (hour * 60 + minute) - (curH * 60 + curM);
|
|
54
|
+
if (delta <= 0) delta += 24 * 60; // already past today → tomorrow
|
|
55
|
+
return new Date(now.getTime() + (delta + 2) * 60 * 1000).toISOString();
|
|
56
|
+
} catch {
|
|
57
|
+
return null; // unknown/invalid IANA tz string
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Detect a limit NOTICE (not merely the topic) in output/stderr on a failed run;
|
|
62
|
+
* returns {resumeAt, message} or null. `redact` (from logger) scrubs secrets before
|
|
63
|
+
* the message is shipped to the server / shown in the Studio. */
|
|
64
|
+
function detectRateLimit(text, redact) {
|
|
65
|
+
const t = String(text || '');
|
|
66
|
+
if (!LIMIT_NOTICE_RE.test(t)) return null;
|
|
67
|
+
const tail = t.slice(-300).trim();
|
|
68
|
+
return {
|
|
69
|
+
resumeAt: parseResetAt(t) || new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
|
70
|
+
message: (typeof redact === 'function' ? redact(tail) : tail),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** True when the whole final reply IS the limit notice (exit-0 case): short AND
|
|
75
|
+
* matches the notice phrasing AND names a reset — must never be stored as a result.
|
|
76
|
+
* Requiring the notice phrasing (not just "rate limit") keeps a short benign reply
|
|
77
|
+
* that merely discusses limits from being swallowed. */
|
|
78
|
+
function isLimitMessage(text) {
|
|
79
|
+
const t = String(text || '').trim();
|
|
80
|
+
return t.length > 0 && t.length <= 300 && LIMIT_NOTICE_RE.test(t) && RESET_RE.test(t);
|
|
81
|
+
}
|
|
82
|
+
|
|
18
83
|
// A small set of flags we refuse to pass through (auto-approve / no-safety modes).
|
|
19
84
|
const FORBIDDEN_FLAGS = [
|
|
20
85
|
'--yolo', '--dangerously-skip-permissions', '--no-sandbox', '--allow-all',
|
|
@@ -101,8 +166,14 @@ async function* spawnStreaming({ binary, argv, cwd, env, timeoutMs = 600000, std
|
|
|
101
166
|
};
|
|
102
167
|
|
|
103
168
|
let stdoutBuf = '';
|
|
169
|
+
// Bounded tail of everything the runtime printed (stdout + stderr), used on failure
|
|
170
|
+
// to recognize a session-limit notice and type the error as rate_limited.
|
|
171
|
+
let tailBuf = '';
|
|
172
|
+
const addTail = (s) => { tailBuf = (tailBuf + s).slice(-4096); };
|
|
104
173
|
const handleChunk = (buf) => {
|
|
105
|
-
|
|
174
|
+
const s = buf.toString();
|
|
175
|
+
addTail(s);
|
|
176
|
+
stdoutBuf += s;
|
|
106
177
|
let idx;
|
|
107
178
|
while ((idx = stdoutBuf.indexOf('\n')) >= 0) {
|
|
108
179
|
const line = stdoutBuf.slice(0, idx);
|
|
@@ -113,7 +184,9 @@ async function* spawnStreaming({ binary, argv, cwd, env, timeoutMs = 600000, std
|
|
|
113
184
|
|
|
114
185
|
child.stdout.on('data', handleChunk);
|
|
115
186
|
child.stderr.on('data', (b) => {
|
|
116
|
-
const
|
|
187
|
+
const raw = b.toString();
|
|
188
|
+
addTail(raw);
|
|
189
|
+
const text = logger.redact(raw).trim();
|
|
117
190
|
if (text) push({ type: 'log', content: text.slice(0, 500) });
|
|
118
191
|
});
|
|
119
192
|
|
|
@@ -121,7 +194,16 @@ async function* spawnStreaming({ binary, argv, cwd, env, timeoutMs = 600000, std
|
|
|
121
194
|
clearTimeout(timer);
|
|
122
195
|
if (stdoutBuf.trim()) for (const evt of (parseLine(stdoutBuf) || [])) push(evt);
|
|
123
196
|
if (killed) exitErr = new Error(`runtime timed out after ${timeoutMs}ms`);
|
|
124
|
-
else if (code !== 0)
|
|
197
|
+
else if (code !== 0) {
|
|
198
|
+
const limit = detectRateLimit(tailBuf, logger.redact);
|
|
199
|
+
if (limit) {
|
|
200
|
+
exitErr = Object.assign(new Error(limit.message || `runtime exited with code ${code}`), {
|
|
201
|
+
code: 'rate_limited', resumeAt: limit.resumeAt,
|
|
202
|
+
});
|
|
203
|
+
} else {
|
|
204
|
+
exitErr = new Error(`runtime exited with code ${code}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
125
207
|
done = true;
|
|
126
208
|
if (resolveNext) { resolveNext(); resolveNext = null; }
|
|
127
209
|
});
|
|
@@ -140,4 +222,4 @@ async function* spawnStreaming({ binary, argv, cwd, env, timeoutMs = 600000, std
|
|
|
140
222
|
if (exitErr) throw exitErr;
|
|
141
223
|
}
|
|
142
224
|
|
|
143
|
-
module.exports = { spawnStreaming, buildArgv, FORBIDDEN_FLAGS };
|
|
225
|
+
module.exports = { spawnStreaming, buildArgv, FORBIDDEN_FLAGS, detectRateLimit, isLimitMessage };
|
package/src/bridge.js
CHANGED
|
@@ -60,6 +60,9 @@ class Bridge {
|
|
|
60
60
|
stream(jobId, seq, type, content) { return this._post('stream', { job_id: jobId, seq, type, content }); }
|
|
61
61
|
complete(jobId, finalContent, metadata) { return this._post('complete', { job_id: jobId, final_content: finalContent, metadata: metadata || {} }); }
|
|
62
62
|
fail(jobId, error) { return this._post('fail', { job_id: jobId, error: String(error).slice(0, 1000) }); }
|
|
63
|
+
// Session-limit hit: park the job server-side until resume_at (run stays alive); the same
|
|
64
|
+
// machine re-claims it after the reset and --resume continues the conversation.
|
|
65
|
+
pause(jobId, resumeAt, message) { return this._post('pause', { job_id: jobId, resume_at: resumeAt, message: String(message ?? '').slice(0, 500) }); }
|
|
63
66
|
// Poll a running job's status so the worker can abort the agent if the run was cancelled.
|
|
64
67
|
jobStatus(jobId) { return this._post('job_status', { job_id: jobId }); }
|
|
65
68
|
// AHP data plane: request short-lived signed URLs to sync content-addressed Capsule bytes.
|
package/src/worker.js
CHANGED
|
@@ -10,6 +10,7 @@ const logger = require('./logger');
|
|
|
10
10
|
const config = require('./config');
|
|
11
11
|
const { Bridge, withRetry } = require('./bridge');
|
|
12
12
|
const { getAdapter } = require('./adapters');
|
|
13
|
+
const { detectRateLimit, isLimitMessage } = require('./adapters/base');
|
|
13
14
|
const { detectRuntimes } = require('./detect');
|
|
14
15
|
const { P2P } = require('./transport/p2p');
|
|
15
16
|
const control = require('./control');
|
|
@@ -100,6 +101,21 @@ async function processJob(bridge, agentCfg, job, p2p) {
|
|
|
100
101
|
catch (e) { logger.warn(`stream seq ${seq} failed: ${e.message}`); }
|
|
101
102
|
}
|
|
102
103
|
if (ac.signal.aborted) { logger.info(`job ${job.id} cancelled — not completing`); return; }
|
|
104
|
+
// Session-limit notice delivered as a NORMAL reply (exit 0): the whole answer is the
|
|
105
|
+
// limit message. Never store that as the node's result — pause the job instead so the
|
|
106
|
+
// run resumes after the reset (server parks it with not_before; --resume continues).
|
|
107
|
+
if (job.harness && isLimitMessage(finalText)) {
|
|
108
|
+
const limit = detectRateLimit(finalText, logger.redact) || { resumeAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), message: logger.redact(finalText) };
|
|
109
|
+
logger.warn(`job ${job.id} rate-limited — pausing until ${limit.resumeAt}`);
|
|
110
|
+
try {
|
|
111
|
+
await withRetry(() => bridge.pause(job.id, limit.resumeAt, limit.message));
|
|
112
|
+
return;
|
|
113
|
+
} catch (pe) {
|
|
114
|
+
logger.warn(`pause not supported by server (${pe.message}) — failing instead`);
|
|
115
|
+
try { await bridge.fail(job.id, limit.message); } catch { /* ignore */ }
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
103
119
|
// H2H: store a large result for P2P fetch by the next node's host (inline copy stays the floor).
|
|
104
120
|
// We add+ref it before complete(), but ANNOUNCE only AFTER complete() persists the capsule — so
|
|
105
121
|
// the directory can verify this host is the producer before listing it.
|
|
@@ -120,6 +136,17 @@ async function processJob(bridge, agentCfg, job, p2p) {
|
|
|
120
136
|
logger.info(`job ${job.id} done (${finalText.length} chars${transport ? `, P2P-offered ${transport.cid.slice(0, 14)}…` : ''})`);
|
|
121
137
|
} catch (e) {
|
|
122
138
|
if (ac.signal.aborted) { logger.info(`job ${job.id} cancelled — agent stopped`); return; }
|
|
139
|
+
// Typed rate-limit (nonzero exit + limit notice in the output tail): pause, don't fail.
|
|
140
|
+
// An old server without the pause action degrades to today's fail behavior.
|
|
141
|
+
if (e && e.code === 'rate_limited' && job.harness) {
|
|
142
|
+
logger.warn(`job ${job.id} rate-limited — pausing until ${e.resumeAt}`);
|
|
143
|
+
try {
|
|
144
|
+
await withRetry(() => bridge.pause(job.id, e.resumeAt, e.message));
|
|
145
|
+
return;
|
|
146
|
+
} catch (pe) {
|
|
147
|
+
logger.warn(`pause not supported by server (${pe.message}) — failing instead`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
123
150
|
logger.error(`job ${job.id} failed: ${e.message}`);
|
|
124
151
|
try { await bridge.fail(job.id, e.message); } catch { /* ignore */ }
|
|
125
152
|
} finally {
|