@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.
- package/.github/extensions/web-relay/extension.mjs +6 -1
- package/.github/extensions/web-relay/skills/tool-activity.mjs +7 -0
- package/CHANGELOG.md +67 -0
- package/README.md +62 -13
- package/package.json +5 -2
- package/server/README.md +43 -3
- package/server/claude-worker/claude-remote-relay-tool.mjs +99 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
- package/server/claude-worker/claude-session-process.mjs +92 -25
- package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
- package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +12 -2
- package/server/copilot-worker/copilot-sdk-session-process.mjs +83 -9
- package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
- package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
- package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
- package/server/db-schema.mjs +8 -0
- package/server/grok-worker/acp-client.mjs +92 -6
- package/server/grok-worker/grok-turn-runner.mjs +59 -8
- package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
- package/server/mcp/oar-mcp-server.mjs +473 -0
- package/server/migrations/0005-remote-relays.mjs +90 -0
- package/server/public/app/action-confirmations.js +141 -13
- package/server/public/app/api-client.js +103 -0
- package/server/public/app/bootstrap.js +21 -6
- package/server/public/app/conversation-deep-link.mjs +42 -0
- package/server/public/app/conversation-view.js +98 -7
- package/server/public/app/host-suspend-ui.js +272 -0
- package/server/public/app/journal-view.js +4 -1
- package/server/public/app/mention-autocomplete.mjs +298 -0
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/pwa-install.js +8 -3
- package/server/public/app/relay-prompt-sanitizer.mjs +48 -5
- package/server/public/app/remote-relay-origin-view.mjs +76 -0
- package/server/public/app/remote-relay-shared.mjs +85 -0
- package/server/public/app/remote-relays-settings.js +618 -0
- package/server/public/app/remote-relays-store.mjs +126 -0
- package/server/public/app/settings-modal.js +4 -2
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +18 -1
- package/server/public/app/thinking-follow.js +260 -0
- package/server/public/app/tool-activity-emoji.mjs +4 -0
- package/server/public/index.html +257 -8
- package/server/relay-tools.md +5 -1
- package/server/repositories/message-repository.mjs +7 -0
- package/server/repositories/question-repository.mjs +3 -0
- package/server/repositories/remote-relay-repository.mjs +75 -0
- package/server/repositories/session-repository.mjs +6 -1
- package/server/routes/ask-user-routes.mjs +42 -2
- package/server/routes/messages-routes.mjs +90 -16
- package/server/routes/remote-relay-routes.mjs +133 -0
- package/server/routes/sessions-routes.mjs +80 -29
- package/server/server-runtime.mjs +238 -0
- package/server/services/github-ci-activity-service.mjs +199 -0
- package/server/services/host-suspend-command.mjs +38 -0
- package/server/services/host-suspend-service.mjs +254 -0
- package/server/services/push-dispatch-service.mjs +37 -4
- package/server/services/remote-relay-caller-context.mjs +341 -0
- package/server/services/remote-relay-client.mjs +332 -0
- package/server/services/remote-relay-dispatcher.mjs +1500 -0
- package/server/services/remote-relay-inbound.mjs +187 -0
- package/server/services/remote-relay-pairing.mjs +189 -0
- package/server/services/remote-relay-registry-service.mjs +581 -0
- package/server/services/session-worker-launch-service.mjs +131 -12
- package/shared/remote-relay-contract.mjs +539 -0
- package/shared/remote-relay-mentions.mjs +129 -0
- package/shared/remote-relay-tool-core.mjs +314 -0
- 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
|
-
|
|
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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
};
|