@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,473 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// OAR's own stdio MCP server. It gives the agents OAR launches without an
|
|
3
|
+
// in-process tool surface — Grok (ACP `mcpServers`) and the Copilot CLI
|
|
4
|
+
// extension engine (`--additional-mcp-config`) — the relay's real tools:
|
|
5
|
+
// `remote_relay` and `preview`, bound to the one conversation it was started
|
|
6
|
+
// for, so the relay's mention gate and approval apply exactly as they do for
|
|
7
|
+
// the Claude, Cursor and Copilot SDK workers.
|
|
8
|
+
//
|
|
9
|
+
// Hand-written on purpose (no MCP SDK dependency): JSON-RPC 2.0 as
|
|
10
|
+
// newline-delimited JSON on stdin/stdout. stdout carries protocol frames ONLY;
|
|
11
|
+
// every diagnostic goes to stderr.
|
|
12
|
+
//
|
|
13
|
+
// node server/mcp/oar-mcp-server.mjs --conversation-id <id>
|
|
14
|
+
//
|
|
15
|
+
// The relay is found the way the session workers find it: the config at
|
|
16
|
+
// COPILOT_WEB_RELAY_CONFIG (else server/config.json) supplies the token and
|
|
17
|
+
// port, COPILOT_WEB_RELAY_SERVER_URL overrides the address.
|
|
18
|
+
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import readline from 'node:readline';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
|
|
24
|
+
import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
|
|
25
|
+
import { loadTokenFromConfig, resolveRelayServerUrl } from '../../shared/worker-runtime/config-loader.mjs';
|
|
26
|
+
import {
|
|
27
|
+
PREVIEW_TOOL_DESCRIPTION,
|
|
28
|
+
PREVIEW_TOOL_INPUT_SCHEMA,
|
|
29
|
+
PREVIEW_TOOL_NAME,
|
|
30
|
+
executePreviewTool,
|
|
31
|
+
} from '../../shared/preview-tool-core.mjs';
|
|
32
|
+
import {
|
|
33
|
+
REMOTE_RELAY_CALL_FAILED,
|
|
34
|
+
REMOTE_RELAY_TOOL_CALL_TIMEOUT_MS,
|
|
35
|
+
REMOTE_RELAY_TOOL_DESCRIPTION,
|
|
36
|
+
REMOTE_RELAY_TOOL_NAME,
|
|
37
|
+
cloneRemoteRelayToolInputSchema,
|
|
38
|
+
executeRemoteRelayTool,
|
|
39
|
+
formatRemoteRelayToolResult,
|
|
40
|
+
shouldRegisterRemoteRelayTool,
|
|
41
|
+
} from '../../shared/remote-relay-tool-core.mjs';
|
|
42
|
+
|
|
43
|
+
const MODULE_PATH = fileURLToPath(import.meta.url);
|
|
44
|
+
|
|
45
|
+
/** The server name hosts register it under (`oar`), and its `serverInfo.name`. */
|
|
46
|
+
export const OAR_MCP_SERVER_NAME = 'oar';
|
|
47
|
+
export const OAR_MCP_SERVER_SCRIPT_PATH = MODULE_PATH;
|
|
48
|
+
|
|
49
|
+
export const OAR_MCP_DEFAULT_PROTOCOL_VERSION = '2025-06-18';
|
|
50
|
+
// Everything this server speaks is the tools subset every revision shares, so
|
|
51
|
+
// a client's requested revision is echoed back when it is one of these.
|
|
52
|
+
export const OAR_MCP_SUPPORTED_PROTOCOL_VERSIONS = Object.freeze([
|
|
53
|
+
'2025-11-25',
|
|
54
|
+
'2025-06-18',
|
|
55
|
+
'2025-03-26',
|
|
56
|
+
'2024-11-05',
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
// A host may start MCP servers with a trimmed environment, so the variables
|
|
60
|
+
// that locate the relay travel explicitly with the launch. None is a secret:
|
|
61
|
+
// the token stays in the config file the path points at.
|
|
62
|
+
export const OAR_MCP_RELAY_ENV_KEYS = Object.freeze([
|
|
63
|
+
'COPILOT_WEB_RELAY_CONFIG',
|
|
64
|
+
'COPILOT_WEB_RELAY_SERVER_URL',
|
|
65
|
+
'COPILOT_WEB_RELAY_ROOT',
|
|
66
|
+
'COPILOT_WEB_RELAY_SERVER_DIR',
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
// How long a host should let one tool call run (for hosts that take a
|
|
70
|
+
// per-server timeout, like the Copilot CLI whose default is 180 s).
|
|
71
|
+
export const OAR_MCP_TOOL_TIMEOUT_MS = REMOTE_RELAY_TOOL_CALL_TIMEOUT_MS;
|
|
72
|
+
|
|
73
|
+
// Progress heartbeats for a long call, sent only when the client asked for
|
|
74
|
+
// progress. Hosts that reset their timeout on progress keep waiting.
|
|
75
|
+
const DEFAULT_PROGRESS_INTERVAL_MS = 25_000;
|
|
76
|
+
|
|
77
|
+
// After the host closes stdin: how long a call still waiting on the relay may
|
|
78
|
+
// keep the process up before it is cut off.
|
|
79
|
+
const SHUTDOWN_GRACE_MS = 2_000;
|
|
80
|
+
|
|
81
|
+
const JSON_RPC = Object.freeze({
|
|
82
|
+
parseError: -32700,
|
|
83
|
+
invalidRequest: -32600,
|
|
84
|
+
methodNotFound: -32601,
|
|
85
|
+
invalidParams: -32602,
|
|
86
|
+
internalError: -32603,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
function toText(value) {
|
|
90
|
+
return String(value ?? '').trim();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The operator's kill switch: `OAR_MCP_SERVER=0|false|off|no` keeps this
|
|
95
|
+
* server out of every session OAR launches (Grok falls back to its preview
|
|
96
|
+
* instruction block, the extension engine starts without the extra config).
|
|
97
|
+
*/
|
|
98
|
+
export function isOarMcpServerDisabled(env = {}) {
|
|
99
|
+
return /^(0|false|off|no)$/i.test(String(env?.OAR_MCP_SERVER || '').trim());
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** `--conversation-id <id>` or `--conversation-id=<id>`; '' when absent. */
|
|
103
|
+
export function parseConversationIdArg(argv = []) {
|
|
104
|
+
const args = Array.isArray(argv) ? argv.map((arg) => String(arg ?? '')) : [];
|
|
105
|
+
const index = args.indexOf('--conversation-id');
|
|
106
|
+
if (index !== -1) return toText(args[index + 1]);
|
|
107
|
+
const inline = args.find((arg) => arg.startsWith('--conversation-id='));
|
|
108
|
+
return inline ? toText(inline.slice('--conversation-id='.length)) : '';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* How to start this server for one conversation: `{ command, args, env }`,
|
|
113
|
+
* the neutral shape the Grok ACP wiring and the Copilot extension-engine
|
|
114
|
+
* config are built from. `env` holds only the relay-locating variables that
|
|
115
|
+
* are set in `env`.
|
|
116
|
+
*/
|
|
117
|
+
export function buildOarMcpServerLaunch({
|
|
118
|
+
nodePath = process.execPath,
|
|
119
|
+
conversationId = '',
|
|
120
|
+
env = process.env,
|
|
121
|
+
scriptPath = MODULE_PATH,
|
|
122
|
+
} = {}) {
|
|
123
|
+
const id = toText(conversationId);
|
|
124
|
+
if (!id) throw new Error('oar-mcp-server: a conversation id is required');
|
|
125
|
+
const command = toText(nodePath);
|
|
126
|
+
if (!command) throw new Error('oar-mcp-server: a node executable is required');
|
|
127
|
+
const launchEnv = {};
|
|
128
|
+
for (const key of OAR_MCP_RELAY_ENV_KEYS) {
|
|
129
|
+
const value = toText(env?.[key]);
|
|
130
|
+
if (value) launchEnv[key] = value;
|
|
131
|
+
}
|
|
132
|
+
return { command, args: [toText(scriptPath), '--conversation-id', id], env: launchEnv };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function readPackageVersion() {
|
|
136
|
+
try {
|
|
137
|
+
const packagePath = path.resolve(path.dirname(MODULE_PATH), '..', '..', 'package.json');
|
|
138
|
+
return toText(JSON.parse(fs.readFileSync(packagePath, 'utf8'))?.version) || '0.0.0';
|
|
139
|
+
} catch {
|
|
140
|
+
return '0.0.0';
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function toolDefinitions({ remoteRelay }) {
|
|
145
|
+
return [
|
|
146
|
+
...(remoteRelay ? [{
|
|
147
|
+
name: REMOTE_RELAY_TOOL_NAME,
|
|
148
|
+
description: REMOTE_RELAY_TOOL_DESCRIPTION,
|
|
149
|
+
inputSchema: cloneRemoteRelayToolInputSchema(),
|
|
150
|
+
}] : []),
|
|
151
|
+
{
|
|
152
|
+
name: PREVIEW_TOOL_NAME,
|
|
153
|
+
description: PREVIEW_TOOL_DESCRIPTION,
|
|
154
|
+
inputSchema: JSON.parse(JSON.stringify(PREVIEW_TOOL_INPUT_SCHEMA)),
|
|
155
|
+
},
|
|
156
|
+
];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function requestKey(id) {
|
|
160
|
+
return `${typeof id}:${String(id)}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The protocol core, transport-free: feed it parsed JSON-RPC messages
|
|
165
|
+
* (`handleMessage`) or raw lines (`handleLine`) and it answers through `send`.
|
|
166
|
+
* `remoteRelay` is the registration decision — a boolean or a promise of one;
|
|
167
|
+
* the tool list waits for it, `initialize` and `ping` do not.
|
|
168
|
+
*/
|
|
169
|
+
export function createOarMcpServer({
|
|
170
|
+
api,
|
|
171
|
+
conversationId = '',
|
|
172
|
+
remoteRelay = false,
|
|
173
|
+
version = '0.0.0',
|
|
174
|
+
send = () => {},
|
|
175
|
+
log = () => {},
|
|
176
|
+
progressIntervalMs = DEFAULT_PROGRESS_INTERVAL_MS,
|
|
177
|
+
} = {}) {
|
|
178
|
+
const conversation = toText(conversationId);
|
|
179
|
+
const remoteRelayDecision = Promise.resolve(remoteRelay).then((value) => value === true, () => false);
|
|
180
|
+
const inflight = new Map();
|
|
181
|
+
|
|
182
|
+
function reply(id, result) {
|
|
183
|
+
return { jsonrpc: '2.0', id, result };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function fail(id, code, message, data) {
|
|
187
|
+
return { jsonrpc: '2.0', id: id ?? null, error: { code, message, ...(data === undefined ? {} : { data }) } };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function listTools() {
|
|
191
|
+
return toolDefinitions({ remoteRelay: await remoteRelayDecision });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function runTool(name, args, signal) {
|
|
195
|
+
if (name === REMOTE_RELAY_TOOL_NAME) {
|
|
196
|
+
const result = await executeRemoteRelayTool(args, { api, conversationId: conversation, signal });
|
|
197
|
+
return formatRemoteRelayToolResult(result);
|
|
198
|
+
}
|
|
199
|
+
const result = await executePreviewTool(args, { api, conversationId: conversation });
|
|
200
|
+
return JSON.stringify(result);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function startProgress(progressToken) {
|
|
204
|
+
if (progressToken === undefined || progressToken === null || !(Number(progressIntervalMs) > 0)) return () => {};
|
|
205
|
+
let progress = 0;
|
|
206
|
+
const timer = setInterval(() => {
|
|
207
|
+
progress += 1;
|
|
208
|
+
send({
|
|
209
|
+
jsonrpc: '2.0',
|
|
210
|
+
method: 'notifications/progress',
|
|
211
|
+
params: { progressToken, progress, message: 'Still waiting for the relay…' },
|
|
212
|
+
});
|
|
213
|
+
}, progressIntervalMs);
|
|
214
|
+
timer.unref?.();
|
|
215
|
+
return () => clearInterval(timer);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function callTool(id, params) {
|
|
219
|
+
const name = toText(params?.name);
|
|
220
|
+
const tools = await listTools();
|
|
221
|
+
if (!tools.some((tool) => tool.name === name)) {
|
|
222
|
+
return fail(id, JSON_RPC.invalidParams, `Unknown tool: ${name || '(none)'}`);
|
|
223
|
+
}
|
|
224
|
+
const args = params?.arguments && typeof params.arguments === 'object' && !Array.isArray(params.arguments)
|
|
225
|
+
? params.arguments
|
|
226
|
+
: {};
|
|
227
|
+
const entry = { cancelled: false, controller: new AbortController() };
|
|
228
|
+
const key = requestKey(id);
|
|
229
|
+
inflight.set(key, entry);
|
|
230
|
+
const stopProgress = startProgress(params?._meta?.progressToken);
|
|
231
|
+
try {
|
|
232
|
+
const text = await runTool(name, args, entry.controller.signal);
|
|
233
|
+
return entry.cancelled ? null : reply(id, { content: [{ type: 'text', text }] });
|
|
234
|
+
} catch (error) {
|
|
235
|
+
// The cores never throw by contract; this is the belt to their braces,
|
|
236
|
+
// and still an answer the model can read rather than a protocol error.
|
|
237
|
+
const message = error?.message || String(error);
|
|
238
|
+
log(`tool ${name} failed: ${message}`);
|
|
239
|
+
if (entry.cancelled) return null;
|
|
240
|
+
return reply(id, {
|
|
241
|
+
content: [{ type: 'text', text: formatRemoteRelayToolResult({ ok: false, code: REMOTE_RELAY_CALL_FAILED, error: message }) }],
|
|
242
|
+
isError: true,
|
|
243
|
+
});
|
|
244
|
+
} finally {
|
|
245
|
+
stopProgress();
|
|
246
|
+
inflight.delete(key);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function handleRequest(message) {
|
|
251
|
+
const { id, method, params } = message;
|
|
252
|
+
switch (method) {
|
|
253
|
+
case 'initialize': {
|
|
254
|
+
const requested = toText(params?.protocolVersion);
|
|
255
|
+
const protocolVersion = OAR_MCP_SUPPORTED_PROTOCOL_VERSIONS.includes(requested)
|
|
256
|
+
? requested
|
|
257
|
+
: OAR_MCP_DEFAULT_PROTOCOL_VERSION;
|
|
258
|
+
return reply(id, {
|
|
259
|
+
protocolVersion,
|
|
260
|
+
capabilities: { tools: {} },
|
|
261
|
+
serverInfo: { name: OAR_MCP_SERVER_NAME, version },
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
case 'ping':
|
|
265
|
+
return reply(id, {});
|
|
266
|
+
case 'tools/list':
|
|
267
|
+
return reply(id, { tools: await listTools() });
|
|
268
|
+
case 'tools/call':
|
|
269
|
+
return callTool(id, params);
|
|
270
|
+
default:
|
|
271
|
+
return fail(id, JSON_RPC.methodNotFound, `Method not found: ${method}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// A cancelled call gets no answer (the spec's rule), and its relay request is
|
|
276
|
+
// aborted: the relay then withdraws an open approval card and stops waiting
|
|
277
|
+
// on the remote instead of working for nobody.
|
|
278
|
+
function cancel(entry) {
|
|
279
|
+
entry.cancelled = true;
|
|
280
|
+
entry.controller.abort();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function handleNotification(message) {
|
|
284
|
+
if (message.method === 'notifications/cancelled') {
|
|
285
|
+
const entry = inflight.get(requestKey(message.params?.requestId));
|
|
286
|
+
if (entry) cancel(entry);
|
|
287
|
+
}
|
|
288
|
+
// notifications/initialized and everything else need no answer.
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Cancels every call still running (the host went away); returns how many. */
|
|
292
|
+
function cancelAll() {
|
|
293
|
+
let count = 0;
|
|
294
|
+
for (const entry of inflight.values()) {
|
|
295
|
+
if (entry.cancelled) continue;
|
|
296
|
+
cancel(entry);
|
|
297
|
+
count += 1;
|
|
298
|
+
}
|
|
299
|
+
return count;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** One parsed message → its response, or null when none is due. */
|
|
303
|
+
async function handleMessage(message) {
|
|
304
|
+
if (Array.isArray(message)) {
|
|
305
|
+
// Batches (2025-03-26): answer what needs answering, as one array.
|
|
306
|
+
if (message.length === 0) return fail(null, JSON_RPC.invalidRequest, 'Empty batch');
|
|
307
|
+
const responses = (await Promise.all(message.map((entry) => handleMessage(entry)))).filter(Boolean);
|
|
308
|
+
return responses.length ? responses : null;
|
|
309
|
+
}
|
|
310
|
+
if (!message || typeof message !== 'object' || message.jsonrpc !== '2.0') {
|
|
311
|
+
return fail(message?.id, JSON_RPC.invalidRequest, 'Invalid Request');
|
|
312
|
+
}
|
|
313
|
+
const hasId = message.id !== undefined && message.id !== null;
|
|
314
|
+
if (typeof message.method !== 'string') {
|
|
315
|
+
// A response to a request this server never sends: nothing to do.
|
|
316
|
+
if (hasId && ('result' in message || 'error' in message)) return null;
|
|
317
|
+
return fail(message.id, JSON_RPC.invalidRequest, 'Invalid Request');
|
|
318
|
+
}
|
|
319
|
+
if (!hasId) {
|
|
320
|
+
handleNotification(message);
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
try {
|
|
324
|
+
return await handleRequest(message);
|
|
325
|
+
} catch (error) {
|
|
326
|
+
log(`${message.method} failed: ${error?.message || error}`);
|
|
327
|
+
return fail(message.id, JSON_RPC.internalError, error?.message || 'Internal error');
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** One raw stdin line; answers are sent as they are ready (ids correlate). */
|
|
332
|
+
async function handleLine(line) {
|
|
333
|
+
const text = String(line ?? '').trim();
|
|
334
|
+
if (!text) return;
|
|
335
|
+
let message;
|
|
336
|
+
try {
|
|
337
|
+
message = JSON.parse(text);
|
|
338
|
+
} catch {
|
|
339
|
+
send(fail(null, JSON_RPC.parseError, 'Parse error'));
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const response = await handleMessage(message);
|
|
343
|
+
if (response) send(response);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return { handleLine, handleMessage, listTools, cancelAll };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* The stdio process: parse the arguments, reach the relay like a worker does,
|
|
351
|
+
* decide whether `remote_relay` is listed (asked once, at startup), then serve
|
|
352
|
+
* until stdin closes. Returns the server, or null after a startup failure.
|
|
353
|
+
*/
|
|
354
|
+
export async function runOarMcpServer({
|
|
355
|
+
argv = process.argv.slice(2),
|
|
356
|
+
env = process.env,
|
|
357
|
+
stdin = process.stdin,
|
|
358
|
+
stdout = process.stdout,
|
|
359
|
+
stderr = process.stderr,
|
|
360
|
+
createApiClientImpl = createApiClient,
|
|
361
|
+
shouldRegisterRemoteRelayToolImpl = shouldRegisterRemoteRelayTool,
|
|
362
|
+
exit = (code) => process.exit(code),
|
|
363
|
+
} = {}) {
|
|
364
|
+
const log = (message) => {
|
|
365
|
+
try {
|
|
366
|
+
stderr.write(`[oar-mcp ${new Date().toISOString()}] ${message}\n`);
|
|
367
|
+
} catch {
|
|
368
|
+
// stderr gone: nothing else to tell.
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
const conversationId = parseConversationIdArg(argv);
|
|
372
|
+
if (!conversationId) {
|
|
373
|
+
log('missing --conversation-id <id>; this server serves exactly one conversation');
|
|
374
|
+
exit(2);
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
let api;
|
|
379
|
+
try {
|
|
380
|
+
const configPath = toText(env?.COPILOT_WEB_RELAY_CONFIG)
|
|
381
|
+
|| path.resolve(path.dirname(MODULE_PATH), '..', 'config.json');
|
|
382
|
+
const serverUrl = resolveRelayServerUrl({ configPath, env });
|
|
383
|
+
const token = loadTokenFromConfig(configPath);
|
|
384
|
+
api = createApiClientImpl({
|
|
385
|
+
serverUrl,
|
|
386
|
+
token,
|
|
387
|
+
// The conversation this server is bound to, named the way the workers
|
|
388
|
+
// name theirs. No pid headers: this process is not a session worker and
|
|
389
|
+
// must never be taken for one.
|
|
390
|
+
getHeaders: () => ({
|
|
391
|
+
'X-Relay-Session-Id': conversationId,
|
|
392
|
+
'X-Relay-Conversation-Id': conversationId,
|
|
393
|
+
}),
|
|
394
|
+
});
|
|
395
|
+
} catch (error) {
|
|
396
|
+
log(`cannot reach the relay: ${error?.message || error}`);
|
|
397
|
+
exit(1);
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
let closed = false;
|
|
402
|
+
const send = (message) => {
|
|
403
|
+
if (closed) return;
|
|
404
|
+
try {
|
|
405
|
+
stdout.write(`${JSON.stringify(message)}\n`);
|
|
406
|
+
} catch (error) {
|
|
407
|
+
log(`stdout write failed: ${error?.message || error}`);
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
const server = createOarMcpServer({
|
|
411
|
+
api,
|
|
412
|
+
conversationId,
|
|
413
|
+
remoteRelay: Promise.resolve()
|
|
414
|
+
.then(() => shouldRegisterRemoteRelayToolImpl({ api }))
|
|
415
|
+
.then((enabled) => {
|
|
416
|
+
log(`serving conversation ${conversationId.slice(0, 8)}; remote_relay ${enabled ? 'on' : 'off'}`);
|
|
417
|
+
return enabled;
|
|
418
|
+
}),
|
|
419
|
+
version: readPackageVersion(),
|
|
420
|
+
send,
|
|
421
|
+
log,
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
const lines = readline.createInterface({ input: stdin, crlfDelay: Infinity });
|
|
425
|
+
// The host ends the session by closing stdin (or by vanishing: EPIPE). The
|
|
426
|
+
// process then leaves through the event loop rather than process.exit():
|
|
427
|
+
// on Windows an exit in the instant a fetch settles trips a libuv assertion
|
|
428
|
+
// (src\win\async.c, `!(handle->flags & UV_HANDLE_CLOSING)`) and dies with
|
|
429
|
+
// 0xC0000409. Calls still waiting on the relay are cancelled (nobody is
|
|
430
|
+
// left to read their answer, and the relay should stop working for them),
|
|
431
|
+
// which empties the loop; the grace timer is the backstop.
|
|
432
|
+
let shuttingDown = false;
|
|
433
|
+
const shutdown = () => {
|
|
434
|
+
if (shuttingDown) return;
|
|
435
|
+
shuttingDown = true;
|
|
436
|
+
closed = true;
|
|
437
|
+
const cancelled = server.cancelAll();
|
|
438
|
+
if (cancelled) log(`host hung up: cancelled ${cancelled} call(s) still waiting on the relay`);
|
|
439
|
+
lines.close();
|
|
440
|
+
const timer = setTimeout(() => exit(0), SHUTDOWN_GRACE_MS);
|
|
441
|
+
timer.unref?.();
|
|
442
|
+
};
|
|
443
|
+
stdout.on?.('error', (error) => {
|
|
444
|
+
log(`stdout closed: ${error?.message || error}`);
|
|
445
|
+
shutdown();
|
|
446
|
+
});
|
|
447
|
+
lines.on('line', (line) => {
|
|
448
|
+
server.handleLine(line).catch((error) => log(`message handling failed: ${error?.message || error}`));
|
|
449
|
+
});
|
|
450
|
+
lines.on('close', shutdown);
|
|
451
|
+
return server;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function isMainModule() {
|
|
455
|
+
const entry = process.argv[1];
|
|
456
|
+
if (!entry) return false;
|
|
457
|
+
try {
|
|
458
|
+
const normalize = (value) => {
|
|
459
|
+
const resolved = fs.realpathSync(path.resolve(value));
|
|
460
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
461
|
+
};
|
|
462
|
+
return normalize(entry) === normalize(MODULE_PATH);
|
|
463
|
+
} catch {
|
|
464
|
+
return false;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (isMainModule()) {
|
|
469
|
+
runOarMcpServer().catch((error) => {
|
|
470
|
+
process.stderr.write(`oar-mcp-server fatal: ${error?.stack || error?.message || error}\n`);
|
|
471
|
+
process.exit(1);
|
|
472
|
+
});
|
|
473
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Migration: storage for remote relays (agents working across OAR relays).
|
|
5
|
+
*
|
|
6
|
+
* 1. `messages.origin_json` — on a user row that another relay's agent sent:
|
|
7
|
+
* who sent it (relay, source conversation, model, hop count). Drives the
|
|
8
|
+
* "from <relay>" badge, keeps such rows from unlocking relays, and skips
|
|
9
|
+
* the reply-ready push.
|
|
10
|
+
* 2. `conversations.origin_json` — on a conversation another relay's agent
|
|
11
|
+
* created: the "via <relay>" marker in the conversation list.
|
|
12
|
+
* 3. `conversation_remote_relay_unlocks` — which remote relays the user has
|
|
13
|
+
* mentioned (and so unlocked) in which conversation. Keyed by the remote's
|
|
14
|
+
* local registry id, so a renamed remote keeps its unlocks.
|
|
15
|
+
*
|
|
16
|
+
* Additive and idempotent. Safe to run on every boot.
|
|
17
|
+
*
|
|
18
|
+
* Usage: node server/migrations/0005-remote-relays.mjs [path/to/copilot.db]
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { fileURLToPath } from 'url';
|
|
22
|
+
import path from 'path';
|
|
23
|
+
import process from 'process';
|
|
24
|
+
import Database from 'better-sqlite3';
|
|
25
|
+
|
|
26
|
+
function tableExists(db, name) {
|
|
27
|
+
return !!db
|
|
28
|
+
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
|
|
29
|
+
.get(name);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function columnNames(db, table) {
|
|
33
|
+
return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((column) => String(column.name)));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function migrateRemoteRelays(db) {
|
|
37
|
+
let columnsAdded = 0;
|
|
38
|
+
for (const table of ['messages', 'conversations']) {
|
|
39
|
+
if (!tableExists(db, table)) continue;
|
|
40
|
+
if (!columnNames(db, table).has('origin_json')) {
|
|
41
|
+
db.exec(`ALTER TABLE ${table} ADD COLUMN origin_json TEXT`);
|
|
42
|
+
columnsAdded += 1;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const unlocksExisted = tableExists(db, 'conversation_remote_relay_unlocks');
|
|
46
|
+
db.exec(`
|
|
47
|
+
CREATE TABLE IF NOT EXISTS conversation_remote_relay_unlocks (
|
|
48
|
+
conversation_id TEXT NOT NULL,
|
|
49
|
+
remote_relay_id TEXT NOT NULL,
|
|
50
|
+
message_id TEXT,
|
|
51
|
+
created_at TEXT NOT NULL,
|
|
52
|
+
PRIMARY KEY (conversation_id, remote_relay_id)
|
|
53
|
+
)
|
|
54
|
+
`);
|
|
55
|
+
return {
|
|
56
|
+
applied: columnsAdded > 0 || !unlocksExisted,
|
|
57
|
+
columnsAdded,
|
|
58
|
+
unlocksTableCreated: !unlocksExisted,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function migrate(dbPath) {
|
|
63
|
+
const db = new Database(dbPath);
|
|
64
|
+
try {
|
|
65
|
+
return migrateRemoteRelays(db);
|
|
66
|
+
} finally {
|
|
67
|
+
db.close();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function defaultDbPath() {
|
|
72
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
73
|
+
return path.resolve(here, '..', 'data', 'copilot.db');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
77
|
+
if (isMain) {
|
|
78
|
+
const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
|
|
79
|
+
try {
|
|
80
|
+
const result = migrate(dbPath);
|
|
81
|
+
console.log(`Migration 0005 on ${dbPath}`);
|
|
82
|
+
console.log(result.applied
|
|
83
|
+
? ` origin columns added: ${result.columnsAdded}; unlocks table created: ${result.unlocksTableCreated}`
|
|
84
|
+
: ' skipped: nothing to change');
|
|
85
|
+
process.exit(0);
|
|
86
|
+
} catch (err) {
|
|
87
|
+
console.error(`Migration 0005 failed: ${err?.message || err}`);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
}
|