@oar-sh/oar 0.9.3 → 0.9.4

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.
Files changed (68) hide show
  1. package/.github/extensions/web-relay/extension.mjs +6 -1
  2. package/.github/extensions/web-relay/skills/tool-activity.mjs +7 -0
  3. package/CHANGELOG.md +67 -0
  4. package/README.md +62 -13
  5. package/package.json +5 -2
  6. package/server/README.md +43 -3
  7. package/server/claude-worker/claude-remote-relay-tool.mjs +99 -0
  8. package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
  9. package/server/claude-worker/claude-session-process.mjs +92 -25
  10. package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
  11. package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
  12. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +12 -2
  13. package/server/copilot-worker/copilot-sdk-session-process.mjs +83 -9
  14. package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
  15. package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
  16. package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
  17. package/server/db-schema.mjs +8 -0
  18. package/server/grok-worker/acp-client.mjs +92 -6
  19. package/server/grok-worker/grok-turn-runner.mjs +59 -8
  20. package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
  21. package/server/mcp/oar-mcp-server.mjs +473 -0
  22. package/server/migrations/0005-remote-relays.mjs +90 -0
  23. package/server/public/app/action-confirmations.js +141 -13
  24. package/server/public/app/api-client.js +103 -0
  25. package/server/public/app/bootstrap.js +21 -6
  26. package/server/public/app/conversation-deep-link.mjs +42 -0
  27. package/server/public/app/conversation-view.js +98 -7
  28. package/server/public/app/host-suspend-ui.js +272 -0
  29. package/server/public/app/journal-view.js +4 -1
  30. package/server/public/app/mention-autocomplete.mjs +298 -0
  31. package/server/public/app/push-settings.js +1 -0
  32. package/server/public/app/pwa-install.js +8 -3
  33. package/server/public/app/relay-prompt-sanitizer.mjs +48 -5
  34. package/server/public/app/remote-relay-origin-view.mjs +76 -0
  35. package/server/public/app/remote-relay-shared.mjs +85 -0
  36. package/server/public/app/remote-relays-settings.js +618 -0
  37. package/server/public/app/remote-relays-store.mjs +126 -0
  38. package/server/public/app/settings-modal.js +4 -2
  39. package/server/public/app/settings-tabs.js +1 -1
  40. package/server/public/app/socket-handlers.js +18 -1
  41. package/server/public/app/thinking-follow.js +260 -0
  42. package/server/public/app/tool-activity-emoji.mjs +4 -0
  43. package/server/public/index.html +257 -8
  44. package/server/relay-tools.md +5 -1
  45. package/server/repositories/message-repository.mjs +7 -0
  46. package/server/repositories/question-repository.mjs +3 -0
  47. package/server/repositories/remote-relay-repository.mjs +75 -0
  48. package/server/repositories/session-repository.mjs +6 -1
  49. package/server/routes/ask-user-routes.mjs +42 -2
  50. package/server/routes/messages-routes.mjs +90 -16
  51. package/server/routes/remote-relay-routes.mjs +133 -0
  52. package/server/routes/sessions-routes.mjs +80 -29
  53. package/server/server-runtime.mjs +238 -0
  54. package/server/services/github-ci-activity-service.mjs +199 -0
  55. package/server/services/host-suspend-command.mjs +38 -0
  56. package/server/services/host-suspend-service.mjs +254 -0
  57. package/server/services/push-dispatch-service.mjs +37 -4
  58. package/server/services/remote-relay-caller-context.mjs +341 -0
  59. package/server/services/remote-relay-client.mjs +332 -0
  60. package/server/services/remote-relay-dispatcher.mjs +1500 -0
  61. package/server/services/remote-relay-inbound.mjs +187 -0
  62. package/server/services/remote-relay-pairing.mjs +189 -0
  63. package/server/services/remote-relay-registry-service.mjs +581 -0
  64. package/server/services/session-worker-launch-service.mjs +131 -12
  65. package/shared/remote-relay-contract.mjs +539 -0
  66. package/shared/remote-relay-mentions.mjs +129 -0
  67. package/shared/remote-relay-tool-core.mjs +314 -0
  68. package/shared/worker-runtime/api-client.mjs +112 -22
@@ -0,0 +1,314 @@
1
+ // The provider-facing side of the `remote_relay` tool, in one place.
2
+ //
3
+ // Every adapter (Claude's in-process MCP server, Cursor's customTools, the
4
+ // Copilot SDK engine's session tools, and the OAR stdio MCP server that Grok
5
+ // and the Copilot extension engine reach) registers the SAME name, description
6
+ // and schema from the contract and forwards every call to the one local relay
7
+ // endpoint. The relay does the gating, the approval, the forwarding and the
8
+ // waiting; nothing here knows about remote relays beyond the wire shape.
9
+ //
10
+ // SDK-free and dependency-injected, following preview-tool-core.mjs: all relay
11
+ // traffic goes through the worker's injected `api(method, path, body, options)`
12
+ // helper (shared/worker-runtime/api-client.mjs).
13
+
14
+ import {
15
+ REMOTE_RELAY_ERROR_CODES,
16
+ REMOTE_RELAY_TOOL_DESCRIPTION,
17
+ REMOTE_RELAY_TOOL_ENDPOINT,
18
+ REMOTE_RELAY_TOOL_INPUT_SCHEMA,
19
+ REMOTE_RELAY_TOOL_NAME,
20
+ summarizeRemoteRelayCall,
21
+ validateRemoteRelayToolInput,
22
+ } from './remote-relay-contract.mjs';
23
+
24
+ export {
25
+ REMOTE_RELAY_TOOL_DESCRIPTION,
26
+ REMOTE_RELAY_TOOL_ENDPOINT,
27
+ REMOTE_RELAY_TOOL_INPUT_SCHEMA,
28
+ REMOTE_RELAY_TOOL_NAME,
29
+ summarizeRemoteRelayCall,
30
+ };
31
+
32
+ // The adapters' crisp early check; the relay validates again authoritatively.
33
+ export const validateRemoteRelayTool = validateRemoteRelayToolInput;
34
+
35
+ /**
36
+ * A private deep copy of the contract schema for SDKs that take ownership of
37
+ * the object they are handed (the contract's is frozen at the top level, and
38
+ * an SDK that annotates its input would throw on it).
39
+ */
40
+ export function cloneRemoteRelayToolInputSchema() {
41
+ return JSON.parse(JSON.stringify(REMOTE_RELAY_TOOL_INPUT_SCHEMA));
42
+ }
43
+
44
+ // How many remotes this relay knows (`{ count }`): the registration decision.
45
+ export const REMOTE_RELAY_SUMMARY_PATH = '/api/remote-relays/summary';
46
+ // Calls of one conversation the relay is still working on (`{ inflight }`):
47
+ // the Grok inactivity hold, which cannot see the tool call itself.
48
+ export const REMOTE_RELAY_INFLIGHT_PATH = '/api/remote-relays/inflight';
49
+
50
+ // The local relay could not be asked at all, or answered without a body the
51
+ // model could read (a transport error, a crash page). Distinct from every
52
+ // contract code, which describe a refusal the relay itself made.
53
+ export const REMOTE_RELAY_CALL_FAILED = 'REMOTE_RELAY_CALL_FAILED';
54
+
55
+ // The provider cancelled the call (the user's Stop, a host's cancel, a
56
+ // runtime going away) before the relay answered. The relay sees the request
57
+ // close and stops too: it withdraws an open approval card and stops waiting
58
+ // on the remote. A prompt already handed over is NOT taken back.
59
+ export const REMOTE_RELAY_CANCELLED = 'REMOTE_RELAY_CANCELLED';
60
+
61
+ // How long the registration probe may take before the tool is left out.
62
+ export const REMOTE_RELAY_DECISION_TIMEOUT_MS = 5_000;
63
+
64
+ // A hard ceiling for one tool call on the providers that impose their own
65
+ // (MCP clients). The relay already bounds the wait (wait_seconds ≤ 600) but not
66
+ // the time an approval card may sit unanswered, so this only has to outlast a
67
+ // human, never a remote turn.
68
+ export const REMOTE_RELAY_TOOL_CALL_TIMEOUT_MS = 8 * 60 * 60_000;
69
+
70
+ function toText(value) {
71
+ return String(value ?? '').trim();
72
+ }
73
+
74
+ function withTimeout(promise, timeoutMs, label) {
75
+ if (!(Number(timeoutMs) > 0)) return promise;
76
+ let timer = null;
77
+ return Promise.race([
78
+ promise,
79
+ new Promise((_, reject) => {
80
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
81
+ timer.unref?.();
82
+ }),
83
+ ]).finally(() => { if (timer) clearTimeout(timer); });
84
+ }
85
+
86
+ function errorBodyOf(error) {
87
+ // `body` is the parsed JSON error body when the api client kept it; a
88
+ // client that parsed it into `detail` instead is tolerated too.
89
+ for (const candidate of [error?.body, error?.detail]) {
90
+ if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) return candidate;
91
+ }
92
+ return null;
93
+ }
94
+
95
+ /**
96
+ * Maps an api-client failure onto the result shape the model reads. The relay
97
+ * answers a refusal with `{ ok:false, code, error }` and a 4xx/5xx status; that
98
+ * body IS the answer (LOCKED, FORBIDDEN, OFFLINE, …), so it is passed through
99
+ * as-is. Anything without such a body becomes REMOTE_RELAY_CALL_FAILED.
100
+ */
101
+ export function remoteRelayErrorResult(error) {
102
+ const status = Number(error?.status) || 0;
103
+ const body = errorBodyOf(error);
104
+ const detail = typeof error?.detail === 'string' ? toText(error.detail) : '';
105
+ if (body && (body.code || body.error)) {
106
+ return {
107
+ ...body,
108
+ ok: false,
109
+ code: toText(body.code) || REMOTE_RELAY_CALL_FAILED,
110
+ error: toText(body.error || body.message) || detail || `HTTP ${status}`,
111
+ };
112
+ }
113
+ if (status === 404) {
114
+ // A relay that predates the feature (or runs without its routes).
115
+ return {
116
+ ok: false,
117
+ code: REMOTE_RELAY_ERROR_CODES.unsupported,
118
+ error: 'This relay does not offer remote relays (the tool endpoint is missing). Tell the user.',
119
+ };
120
+ }
121
+ return {
122
+ ok: false,
123
+ code: REMOTE_RELAY_CALL_FAILED,
124
+ ...(status ? { status } : {}),
125
+ error: detail || toText(error?.message || error) || 'The relay could not be reached',
126
+ };
127
+ }
128
+
129
+ function isAbortError(error) {
130
+ return error?.name === 'AbortError' || error?.code === 'ABORT_ERR';
131
+ }
132
+
133
+ /** The answer for a call its provider cancelled before the relay answered. */
134
+ export function remoteRelayCancelledResult() {
135
+ return {
136
+ ok: false,
137
+ code: REMOTE_RELAY_CANCELLED,
138
+ error: 'The call was cancelled before the relay answered. A message it had already handed over may still run on the remote.',
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Executes one `remote_relay` call: validate, then forward
144
+ * `{ conversationId, action, args }` to the local relay and hand back its JSON.
145
+ * Never throws — a refusal reads as an answer the model can act on, the same
146
+ * contract as executePreviewTool.
147
+ *
148
+ * The call goes out as a long call (`{ longCall: true }`): no client-side
149
+ * timeout at all, because the relay bounds the wait itself (wait_seconds plus
150
+ * approval time) and fetch's 300 s header limit would cut both short.
151
+ * `signal` (the provider's cancellation) is the only way to end it early.
152
+ */
153
+ export async function executeRemoteRelayTool(input, { api, conversationId = '', signal = null } = {}) {
154
+ if (typeof api !== 'function') {
155
+ return { ok: false, code: REMOTE_RELAY_CALL_FAILED, error: 'Remote relay API unavailable in this worker' };
156
+ }
157
+ const conversation = toText(conversationId);
158
+ if (!conversation) {
159
+ return { ok: false, code: REMOTE_RELAY_CALL_FAILED, error: 'No conversation is bound to this call' };
160
+ }
161
+ const parsed = validateRemoteRelayToolInput(input);
162
+ if (!parsed.ok) return parsed;
163
+ if (signal?.aborted) return remoteRelayCancelledResult();
164
+ try {
165
+ const response = await api('POST', REMOTE_RELAY_TOOL_ENDPOINT, {
166
+ conversationId: conversation,
167
+ action: parsed.action,
168
+ args: parsed.args,
169
+ }, { longCall: true, ...(signal ? { signal } : {}) });
170
+ if (response && typeof response === 'object' && !Array.isArray(response)) return response;
171
+ return { ok: true, result: response ?? null };
172
+ } catch (error) {
173
+ if (signal?.aborted || isAbortError(error)) return remoteRelayCancelledResult();
174
+ return remoteRelayErrorResult(error);
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Whether a worker should register the tool: at least one remote is paired.
180
+ * Fails closed — any error, a malformed answer or a slow relay leaves the tool
181
+ * out, and the next worker start asks again.
182
+ */
183
+ export async function shouldRegisterRemoteRelayTool({ api, timeoutMs = REMOTE_RELAY_DECISION_TIMEOUT_MS } = {}) {
184
+ if (typeof api !== 'function') return false;
185
+ try {
186
+ const response = await withTimeout(
187
+ Promise.resolve().then(() => api('GET', REMOTE_RELAY_SUMMARY_PATH)),
188
+ timeoutMs,
189
+ 'remote relay summary',
190
+ );
191
+ return Number(response?.count) > 0;
192
+ } catch {
193
+ return false;
194
+ }
195
+ }
196
+
197
+ /**
198
+ * The registration decision as a worker holds it: asked once when the worker
199
+ * starts (`refresh()`), read synchronously where a tool set is fixed
200
+ * (`isEnabled()`), and awaitable where the first build may wait for it
201
+ * (`ready()`, which never rejects). `fixed` pins the answer (tests, kill
202
+ * switches) without asking the relay.
203
+ */
204
+ export function createRemoteRelayToolGate({
205
+ api,
206
+ fixed = undefined,
207
+ decide = shouldRegisterRemoteRelayTool,
208
+ timeoutMs = REMOTE_RELAY_DECISION_TIMEOUT_MS,
209
+ } = {}) {
210
+ if (typeof fixed === 'boolean') {
211
+ return {
212
+ isEnabled: () => fixed,
213
+ isSettled: () => true,
214
+ refresh: () => Promise.resolve(fixed),
215
+ ready: () => Promise.resolve(fixed),
216
+ };
217
+ }
218
+ let enabled = false;
219
+ let settledOnce = false;
220
+ let pending = null;
221
+ function refresh() {
222
+ if (!pending) {
223
+ pending = Promise.resolve()
224
+ .then(() => decide({ api, timeoutMs }))
225
+ .then((value) => { enabled = value === true; }, () => { enabled = false; })
226
+ .then(() => {
227
+ settledOnce = true;
228
+ pending = null;
229
+ return enabled;
230
+ });
231
+ }
232
+ return pending;
233
+ }
234
+ return {
235
+ isEnabled: () => enabled,
236
+ // True once an answer is in and no refresh is running: a caller can then
237
+ // read isEnabled() without awaiting ready().
238
+ isSettled: () => settledOnce && !pending,
239
+ refresh,
240
+ ready: () => (settledOnce && !pending ? Promise.resolve(enabled) : (pending || refresh())),
241
+ };
242
+ }
243
+
244
+ /**
245
+ * How many remote_relay calls of this conversation the relay is still working
246
+ * on. 0 on any failure: the caller's watchdog then does what it would have
247
+ * done without the question.
248
+ */
249
+ export async function fetchRemoteRelayInflight({ api, conversationId = '', timeoutMs = 10_000 } = {}) {
250
+ const conversation = toText(conversationId);
251
+ if (typeof api !== 'function' || !conversation) return 0;
252
+ try {
253
+ const response = await withTimeout(
254
+ Promise.resolve().then(() => api(
255
+ 'GET',
256
+ `${REMOTE_RELAY_INFLIGHT_PATH}?conversationId=${encodeURIComponent(conversation)}`,
257
+ )),
258
+ timeoutMs,
259
+ 'remote relay inflight',
260
+ );
261
+ const count = Number(response?.inflight);
262
+ return Number.isFinite(count) && count > 0 ? count : 0;
263
+ } catch {
264
+ return 0;
265
+ }
266
+ }
267
+
268
+ /** The text every adapter hands the model: the relay's JSON, pretty-printed. */
269
+ export function formatRemoteRelayToolResult(result) {
270
+ try {
271
+ const text = JSON.stringify(result ?? null, null, 2);
272
+ return text === undefined ? 'null' : text;
273
+ } catch {
274
+ return String(result);
275
+ }
276
+ }
277
+
278
+ // ─── Activity lines ──────────────────────────────────────────────────────────
279
+
280
+ // A provider may report the tool under its bare name or namespaced by the MCP
281
+ // server that carries it (`mcp__relay__remote_relay`, `oar__remote_relay`,
282
+ // `oar/remote_relay`, `oar-remote_relay`); all of them are this tool.
283
+ const REMOTE_RELAY_NAME_PATTERN = new RegExp(`(?:^|__|[^A-Za-z0-9_])${REMOTE_RELAY_TOOL_NAME}$`, 'i');
284
+
285
+ export function isRemoteRelayToolName(name) {
286
+ return REMOTE_RELAY_NAME_PATTERN.test(toText(name));
287
+ }
288
+
289
+ /**
290
+ * The arguments of a tool call as an object, whether the provider delivered
291
+ * them parsed or as a JSON string. Null when there is nothing usable.
292
+ */
293
+ export function remoteRelayCallInput(rawInput) {
294
+ let input = rawInput;
295
+ if (typeof input === 'string') {
296
+ try {
297
+ input = JSON.parse(input);
298
+ } catch {
299
+ return null;
300
+ }
301
+ }
302
+ return input && typeof input === 'object' && !Array.isArray(input) ? input : null;
303
+ }
304
+
305
+ /**
306
+ * The summary part of the activity line (`send → linux-test session 01234567:
307
+ * “…”`), or '' when the call carries no action yet (a streaming frame before
308
+ * its arguments arrived) so the caller keeps its generic line.
309
+ */
310
+ export function remoteRelayActivitySummary(rawInput) {
311
+ const input = remoteRelayCallInput(rawInput);
312
+ if (!input || !toText(input.action)) return '';
313
+ return summarizeRemoteRelayCall(input);
314
+ }
@@ -1,33 +1,123 @@
1
+ import http from "node:http";
2
+ import https from "node:https";
3
+
4
+ // The error a non-2xx answer throws, the same for both transports:
5
+ // `HTTP <status> <path>: <detail>`, with `status`, `detail` and `body`.
6
+ function httpError(status, routePath, text) {
7
+ const rawText = String(text || "").trim();
8
+ let detail = rawText;
9
+ let payload = null;
10
+ if (rawText) {
11
+ try {
12
+ payload = JSON.parse(rawText);
13
+ detail = String(payload?.error || payload?.message || rawText).trim();
14
+ } catch {
15
+ detail = rawText;
16
+ }
17
+ }
18
+ const error = new Error(`HTTP ${status} ${routePath}${detail ? `: ${detail}` : ""}`);
19
+ error.status = status;
20
+ error.detail = detail;
21
+ // The parsed error body, for callers that pass the relay's answer on as
22
+ // is (a refused remote_relay call carries its code there).
23
+ error.body = payload && typeof payload === "object" ? payload : null;
24
+ return error;
25
+ }
26
+
27
+ function abortError(signal) {
28
+ const error = new Error("The operation was aborted");
29
+ error.name = "AbortError";
30
+ error.code = "ABORT_ERR";
31
+ if (signal?.reason !== undefined) error.cause = signal.reason;
32
+ return error;
33
+ }
34
+
35
+ /**
36
+ * One request over node:http(s) with no clock on it at all. Global fetch
37
+ * (undici) gives up when no response headers arrive within 300 s, and a
38
+ * long call's route sends nothing until it has its answer: a remote turn of
39
+ * up to 600 s, or an approval card that may sit for hours. So: its own
40
+ * connection (`agent: false` — the global agent carries a socket timeout),
41
+ * no request timeout, no body timeout. Only `signal` ends it early; the
42
+ * relay notices the closed request and stops waiting on its side too.
43
+ */
44
+ function requestWithoutTimeout({ url, method, headers, payload, signal }) {
45
+ return new Promise((resolve, reject) => {
46
+ if (signal?.aborted) {
47
+ reject(abortError(signal));
48
+ return;
49
+ }
50
+ const target = new URL(url);
51
+ const transport = target.protocol === "https:" ? https : http;
52
+ let settled = false;
53
+ let req = null;
54
+ const onAbort = () => {
55
+ const error = abortError(signal);
56
+ finish(error);
57
+ req?.destroy(error);
58
+ };
59
+ function finish(error, value) {
60
+ if (settled) return;
61
+ settled = true;
62
+ signal?.removeEventListener?.("abort", onAbort);
63
+ if (error) reject(error);
64
+ else resolve(value);
65
+ }
66
+ req = transport.request(target, {
67
+ method,
68
+ agent: false,
69
+ headers: {
70
+ ...headers,
71
+ ...(payload !== undefined ? { "Content-Length": Buffer.byteLength(payload) } : {}),
72
+ },
73
+ }, (res) => {
74
+ const chunks = [];
75
+ res.on("data", (chunk) => { chunks.push(chunk); });
76
+ res.on("end", () => finish(null, { status: res.statusCode, text: Buffer.concat(chunks).toString("utf8") }));
77
+ res.on("error", (error) => finish(error));
78
+ res.on("close", () => {
79
+ if (!res.complete) finish(new Error(`The relay closed the connection mid-answer (${url})`));
80
+ });
81
+ });
82
+ req.on("error", (error) => finish(error));
83
+ signal?.addEventListener?.("abort", onAbort, { once: true });
84
+ if (payload !== undefined) req.write(payload);
85
+ req.end();
86
+ });
87
+ }
88
+
1
89
  export function createApiClient({ serverUrl, token, getHeaders }) {
2
- return async function api(method, routePath, body) {
90
+ /**
91
+ * `options.longCall`: a call the relay may legitimately hold open for many
92
+ * minutes (the remote_relay tool) — it goes over node:http without any
93
+ * timeout instead of fetch. `options.signal` aborts either kind; an aborted
94
+ * call rejects with an AbortError.
95
+ */
96
+ return async function api(method, routePath, body, { longCall = false, signal = null } = {}) {
3
97
  const url = `${serverUrl}${routePath}`;
4
98
  const extraHeaders = typeof getHeaders === "function" ? (getHeaders() || {}) : {};
99
+ const headers = {
100
+ "Content-Type": "application/json",
101
+ Authorization: `Bearer ${token}`,
102
+ ...extraHeaders,
103
+ };
104
+ const payload = method !== "GET" ? JSON.stringify(body || {}) : undefined;
105
+
106
+ if (longCall) {
107
+ const res = await requestWithoutTimeout({ url, method, headers, payload, signal });
108
+ if (res.status < 200 || res.status > 299) throw httpError(res.status, routePath, res.text);
109
+ return JSON.parse(res.text);
110
+ }
111
+
5
112
  const opts = {
6
113
  method,
7
- headers: {
8
- "Content-Type": "application/json",
9
- Authorization: `Bearer ${token}`,
10
- ...extraHeaders,
11
- },
12
- ...(method !== "GET" ? { body: JSON.stringify(body || {}) } : {}),
114
+ headers,
115
+ ...(payload !== undefined ? { body: payload } : {}),
116
+ ...(signal ? { signal } : {}),
13
117
  };
14
-
15
118
  const res = await fetch(url, opts);
16
119
  if (!res.ok) {
17
- const rawText = String(await res.text().catch(() => "")).trim();
18
- let detail = rawText;
19
- if (rawText) {
20
- try {
21
- const payload = JSON.parse(rawText);
22
- detail = String(payload?.error || payload?.message || rawText).trim();
23
- } catch {
24
- detail = rawText;
25
- }
26
- }
27
- const error = new Error(`HTTP ${res.status} ${routePath}${detail ? `: ${detail}` : ""}`);
28
- error.status = res.status;
29
- error.detail = detail;
30
- throw error;
120
+ throw httpError(res.status, routePath, await res.text().catch(() => ""));
31
121
  }
32
122
  return res.json();
33
123
  };