@hwip/mcp-server 0.0.0-stage → 0.1.0
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/LICENSE +190 -0
- package/NOTICE +10 -0
- package/README.md +413 -2
- package/dist/agent-target.d.ts +45 -0
- package/dist/agent-target.js +81 -0
- package/dist/api-client.d.ts +162 -0
- package/dist/api-client.js +517 -0
- package/dist/ask-context-attach.d.ts +67 -0
- package/dist/ask-context-attach.js +147 -0
- package/dist/ask-seal.d.ts +118 -0
- package/dist/ask-seal.js +194 -0
- package/dist/command-seal.d.ts +49 -0
- package/dist/command-seal.js +90 -0
- package/dist/command-seq.d.ts +30 -0
- package/dist/command-seq.js +147 -0
- package/dist/config.d.ts +93 -0
- package/dist/config.js +561 -0
- package/dist/crypto.d.ts +234 -0
- package/dist/crypto.js +606 -0
- package/dist/error-format.d.ts +7 -0
- package/dist/error-format.js +77 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +30 -0
- package/dist/key-changes.d.ts +50 -0
- package/dist/key-changes.js +163 -0
- package/dist/mime.d.ts +16 -0
- package/dist/mime.js +58 -0
- package/dist/peer-key.d.ts +36 -0
- package/dist/peer-key.js +35 -0
- package/dist/poll.d.ts +24 -0
- package/dist/poll.js +156 -0
- package/dist/push-audience.d.ts +35 -0
- package/dist/push-audience.js +64 -0
- package/dist/redirect.d.ts +8 -0
- package/dist/redirect.js +17 -0
- package/dist/remote-state.d.ts +38 -0
- package/dist/remote-state.js +81 -0
- package/dist/resources/channels.d.ts +4 -0
- package/dist/resources/channels.js +24 -0
- package/dist/resources/devices.d.ts +4 -0
- package/dist/resources/devices.js +24 -0
- package/dist/sanitize.d.ts +41 -0
- package/dist/sanitize.js +106 -0
- package/dist/self-register.d.ts +34 -0
- package/dist/self-register.js +51 -0
- package/dist/server.d.ts +4 -0
- package/dist/server.js +63 -0
- package/dist/tools/agent-local.d.ts +17 -0
- package/dist/tools/agent-local.js +139 -0
- package/dist/tools/agent-send.d.ts +6 -0
- package/dist/tools/agent-send.js +89 -0
- package/dist/tools/ask.d.ts +9 -0
- package/dist/tools/ask.js +275 -0
- package/dist/tools/broadcast.d.ts +5 -0
- package/dist/tools/broadcast.js +42 -0
- package/dist/tools/clipboard.d.ts +5 -0
- package/dist/tools/clipboard.js +47 -0
- package/dist/tools/dismiss.d.ts +5 -0
- package/dist/tools/dismiss.js +49 -0
- package/dist/tools/file.d.ts +5 -0
- package/dist/tools/file.js +169 -0
- package/dist/tools/list.d.ts +4 -0
- package/dist/tools/list.js +44 -0
- package/dist/tools/lookup.d.ts +10 -0
- package/dist/tools/lookup.js +45 -0
- package/dist/tools/notify.d.ts +5 -0
- package/dist/tools/notify.js +118 -0
- package/dist/tools/rename.d.ts +5 -0
- package/dist/tools/rename.js +45 -0
- package/dist/trust-pins.d.ts +70 -0
- package/dist/trust-pins.js +269 -0
- package/dist/types.d.ts +157 -0
- package/dist/types.js +2 -0
- package/dist/url-check.d.ts +11 -0
- package/dist/url-check.js +57 -0
- package/dist/ws-wait.d.ts +72 -0
- package/dist/ws-wait.js +200 -0
- package/package.json +70 -4
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.HwipApiClient = exports.requestSignal = exports.transferTimeoutMs = exports.readCappedText = exports.serverMessage = exports.UNPRINTABLE = exports.MAX_RESPONSE_BYTES = exports.MAX_FILE_BYTES = exports.isTransient = exports.ApiError = void 0;
|
|
4
|
+
const node_crypto_1 = require("node:crypto");
|
|
5
|
+
const redirect_js_1 = require("./redirect.js");
|
|
6
|
+
const mime_js_1 = require("./mime.js");
|
|
7
|
+
class ApiError extends Error {
|
|
8
|
+
code;
|
|
9
|
+
status;
|
|
10
|
+
/** The request cannot have been sent: no connection was ever made. */
|
|
11
|
+
unsent;
|
|
12
|
+
/** The transport's own code for a call that got no reply (`ECONNRESET`), when it gave one. */
|
|
13
|
+
causeCode;
|
|
14
|
+
/** A reply this client would not read to the end: the server answered, whatever it said. */
|
|
15
|
+
unreadReply;
|
|
16
|
+
constructor(message, code, status, transport = {}) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.code = code;
|
|
19
|
+
this.status = status;
|
|
20
|
+
this.name = 'ApiError';
|
|
21
|
+
this.unsent = transport.unsent ?? false;
|
|
22
|
+
this.causeCode = transport.causeCode;
|
|
23
|
+
this.unreadReply = transport.unreadReply ?? false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
exports.ApiError = ApiError;
|
|
27
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
28
|
+
const POLL_TIMEOUT_MS = 15_000;
|
|
29
|
+
/** Pauses before the tries that follow a trigger failure that may pass: four tries in all. */
|
|
30
|
+
const TRIGGER_BACKOFF_MS = [1_000, 2_000, 4_000];
|
|
31
|
+
/**
|
|
32
|
+
* A first request still running under the key answers a retry with 409 until it ends. One the server
|
|
33
|
+
* lost to its own timeout never ends: the key stays taken for 30 s (hwip `server/src/entities/idempotency.ts`
|
|
34
|
+
* STALE_CLAIM_MS, read 2026-10-14) and a retry after that starts a new request. So these tries are
|
|
35
|
+
* counted apart from the ones above, and go on until that hold has passed: seven more, 35 s of
|
|
36
|
+
* waiting from the first 409. The key was taken before that answer, so the last try comes after the
|
|
37
|
+
* hold whatever the first request took to fail.
|
|
38
|
+
*/
|
|
39
|
+
const IN_PROGRESS_RETRY_MS = 5_000;
|
|
40
|
+
const IN_PROGRESS_RETRIES = 7;
|
|
41
|
+
/**
|
|
42
|
+
* A failed call that a later one may well get past: the network (NETWORK_ERROR,
|
|
43
|
+
* status 0), a reply that could not be read (BAD_RESPONSE, status 0), the
|
|
44
|
+
* request's own timeout, a rate limit, a server error. A 4xx
|
|
45
|
+
* other than 408 and 429 is an answer about the request itself (bad key, hook
|
|
46
|
+
* gone) and will say the same thing every time — and so does a refused redirect
|
|
47
|
+
* (REDIRECT_REFUSED, also status 0): the base URL sends the request elsewhere
|
|
48
|
+
* on every call. Nor does a base URL that is not an address get to be one.
|
|
49
|
+
*/
|
|
50
|
+
const isTransient = (err) => !(err instanceof ApiError)
|
|
51
|
+
|| (err.code !== 'REDIRECT_REFUSED' && err.causeCode !== 'ERR_INVALID_URL'
|
|
52
|
+
&& (err.status === 0 || err.status === 408 || err.status === 429 || err.status >= 500));
|
|
53
|
+
exports.isTransient = isTransient;
|
|
54
|
+
/**
|
|
55
|
+
* The errnos of a connection that could not be made, each with the call that says so. Under any other
|
|
56
|
+
* call the socket had been connected, and the request may have been written to it: a reset is reported
|
|
57
|
+
* under `read` (seen on Node 24.21), and "no route" is taken to come the same way (not reproduced).
|
|
58
|
+
*/
|
|
59
|
+
const BEFORE_CONNECTING = new Map([
|
|
60
|
+
['ENOTFOUND', 'getaddrinfo'],
|
|
61
|
+
['EAI_AGAIN', 'getaddrinfo'],
|
|
62
|
+
['ECONNREFUSED', 'connect'],
|
|
63
|
+
['ENETUNREACH', 'connect'],
|
|
64
|
+
['EHOSTUNREACH', 'connect'],
|
|
65
|
+
['ETIMEDOUT', 'connect'],
|
|
66
|
+
]);
|
|
67
|
+
/** undici's own "the connect got no answer", and `fetch` handed an address it could not parse: neither has a call to go by, or needs one. */
|
|
68
|
+
const NEVER_STARTED = new Set(['UND_ERR_CONNECT_TIMEOUT', 'ERR_INVALID_URL']);
|
|
69
|
+
/**
|
|
70
|
+
* Did `fetch` fail, by this cause, before the request could be sent? A connect or a lookup that
|
|
71
|
+
* failed, or — a host with several addresses — every one of them failing so. The shapes are Node
|
|
72
|
+
* 24.21's (undici 7.29), read 2026-10-14: `{ code, syscall }`, and an `AggregateError` of them.
|
|
73
|
+
*/
|
|
74
|
+
const failedBeforeConnecting = (cause) => {
|
|
75
|
+
if (typeof cause !== 'object' || cause === null)
|
|
76
|
+
return false;
|
|
77
|
+
const { code, syscall, errors } = cause;
|
|
78
|
+
if (typeof code !== 'string')
|
|
79
|
+
return false;
|
|
80
|
+
if (NEVER_STARTED.has(code))
|
|
81
|
+
return true;
|
|
82
|
+
if (cause instanceof AggregateError)
|
|
83
|
+
return Array.isArray(errors) && errors.length > 0 && errors.every(failedBeforeConnecting);
|
|
84
|
+
const call = BEFORE_CONNECTING.get(code);
|
|
85
|
+
return call !== undefined && syscall === call;
|
|
86
|
+
};
|
|
87
|
+
/**
|
|
88
|
+
* A failed send the server may have taken all the same: a slow answer, one that could not be read, a 5xx
|
|
89
|
+
* (a gateway that gave up on a request already written), a first send still running, a connection that
|
|
90
|
+
* dropped once made. Not its own "too many", and not a connection that was never made.
|
|
91
|
+
*/
|
|
92
|
+
const mayHaveLanded = (err) => !(err instanceof ApiError) || (err.status !== 429 && !err.unsent);
|
|
93
|
+
const wallClock = {
|
|
94
|
+
now: () => Date.now(),
|
|
95
|
+
sleep: (ms, signal) => new Promise((resolve, reject) => {
|
|
96
|
+
if (signal?.aborted)
|
|
97
|
+
return reject(signal.reason);
|
|
98
|
+
const cancel = () => { clearTimeout(timer); reject(signal?.reason); };
|
|
99
|
+
const timer = setTimeout(() => { signal?.removeEventListener('abort', cancel); resolve(); }, ms);
|
|
100
|
+
signal?.addEventListener('abort', cancel, { once: true });
|
|
101
|
+
}),
|
|
102
|
+
};
|
|
103
|
+
/**
|
|
104
|
+
* The largest file any tier can send — Pro's 1 GiB (`fileMaxBytes` in hwip's
|
|
105
|
+
* shared tier-limits). The account's own tier still decides below this; the cap
|
|
106
|
+
* only stops a transfer no tier could accept from being read into memory.
|
|
107
|
+
*/
|
|
108
|
+
exports.MAX_FILE_BYTES = 1_073_741_824;
|
|
109
|
+
/**
|
|
110
|
+
* The most a JSON reply (or an error body) may weigh. The API's own replies are
|
|
111
|
+
* a few KiB, a push list tens; 8 MiB is far past any real one and still small
|
|
112
|
+
* enough that a wrong or hostile host cannot make this process hold gigabytes.
|
|
113
|
+
*/
|
|
114
|
+
exports.MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
|
115
|
+
/** An error body is read only to be shown, so it gets a much smaller bound. */
|
|
116
|
+
const MAX_ERROR_BODY_BYTES = 64 * 1024;
|
|
117
|
+
/** The longest server-worded message passed on to the model. */
|
|
118
|
+
const MAX_SERVER_MESSAGE_CHARS = 300;
|
|
119
|
+
/** What an API error code looks like. Anything else is not one the API sent. */
|
|
120
|
+
const ERROR_CODE_SHAPE = /^[A-Za-z0-9_.:-]{1,64}$/;
|
|
121
|
+
/**
|
|
122
|
+
* What a one-line message cannot carry: control characters (Cc), the invisible
|
|
123
|
+
* format characters (Cf — bidi marks and overrides, zero-width space, the BOM,
|
|
124
|
+
* and the Unicode tag block that can hide text from a reader but not from a
|
|
125
|
+
* model), the grapheme-join blocker U+034F, and the Hangul fillers (U+115F,
|
|
126
|
+
* U+1160, U+3164, U+FFA0 — blank letters an attacker would use as an invisible
|
|
127
|
+
* base character), and line and paragraph separators. U+200C ZWNJ and U+200D ZWJ
|
|
128
|
+
* are writing, not hiding (Persian, Hindi, emoji sequences), so a lone one
|
|
129
|
+
* stays — but a run of them is a bit channel, so a run collapses to its first
|
|
130
|
+
* character (C153). Selectors go by allowlist, not by run: U+FE0E/U+FE0F stay
|
|
131
|
+
* only after an emoji base (text/emoji presentation), U+E0100–E01EF only after a
|
|
132
|
+
* unified ideograph (IVS); every other selector — including the Mongolian FVS
|
|
133
|
+
* (U+180B–U+180D, U+180F) — goes, so spacing a run out with an invisible
|
|
134
|
+
* character no longer smuggles it past the rule. The same rule as cli's
|
|
135
|
+
* `UNPRINTABLE` block (src/http-failure.ts): the lines below from
|
|
136
|
+
* `const UNPRINTABLE` through `sanitizeSmuggled` are character-identical there.
|
|
137
|
+
*/
|
|
138
|
+
exports.UNPRINTABLE = /(?:[\p{Cc}\p{Zl}\p{Zp}]|(?![\u200C\u200D])\p{Cf}|[\u034F\u115F\u1160\u3164\uFFA0])+/gu;
|
|
139
|
+
// Written with \u{...}: \uE0100 would read as U+E010 plus "0", turning 0-\uE01E into a range over ASCII (C153).
|
|
140
|
+
const SELECTOR_ALLOW = /(\p{Emoji}|[0-9#*])([\uFE0E\uFE0F])|(\p{Unified_Ideograph})([\u{E0100}-\u{E01EF}])|[\uFE00-\uFE0F\u180B-\u180D\u180F\u{E0100}-\u{E01EF}]/gu;
|
|
141
|
+
const JOINER_RUN = /([\u200C\u200D])[\u200C\u200D]+/gu;
|
|
142
|
+
// '$1$2$3$4' rebuilds an allowed base + selector and erases anything else (groups that did not match are empty).
|
|
143
|
+
const sanitizeSmuggled = (text) => text.replace(SELECTOR_ALLOW, '$1$2$3$4').replace(JOINER_RUN, '$1');
|
|
144
|
+
/**
|
|
145
|
+
* The server's own wording, made safe to hand to the model: one line, control
|
|
146
|
+
* characters gone, cut to a fixed length. A reply from a hijacked or mistaken
|
|
147
|
+
* host could otherwise put a paragraph of instructions into the tool result.
|
|
148
|
+
*/
|
|
149
|
+
const serverMessage = (value, fallback) => {
|
|
150
|
+
if (typeof value !== 'string')
|
|
151
|
+
return fallback;
|
|
152
|
+
const clean = sanitizeSmuggled(value.replace(exports.UNPRINTABLE, ' ')).trim();
|
|
153
|
+
if (!clean)
|
|
154
|
+
return fallback;
|
|
155
|
+
// By code point: a cut through a surrogate pair would leave half an emoji.
|
|
156
|
+
const points = Array.from(clean);
|
|
157
|
+
return points.length > MAX_SERVER_MESSAGE_CHARS ? `${points.slice(0, MAX_SERVER_MESSAGE_CHARS - 1).join('')}…` : clean;
|
|
158
|
+
};
|
|
159
|
+
exports.serverMessage = serverMessage;
|
|
160
|
+
const responseTooLarge = (max) => new ApiError(`The hwip API's reply is larger than ${max} bytes, so it was not read`, 'RESPONSE_TOO_LARGE', 413, { unreadReply: true });
|
|
161
|
+
/**
|
|
162
|
+
* The body of `response`, refused as soon as it passes `max` bytes. Content-Length
|
|
163
|
+
* is only a claim — it can be absent, wrong, or beaten by chunked encoding — so
|
|
164
|
+
* the bytes actually read are what is counted, and the stream is cancelled at the cap.
|
|
165
|
+
*/
|
|
166
|
+
const readCapped = async (response, max, tooBig) => {
|
|
167
|
+
const declared = Number(response.headers.get('content-length'));
|
|
168
|
+
if (declared > max) {
|
|
169
|
+
// Refused unread: free the connection instead of leaving it to GC.
|
|
170
|
+
await response.body?.cancel().catch(() => undefined);
|
|
171
|
+
throw tooBig(declared);
|
|
172
|
+
}
|
|
173
|
+
if (!response.body) {
|
|
174
|
+
const whole = new Uint8Array(await response.arrayBuffer());
|
|
175
|
+
if (whole.byteLength > max)
|
|
176
|
+
throw tooBig();
|
|
177
|
+
return whole;
|
|
178
|
+
}
|
|
179
|
+
const reader = response.body.getReader();
|
|
180
|
+
const chunks = [];
|
|
181
|
+
let total = 0;
|
|
182
|
+
for (;;) {
|
|
183
|
+
const { done, value } = await reader.read();
|
|
184
|
+
if (done)
|
|
185
|
+
break;
|
|
186
|
+
total += value.byteLength;
|
|
187
|
+
if (total > max) {
|
|
188
|
+
await reader.cancel().catch(() => undefined);
|
|
189
|
+
throw tooBig();
|
|
190
|
+
}
|
|
191
|
+
chunks.push(value);
|
|
192
|
+
}
|
|
193
|
+
const out = new Uint8Array(total);
|
|
194
|
+
let at = 0;
|
|
195
|
+
for (const chunk of chunks) {
|
|
196
|
+
out.set(chunk, at);
|
|
197
|
+
at += chunk.byteLength;
|
|
198
|
+
}
|
|
199
|
+
return out;
|
|
200
|
+
};
|
|
201
|
+
const readCappedText = async (response, max) => new TextDecoder().decode(await readCapped(response, max, () => responseTooLarge(max)));
|
|
202
|
+
exports.readCappedText = readCappedText;
|
|
203
|
+
/** The slowest link a file transfer is still waited on over: 1 MiB/s. */
|
|
204
|
+
const TRANSFER_BYTES_PER_SEC = 1024 * 1024;
|
|
205
|
+
/** A file transfer's timeout: the usual request budget plus time for its bytes. */
|
|
206
|
+
const transferTimeoutMs = (bytes) => DEFAULT_TIMEOUT_MS + Math.ceil((bytes / TRANSFER_BYTES_PER_SEC) * 1000);
|
|
207
|
+
exports.transferTimeoutMs = transferTimeoutMs;
|
|
208
|
+
/**
|
|
209
|
+
* The per-request timeout, ended early by `outer` (the tool call's own abort)
|
|
210
|
+
* when one is given. Hand-rolled because `AbortSignal.any` needs Node 20.3 and
|
|
211
|
+
* the package supports 18.17. Each signal aborts with its own reason, so a
|
|
212
|
+
* timeout still reads as `TimeoutError` and a cancel as `AbortError`.
|
|
213
|
+
* `release` drops the listener on `outer` once the request has settled.
|
|
214
|
+
* Exported for tests.
|
|
215
|
+
*/
|
|
216
|
+
const requestSignal = (timeoutMs, outer) => {
|
|
217
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
218
|
+
if (!outer)
|
|
219
|
+
return { signal: timeout, release: () => undefined };
|
|
220
|
+
const combined = new AbortController();
|
|
221
|
+
const fromOuter = () => combined.abort(outer.reason);
|
|
222
|
+
if (outer.aborted)
|
|
223
|
+
fromOuter();
|
|
224
|
+
else
|
|
225
|
+
outer.addEventListener('abort', fromOuter, { once: true });
|
|
226
|
+
timeout.addEventListener('abort', () => combined.abort(timeout.reason), { once: true });
|
|
227
|
+
return { signal: combined.signal, release: () => outer.removeEventListener('abort', fromOuter) };
|
|
228
|
+
};
|
|
229
|
+
exports.requestSignal = requestSignal;
|
|
230
|
+
/** An event id as it goes into the path its answer is read from: the server's (`hevt_` + ULID). The cli holds a saved one to the same shape (`ask-pending.ts`). */
|
|
231
|
+
const EVENT_ID_SHAPE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
232
|
+
/** What a request id may be before it goes into a path: hook, event, push and device ids are all
|
|
233
|
+
* server-shaped (`hevt_` + ULID, `push_…`, `hook_…`, `dev_…`), so anything else — `..`, a `/`,
|
|
234
|
+
* an empty string — is a caller bug, refused before `fetch` can fold the path (`/pushes/../dismiss`
|
|
235
|
+
* reads as `/dismiss`). M40. */
|
|
236
|
+
const PATH_ID_SHAPE = EVENT_ID_SHAPE;
|
|
237
|
+
const assertPathId = (kind, value) => {
|
|
238
|
+
if (!PATH_ID_SHAPE.test(value)) {
|
|
239
|
+
throw new ApiError(`Invalid ${kind}: not an id this client sends`, 'INVALID_ID', 400);
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
/** A tmux session name as it goes into the rename path. The server keys nearly anything
|
|
243
|
+
* (`isSessionMapKey`, only `__proto__` refused), and tmux names can carry spaces, so this is not
|
|
244
|
+
* the id shape above — only what would break out of the path segment. */
|
|
245
|
+
const assertSessionName = (value) => {
|
|
246
|
+
if (value.length === 0 || value.length > 128 || value === '.' || value === '..' || value === '__proto__' || /[\/\0-\x1f\x7f]/.test(value)) {
|
|
247
|
+
throw new ApiError('Invalid session name: not a name this client sends', 'INVALID_ID', 400);
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
/** A transport's code as Node and undici write them (`ECONNRESET`, `UND_ERR_SOCKET`): a word, never a sentence. */
|
|
251
|
+
const TRANSPORT_CODE_SHAPE = /^[A-Z][A-Z0-9_]{1,40}$/;
|
|
252
|
+
/**
|
|
253
|
+
* A failure's words without the userinfo of an address in them: stderr is a log that may be shared
|
|
254
|
+
* (url-check.ts). Up to the last `@` before the path — a password may hold one, or a space, as written.
|
|
255
|
+
*/
|
|
256
|
+
const withoutUserinfo = (words) => words.replace(/\/\/[^/?#]*@/g, '//');
|
|
257
|
+
/**
|
|
258
|
+
* A request that never got a reply. A write may still have landed — the
|
|
259
|
+
* server can act and the answer be what was lost — so the message says so for
|
|
260
|
+
* anything but a read.
|
|
261
|
+
*/
|
|
262
|
+
const transportError = (err, method) => {
|
|
263
|
+
const landedNote = method === 'GET' ? '' : ' The request may still have reached the server.';
|
|
264
|
+
if (err instanceof DOMException && err.name === 'TimeoutError') {
|
|
265
|
+
return new ApiError(`The hwip API did not answer in time.${landedNote}`, 'API_TIMEOUT', 408);
|
|
266
|
+
}
|
|
267
|
+
// fetch rejects with a TypeError when no response came back at all: DNS,
|
|
268
|
+
// refused connection, reset, TLS.
|
|
269
|
+
if (err instanceof TypeError) {
|
|
270
|
+
if ((0, redirect_js_1.isRedirectRefusal)(err)) {
|
|
271
|
+
return new ApiError('The hwip API answered with a redirect, and this server does not follow one with your API key.', 'REDIRECT_REFUSED', 0);
|
|
272
|
+
}
|
|
273
|
+
const cause = err.cause; // fetch's own shape: undici hangs the socket's error here
|
|
274
|
+
// A reset is a TypeError as well, and may be of a request the server took: only a failed connect says it was never sent.
|
|
275
|
+
const unsent = failedBeforeConnecting(cause);
|
|
276
|
+
const causeCode = typeof cause?.code === 'string' && TRANSPORT_CODE_SHAPE.test(cause.code) ? cause.code : undefined;
|
|
277
|
+
// The code and no more: a failure's words may quote what was sent (a header's value, an address with
|
|
278
|
+
// a password in it), and this message goes to the model. With no code to give, the words go to stderr, less that password.
|
|
279
|
+
if (causeCode === undefined) {
|
|
280
|
+
// A code that is not written as one is words too: left out above, and still what to look for here.
|
|
281
|
+
const said = [err.message, cause?.code, cause?.message].filter((part) => typeof part === 'string');
|
|
282
|
+
console.error('[api] no reply:', withoutUserinfo(said.join(' — ')));
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
// M41: the code alone travels with the error; its words (a TLS certificate's detail, the address
|
|
286
|
+
// tried) go to stderr instead, with any userinfo stripped — they never reached the model before.
|
|
287
|
+
const said = [err.message, cause?.message].filter((part) => typeof part === 'string');
|
|
288
|
+
const detail = withoutUserinfo(said.join(' — '));
|
|
289
|
+
if (detail)
|
|
290
|
+
console.error(`[api] no reply (${causeCode}):`, detail);
|
|
291
|
+
}
|
|
292
|
+
return new ApiError(`Could not reach the hwip API${causeCode === undefined ? '' : ` (${causeCode})`}.${unsent ? '' : landedNote}`, 'NETWORK_ERROR', 0, { unsent, causeCode });
|
|
293
|
+
}
|
|
294
|
+
return null;
|
|
295
|
+
};
|
|
296
|
+
class HwipApiClient {
|
|
297
|
+
clock;
|
|
298
|
+
apiKey;
|
|
299
|
+
baseUrl;
|
|
300
|
+
constructor(config, clock = wallClock) {
|
|
301
|
+
this.clock = clock;
|
|
302
|
+
this.apiKey = config.apiKey;
|
|
303
|
+
this.baseUrl = config.baseUrl;
|
|
304
|
+
}
|
|
305
|
+
async sendPush(params) {
|
|
306
|
+
return this.request('POST', '/pushes/send', params);
|
|
307
|
+
}
|
|
308
|
+
async triggerHook(hookId, params, signal,
|
|
309
|
+
/**
|
|
310
|
+
* Sending again: for how long from now the question is still worth it, and who to tell before
|
|
311
|
+
* each wait (with how many there have been). Without it, one try.
|
|
312
|
+
*/
|
|
313
|
+
retry) {
|
|
314
|
+
assertPathId('hookId', hookId);
|
|
315
|
+
// Every try carries the same key and the same bytes: the server answers a repeat with the first
|
|
316
|
+
// question's ids only when its body is the first one's, and refuses another body under the key
|
|
317
|
+
// (422). What the key does not cover (hwip `server/SERVER-API.md`, the trigger's
|
|
318
|
+
// `Idempotency-Key`, read 2026-10-14): a first request that wrote the question and died before it
|
|
319
|
+
// marked the key is taken over after its 30 s hold, and that try opens a second question.
|
|
320
|
+
const payload = { body: JSON.stringify(params), headers: { 'Idempotency-Key': (0, node_crypto_1.randomUUID)() } };
|
|
321
|
+
const path = `/hooks/${encodeURIComponent(hookId)}/trigger`;
|
|
322
|
+
const firstSent = this.clock.now();
|
|
323
|
+
let backoffs = 0;
|
|
324
|
+
let inProgress = 0;
|
|
325
|
+
/** Some try so far may have reached the server, whatever the last one says. */
|
|
326
|
+
let unsure = false;
|
|
327
|
+
let taken;
|
|
328
|
+
for (let tries = 1;; tries += 1) {
|
|
329
|
+
try {
|
|
330
|
+
taken = await this.send('POST', path, payload, DEFAULT_TIMEOUT_MS, signal);
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
333
|
+
catch (err) {
|
|
334
|
+
if (signal?.aborted || !retry)
|
|
335
|
+
throw err;
|
|
336
|
+
const running = err instanceof ApiError && err.code === 'IDEMPOTENCY_IN_PROGRESS';
|
|
337
|
+
const again = running || (0, exports.isTransient)(err);
|
|
338
|
+
// A 2xx too large to read is not sent again, and is the server saying it took the question. Told by
|
|
339
|
+
// the client's own mark, not by the code: a server may write the same word in a refusal.
|
|
340
|
+
if (again || (err instanceof ApiError && err.unreadReply))
|
|
341
|
+
unsure ||= mayHaveLanded(err);
|
|
342
|
+
const wait = !again
|
|
343
|
+
? undefined
|
|
344
|
+
: running
|
|
345
|
+
? (inProgress++ < IN_PROGRESS_RETRIES ? IN_PROGRESS_RETRY_MS : undefined)
|
|
346
|
+
: TRIGGER_BACKOFF_MS[backoffs++];
|
|
347
|
+
if (wait === undefined || this.clock.now() + wait - firstSent >= retry.withinMs) {
|
|
348
|
+
if (!unsure)
|
|
349
|
+
throw err;
|
|
350
|
+
// The last failure alone would send the caller the wrong way: "retry later" after a first try
|
|
351
|
+
// that timed out asks the question twice. Said in this client's words, not the last reply's.
|
|
352
|
+
// Its code or its kind, not its words: those may be the server's, or quote what was sent. A failure
|
|
353
|
+
// that is none of this client's own goes to stderr by name and code only (M40: never whole —
|
|
354
|
+
// its message may quote what was sent). Only the user reads it.
|
|
355
|
+
if (!(err instanceof ApiError)) {
|
|
356
|
+
const errCode = err.code;
|
|
357
|
+
const kind = err instanceof Error
|
|
358
|
+
? `${err.name}${typeof errCode === 'string' && errCode ? ` (${errCode})` : ''}`
|
|
359
|
+
: typeof err;
|
|
360
|
+
console.error(`[api] send failed: ${kind}`);
|
|
361
|
+
}
|
|
362
|
+
const last = err instanceof ApiError
|
|
363
|
+
? [err.code, err.status || undefined, err.causeCode].filter((part) => part !== undefined).join(' ')
|
|
364
|
+
: err instanceof Error ? err.name : 'no reply';
|
|
365
|
+
throw new ApiError(`Sending the question failed after ${tries} ${tries === 1 ? 'try' : 'tries'}, and it may have reached the server all the same (last failure: ${last}).`, 'SEND_UNCONFIRMED', err instanceof ApiError ? err.status : 0);
|
|
366
|
+
}
|
|
367
|
+
// Telling the caller is a courtesy: a failure there does not cost the question its retry.
|
|
368
|
+
await retry.onRetry?.(backoffs + inProgress).catch((e) => console.error('[api] retry notice failed:', e));
|
|
369
|
+
await this.clock.sleep(wait, signal);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
// The id goes into the path the answer is read from. Not sent again: the question is up, and a
|
|
373
|
+
// repeat under the same key is answered with the same id.
|
|
374
|
+
// A 2xx body is whatever JSON came: `null` parses, and so does a number.
|
|
375
|
+
const eventId = taken?.data?.eventId; // the type is the server's promise, not what a proxy sent
|
|
376
|
+
if (typeof eventId !== 'string' || !EVENT_ID_SHAPE.test(eventId)) {
|
|
377
|
+
throw new ApiError('The hwip API took the question but gave no event id its answer can be read by. The question may be on the phone, and this call cannot read an answer to it.', 'BAD_RESPONSE', 0);
|
|
378
|
+
}
|
|
379
|
+
return taken;
|
|
380
|
+
}
|
|
381
|
+
async getHookEvent(hookId, eventId) {
|
|
382
|
+
assertPathId('hookId', hookId);
|
|
383
|
+
assertPathId('eventId', eventId);
|
|
384
|
+
return this.request('GET', `/hooks/${encodeURIComponent(hookId)}/events/${encodeURIComponent(eventId)}`, undefined, POLL_TIMEOUT_MS);
|
|
385
|
+
}
|
|
386
|
+
async listDevices() {
|
|
387
|
+
return this.request('GET', '/devices');
|
|
388
|
+
}
|
|
389
|
+
/** Put this machine's key on the account's device list; `nickname` names it only when the server creates the entry. */
|
|
390
|
+
async registerDeviceKey(deviceId, publicKey, nickname) {
|
|
391
|
+
assertPathId('deviceId', deviceId);
|
|
392
|
+
return this.request('PUT', `/devices/${encodeURIComponent(deviceId)}/public-key`, { publicKey, nickname });
|
|
393
|
+
}
|
|
394
|
+
/** Set (or clear, when `alias` is empty) the display name for an agent
|
|
395
|
+
* session, keyed by its tmux `name` on `deviceId`. */
|
|
396
|
+
async renameAgentSession(deviceId, name, alias) {
|
|
397
|
+
assertPathId('deviceId', deviceId);
|
|
398
|
+
assertSessionName(name);
|
|
399
|
+
return this.request('PATCH', `/devices/${encodeURIComponent(deviceId)}/agent-sessions/${encodeURIComponent(name)}`, { alias });
|
|
400
|
+
}
|
|
401
|
+
async listPushes(params) {
|
|
402
|
+
const query = new URLSearchParams();
|
|
403
|
+
if (params?.limit)
|
|
404
|
+
query.set('limit', String(params.limit));
|
|
405
|
+
if (params?.type)
|
|
406
|
+
query.set('type', params.type);
|
|
407
|
+
const qs = query.toString();
|
|
408
|
+
return this.request('GET', `/pushes${qs ? `?${qs}` : ''}`);
|
|
409
|
+
}
|
|
410
|
+
async dismissPush(pushId) {
|
|
411
|
+
assertPathId('pushId', pushId);
|
|
412
|
+
return this.request('POST', `/pushes/${encodeURIComponent(pushId)}/dismiss`);
|
|
413
|
+
}
|
|
414
|
+
async dismissAllPushes() {
|
|
415
|
+
return this.request('POST', '/pushes/dismiss-all');
|
|
416
|
+
}
|
|
417
|
+
async listChannels() {
|
|
418
|
+
return this.request('GET', '/channels');
|
|
419
|
+
}
|
|
420
|
+
async requestUpload(params, signal) {
|
|
421
|
+
return this.request('POST', '/files/upload-request', params, DEFAULT_TIMEOUT_MS, signal);
|
|
422
|
+
}
|
|
423
|
+
async uploadToS3(url, content, contentType, signal) {
|
|
424
|
+
const isText = typeof content === 'string';
|
|
425
|
+
const body = isText ? content : new Uint8Array(content);
|
|
426
|
+
const bytes = isText ? Buffer.byteLength(content) : content.byteLength;
|
|
427
|
+
const { signal: combined, release } = (0, exports.requestSignal)((0, exports.transferTimeoutMs)(bytes), signal);
|
|
428
|
+
let response;
|
|
429
|
+
try {
|
|
430
|
+
response = await fetch(url, {
|
|
431
|
+
method: 'PUT',
|
|
432
|
+
headers: { 'Content-Type': (0, mime_js_1.uploadContentType)(contentType) },
|
|
433
|
+
body,
|
|
434
|
+
signal: combined,
|
|
435
|
+
// The signed address is the whole destination: a redirect would carry the bytes somewhere it did not name.
|
|
436
|
+
redirect: 'error',
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
catch (err) {
|
|
440
|
+
// M41: this address came from the API's own reply (`requestUpload`'s `uploadUrl`), not from the
|
|
441
|
+
// configured base URL — an address that is not one is the server's defect, and must not send the
|
|
442
|
+
// model to correct HWIP_BASE_URL. Its own code, still carrying the transport's cause.
|
|
443
|
+
const transport = transportError(err, 'PUT');
|
|
444
|
+
if (transport?.causeCode === 'ERR_INVALID_URL') {
|
|
445
|
+
throw new ApiError('The hwip API gave an upload address that is not a usable URL, so the file was not uploaded', 'INVALID_UPLOAD_URL', 0, { unsent: true, causeCode: 'ERR_INVALID_URL' });
|
|
446
|
+
}
|
|
447
|
+
throw transport ?? err;
|
|
448
|
+
}
|
|
449
|
+
finally {
|
|
450
|
+
release();
|
|
451
|
+
}
|
|
452
|
+
if (!response.ok) {
|
|
453
|
+
throw new ApiError(`S3 upload failed with status ${response.status}`, 'UPLOAD_FAILED', response.status);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
request(method, path, body, timeoutMs = DEFAULT_TIMEOUT_MS, signal) {
|
|
457
|
+
return this.send(method, path, body ? { body: JSON.stringify(body) } : {}, timeoutMs, signal);
|
|
458
|
+
}
|
|
459
|
+
/** One request, its JSON body already the bytes to send. */
|
|
460
|
+
async send(method, path, payload, timeoutMs, signal) {
|
|
461
|
+
const url = `${this.baseUrl}${path}`;
|
|
462
|
+
const headers = {
|
|
463
|
+
'X-API-Key': this.apiKey,
|
|
464
|
+
...(payload.body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
|
465
|
+
...payload.headers,
|
|
466
|
+
};
|
|
467
|
+
const { signal: combined, release } = (0, exports.requestSignal)(timeoutMs, signal);
|
|
468
|
+
let response;
|
|
469
|
+
try {
|
|
470
|
+
response = await fetch(url, {
|
|
471
|
+
method,
|
|
472
|
+
headers,
|
|
473
|
+
body: payload.body,
|
|
474
|
+
signal: combined,
|
|
475
|
+
// A redirect would carry the X-API-Key header to another host.
|
|
476
|
+
redirect: 'error',
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
catch (err) {
|
|
480
|
+
throw transportError(err, method) ?? err;
|
|
481
|
+
}
|
|
482
|
+
finally {
|
|
483
|
+
release();
|
|
484
|
+
}
|
|
485
|
+
if (!response.ok) {
|
|
486
|
+
// Read the body as text once, then try to parse it. A non-JSON
|
|
487
|
+
// error (HTML proxy page, gateway error) would otherwise be swallowed
|
|
488
|
+
// silently — surface a truncated snippet so failures are debuggable.
|
|
489
|
+
const raw = await (0, exports.readCappedText)(response, MAX_ERROR_BODY_BYTES).catch(() => '');
|
|
490
|
+
let errorBody = null;
|
|
491
|
+
try {
|
|
492
|
+
errorBody = raw ? JSON.parse(raw) : null;
|
|
493
|
+
}
|
|
494
|
+
catch {
|
|
495
|
+
if (raw)
|
|
496
|
+
console.error(`[api] ${response.status} non-JSON body: ${sanitizeSmuggled(raw.slice(0, 200).replace(exports.UNPRINTABLE, ' '))}`);
|
|
497
|
+
}
|
|
498
|
+
const message = (0, exports.serverMessage)(errorBody?.error?.message, `Request failed with status ${response.status}`);
|
|
499
|
+
const sentCode = errorBody?.error?.code;
|
|
500
|
+
const code = typeof sentCode === 'string' && ERROR_CODE_SHAPE.test(sentCode) ? sentCode : 'UNKNOWN_ERROR';
|
|
501
|
+
throw new ApiError(message, code, response.status);
|
|
502
|
+
}
|
|
503
|
+
try {
|
|
504
|
+
return JSON.parse(await (0, exports.readCappedText)(response, exports.MAX_RESPONSE_BYTES));
|
|
505
|
+
}
|
|
506
|
+
catch (err) {
|
|
507
|
+
// The reader's own refusal of a reply that is too large.
|
|
508
|
+
if (err instanceof ApiError)
|
|
509
|
+
throw err;
|
|
510
|
+
// A 2xx that is not the API's JSON: a proxy's page, a body cut short. Status 0, as for any call
|
|
511
|
+
// that got no usable answer: it is one a later call may get past.
|
|
512
|
+
const landedNote = method === 'GET' ? '' : ' The request may still have reached the server.';
|
|
513
|
+
throw new ApiError(`The hwip API's reply could not be read (HTTP ${response.status}).${landedNote}`, 'BAD_RESPONSE', 0);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
exports.HwipApiClient = HwipApiClient;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The attachment a sealed `hwip_ask` question carries (F3; twin of the cli's `src/ask-context-attach.ts`): the
|
|
3
|
+
* original of the asking session's context — its masked diff and screen — sealed for the devices the question
|
|
4
|
+
* is sealed for. The context itself comes from the `hwip` CLI (`hwip ask-context show`), which owns git, tmux,
|
|
5
|
+
* the masking and the per-project switch; nothing of that is copied here.
|
|
6
|
+
*
|
|
7
|
+
* What the server sees of it is the same for every sealed question: one attachment, named `file`, of type
|
|
8
|
+
* `application/octet-stream`, 64 KiB long. A question with nothing to carry — the switch is off, no change, an
|
|
9
|
+
* older CLI, no CLI at all — gets the same attachment with nothing in it. The real name, type and length are in
|
|
10
|
+
* the sealed body.
|
|
11
|
+
*/
|
|
12
|
+
import type { HwipApiClient } from './api-client.js';
|
|
13
|
+
import type { AskAttachment } from './ask-seal.js';
|
|
14
|
+
import { encryptFileForDevices, type DeviceRecipient, type SealedAskContext } from './crypto.js';
|
|
15
|
+
import { type ExecHwip } from './tools/agent-local.js';
|
|
16
|
+
/** Every attachment is this long before it is sealed: the longest original (the CLI's `ASK_CONTEXT_MAX_BYTES`), so none needs more. */
|
|
17
|
+
export declare const ASK_ATTACHMENT_BYTES: number;
|
|
18
|
+
/** With less of the question's window left than this, the CLI is not asked: its git and tmux reads may take most of it. */
|
|
19
|
+
export declare const ASK_CONTEXT_MIN_WINDOW_MS = 20000;
|
|
20
|
+
/** What attaching leaves of the question's window, for the question itself to be sent and answered. */
|
|
21
|
+
export declare const ASK_ATTACH_RESERVE_MS = 10000;
|
|
22
|
+
export interface AskContextOriginal {
|
|
23
|
+
summary: SealedAskContext;
|
|
24
|
+
original: {
|
|
25
|
+
diff: string;
|
|
26
|
+
screen: string;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The original as JSON (`{ diff, screen }`, UTF-8) followed by zero bytes up to `ASK_ATTACHMENT_BYTES`; `size`
|
|
31
|
+
* is where the JSON ends. No context, or an original that does not fit, is all zeros and `size` 0 — and no summary.
|
|
32
|
+
*/
|
|
33
|
+
export declare const padAskOriginal: (ctx: AskContextOriginal | null) => {
|
|
34
|
+
bytes: Buffer;
|
|
35
|
+
size: number;
|
|
36
|
+
context?: SealedAskContext;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* What `hwip ask-context show` printed, if it is a context this tool can carry: the summary must pass the same
|
|
40
|
+
* reader this server applies to a sealed question's `context` (`askContext`), and the original must be two
|
|
41
|
+
* strings. Anything else — an older CLI's status line included — is `null`.
|
|
42
|
+
*/
|
|
43
|
+
export declare const readShownContext: (stdout: string) => AskContextOriginal | null;
|
|
44
|
+
export interface AttachDeps {
|
|
45
|
+
/** The context to carry, or `null`; a throw counts as `null`. */
|
|
46
|
+
context: () => Promise<AskContextOriginal | null>;
|
|
47
|
+
encrypt: typeof encryptFileForDevices;
|
|
48
|
+
/** Stores the sealed bytes and returns their `fileKey`. */
|
|
49
|
+
upload: (sealed: Buffer) => Promise<string>;
|
|
50
|
+
}
|
|
51
|
+
/** Seals and uploads the attachment for `recipients`. Throws when sealing or the upload fails. */
|
|
52
|
+
export declare const attachAskContext: (recipients: DeviceRecipient[], deps: AttachDeps) => Promise<AskAttachment>;
|
|
53
|
+
/**
|
|
54
|
+
* `attachAskContext`, given up (it rejects, and the question goes without it) once it would eat into the last
|
|
55
|
+
* `ASK_ATTACH_RESERVE_MS` of a window of `windowMs`: storing the attachment must never hold the question back.
|
|
56
|
+
*/
|
|
57
|
+
export declare const attachWithinWindow: (recipients: DeviceRecipient[], deps: AttachDeps, windowMs: number) => Promise<AskAttachment>;
|
|
58
|
+
/** The context of the session this server runs in, from the CLI; `null` with too little of the window left to ask it. */
|
|
59
|
+
export declare const collectAskContext: (exec: ExecHwip, windowMs: number, signal?: AbortSignal) => Promise<AskContextOriginal | null>;
|
|
60
|
+
/**
|
|
61
|
+
* `AskSealDeps.attach` for real use: the context `hwip` reports for this server's directory and tmux pane,
|
|
62
|
+
* sealed with this machine's keys, stored with the account, within the question's `windowMs`. What goes wrong
|
|
63
|
+
* is said on stderr (stdout is the MCP transport): a context that could not be read goes as the empty
|
|
64
|
+
* attachment, one that could not be stored not at all.
|
|
65
|
+
*/
|
|
66
|
+
export declare const liveAttach: (client: Pick<HwipApiClient, "requestUpload" | "uploadToS3">, windowMs: number, signal?: AbortSignal, exec?: ExecHwip, warn?: (line: string) => void) => (recipients: DeviceRecipient[]) => Promise<AskAttachment>;
|
|
67
|
+
//# sourceMappingURL=ask-context-attach.d.ts.map
|