@oar-sh/oar 0.9.2 → 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 +336 -0
- package/README.md +455 -423
- package/bin/oar.js +25 -10
- package/package.json +11 -5
- package/server/README.md +44 -4
- 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 +1073 -69
- package/server/claude-worker/claude-session-worker.mjs +45 -4
- package/server/claude-worker/claude-turn-publisher.mjs +81 -20
- package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
- package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
- package/server/copilot-worker/copilot-question-bridge.mjs +34 -12
- package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +113 -14
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2349 -498
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +82 -20
- 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 +28 -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/0004-steer-settle-markers.mjs +119 -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 +125 -2
- package/server/public/app/ask-user-view.js +102 -13
- package/server/public/app/attachments-view.js +37 -2
- package/server/public/app/background-task-timeout-options.mjs +9 -0
- package/server/public/app/background-tasks-view.mjs +33 -18
- package/server/public/app/bootstrap.js +55 -14
- package/server/public/app/composer-control-state.mjs +52 -34
- package/server/public/app/conversation-deep-link.mjs +42 -0
- package/server/public/app/conversation-draft-sync.mjs +187 -0
- package/server/public/app/conversation-list-filter.mjs +86 -0
- package/server/public/app/conversation-provider-indicator.mjs +8 -0
- package/server/public/app/conversation-view.js +1016 -200
- package/server/public/app/emoji-view.js +2 -0
- package/server/public/app/font-scaling.js +4 -37
- package/server/public/app/host-suspend-ui.js +272 -0
- package/server/public/app/journal-view.js +149 -6
- package/server/public/app/mention-autocomplete.mjs +298 -0
- package/server/public/app/model-selector-options.mjs +1 -1
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/pwa-install.js +8 -3
- package/server/public/app/question-multi-select.mjs +52 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +61 -3
- 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 +12 -8
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +37 -1
- package/server/public/app/steered-turn-merge.mjs +100 -0
- package/server/public/app/store.js +126 -15
- package/server/public/app/sync-outbox.mjs +16 -8
- package/server/public/app/thinking-follow.js +260 -0
- package/server/public/app/tool-activity-emoji.mjs +40 -0
- package/server/public/app/transcript-viewport-anchor.mjs +210 -0
- package/server/public/index.html +463 -17
- package/server/public/sw.js +7 -0
- package/server/relay-tools.md +7 -1
- package/server/repositories/message-repository.mjs +68 -18
- package/server/repositories/question-repository.mjs +18 -0
- package/server/repositories/remote-relay-repository.mjs +75 -0
- package/server/repositories/session-repository.mjs +13 -32
- package/server/routes/ask-user-routes.mjs +42 -2
- package/server/routes/messages-routes.mjs +572 -56
- package/server/routes/remote-relay-routes.mjs +133 -0
- package/server/routes/sessions-routes.mjs +378 -84
- package/server/server-runtime.mjs +303 -25
- 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/live-turn-picker.mjs +47 -0
- package/server/services/push-dispatch-service.mjs +37 -4
- package/server/services/relay-prompt-sanitizer.mjs +17 -2
- package/server/services/relay-question-context.mjs +46 -0
- 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/sdk-session-import-service.mjs +49 -7
- package/server/services/session-worker-launch-service.mjs +131 -12
- package/server/services/session-worker-registry-service.mjs +75 -0
- package/server/services/session-worker-supervisor-service.mjs +7 -1
- package/server/services/session-worker-websocket-service.mjs +23 -1
- package/server/services/steer-resend.mjs +45 -0
- package/shared/background-task-timeout.mjs +12 -4
- package/shared/control-poller.mjs +4 -1
- package/shared/question-multi-select.mjs +16 -0
- package/shared/relay-stop-failure.mjs +15 -0
- 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/slash-command-guard.mjs +76 -0
- package/shared/steer-note.mjs +66 -0
- package/shared/steer-settle-failure.mjs +89 -0
- package/shared/steer-settle-markers.mjs +18 -0
- package/shared/worker-crash-guard.mjs +40 -25
- package/shared/worker-runtime/api-client.mjs +112 -22
- package/shared/worker-runtime/heartbeat.mjs +55 -1
- package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +119 -8
- package/server/claude-worker/claude-session-test-harness.mjs +0 -250
- package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -382
- package/server/copilot-worker/fixtures/abort-turn.json +0 -588
- package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
- package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
- package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
- package/server/copilot-worker/fixtures/happy-turn.json +0 -566
- package/server/copilot-worker/fixtures/quota-turn.json +0 -647
- package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
- package/server/copilot-worker/fixtures/resume-turn.json +0 -700
- package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
- package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
- package/server/routes/messages-routes-test-harness.mjs +0 -111
- package/server/services/fixtures/claude-auth-stub.cmd +0 -1
- package/server/services/fixtures/claude-auth-stub.mjs +0 -76
- package/server/services/fixtures/claude-auth-stub.sh +0 -4
- package/server/services/fixtures/cli-install-stub.cmd +0 -1
- package/server/services/fixtures/cli-install-stub.mjs +0 -98
- package/server/services/fixtures/cli-install-stub.sh +0 -4
- package/server/services/fixtures/grok-stub.cmd +0 -1
- package/server/services/fixtures/grok-stub.mjs +0 -89
- package/server/services/fixtures/grok-stub.sh +0 -4
- package/shared/fixtures/copilot-catalog-raw-2026-09-07.json +0 -2363
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The inbound side of remote relays, shared by the message, conversation and
|
|
4
|
+
// question routes: which requests come from another relay's agent, the
|
|
5
|
+
// inbound switch, provenance ("origin") for API payloads, and the mentions in
|
|
6
|
+
// a human message that unlock a remote relay for its conversation (plus the
|
|
7
|
+
// hint the agent gets with that turn).
|
|
8
|
+
//
|
|
9
|
+
// `inbound` is the runtime's `remoteRelayInbound` dep:
|
|
10
|
+
// { listRelays, selfNames, inboundEnabled, recordUnlock, describeRelay, repository }
|
|
11
|
+
// Every helper treats a missing one as "feature absent": nothing is remote,
|
|
12
|
+
// nothing unlocks, and the routes behave exactly as before.
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
REMOTE_RELAY_ERROR_CODES,
|
|
16
|
+
REMOTE_RELAY_HEADERS,
|
|
17
|
+
REMOTE_RELAY_TOOL_NAME,
|
|
18
|
+
normalizeRemoteRelayOrigin,
|
|
19
|
+
stripRemotePromptHeader,
|
|
20
|
+
} from '../../shared/remote-relay-contract.mjs';
|
|
21
|
+
import { findRemoteRelayMentions } from '../../shared/remote-relay-mentions.mjs';
|
|
22
|
+
|
|
23
|
+
export const REMOTE_INBOUND_DISABLED_RESPONSE = Object.freeze({
|
|
24
|
+
error: 'This relay does not accept prompts from other relays\' agents',
|
|
25
|
+
code: REMOTE_RELAY_ERROR_CODES.inboundDisabled,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function headerValue(req, name) {
|
|
29
|
+
const value = req?.headers?.[name];
|
|
30
|
+
return String((Array.isArray(value) ? value[0] : value) ?? '').trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function headerHops(req) {
|
|
34
|
+
const text = headerValue(req, REMOTE_RELAY_HEADERS.hops);
|
|
35
|
+
if (!/^\d+$/.test(text)) return null;
|
|
36
|
+
return Number(text);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* What a request says about coming from another relay's agent.
|
|
41
|
+
* `remote` is true for the X-OAR-Remote-Origin header or any `origin` in the
|
|
42
|
+
* body, even a malformed one, so such a request can never unlock a relay.
|
|
43
|
+
* `origin` is the sanitised provenance to store: the body's, or a minimal one
|
|
44
|
+
* built from the header (its value is the sender's relay id), so a turn an
|
|
45
|
+
* agent started is always recognisable as one. The hop count is the larger of
|
|
46
|
+
* the body's and the X-OAR-Remote-Hops header's.
|
|
47
|
+
*/
|
|
48
|
+
export function readRemoteRelayRequest(req, inbound, { body = req?.body } = {}) {
|
|
49
|
+
if (!inbound) return { remote: false, origin: null };
|
|
50
|
+
const rawOrigin = body && typeof body === 'object' ? body.origin : undefined;
|
|
51
|
+
const senderRelayId = headerValue(req, REMOTE_RELAY_HEADERS.origin);
|
|
52
|
+
const remote = !!senderRelayId || (rawOrigin !== undefined && rawOrigin !== null);
|
|
53
|
+
if (!remote) return { remote: false, origin: null };
|
|
54
|
+
const hops = headerHops(req);
|
|
55
|
+
let origin = normalizeRemoteRelayOrigin(rawOrigin)
|
|
56
|
+
|| normalizeRemoteRelayOrigin(senderRelayId ? { relayId: senderRelayId, hops: hops ?? 1 } : null);
|
|
57
|
+
if (origin && hops !== null && hops > origin.hops) {
|
|
58
|
+
origin = normalizeRemoteRelayOrigin({ ...origin, hops });
|
|
59
|
+
}
|
|
60
|
+
return { remote: true, origin };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The inbound switch; on by default, closed when it cannot be read. */
|
|
64
|
+
export function remoteRelayInboundEnabled(inbound) {
|
|
65
|
+
if (!inbound || typeof inbound.inboundEnabled !== 'function') return true;
|
|
66
|
+
try {
|
|
67
|
+
return inbound.inboundEnabled() !== false;
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Applies the inbound switch to a write route. Returns the request's
|
|
75
|
+
* remote-relay facts (see readRemoteRelayRequest), or null after answering
|
|
76
|
+
* 403 REMOTE_INBOUND_DISABLED because the request came from another relay's
|
|
77
|
+
* agent and this relay does not accept those.
|
|
78
|
+
*/
|
|
79
|
+
export function admitRemoteRelayRequest(req, res, inbound) {
|
|
80
|
+
const request = readRemoteRelayRequest(req, inbound);
|
|
81
|
+
if (!request.remote || remoteRelayInboundEnabled(inbound)) return request;
|
|
82
|
+
res.status(403).json({ ...REMOTE_INBOUND_DISABLED_RESPONSE });
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The origin a public share may show: the facts the prompt's header line
|
|
88
|
+
* already states, without the other relay's address or ids.
|
|
89
|
+
*/
|
|
90
|
+
export function publicRemoteRelayOrigin(origin) {
|
|
91
|
+
if (!origin || typeof origin !== 'object') return null;
|
|
92
|
+
return { ...origin, relayId: '', relayUrl: '', conversationId: '' };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ─── Mentions ────────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
// A remote relay names itself (pairing, health checks), so its name is
|
|
98
|
+
// untrusted inside a prompt block: no quotes, tags or line breaks.
|
|
99
|
+
function hintText(value, max = 60) {
|
|
100
|
+
return String(value ?? '').replace(/[\r\n"<>]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The remote relays a human message mentions, in order of appearance:
|
|
105
|
+
* `[{ relayId, name, online, version }]`. `text` is what the user typed; a
|
|
106
|
+
* remote-prompt header line (a Resend of an agent's message) is not the user's
|
|
107
|
+
* and is ignored. Empty without the feature or when the registry cannot be
|
|
108
|
+
* read — a mention never breaks a send.
|
|
109
|
+
*/
|
|
110
|
+
export function findMentionedRemoteRelays(inbound, text) {
|
|
111
|
+
if (!inbound || typeof inbound.listRelays !== 'function') return [];
|
|
112
|
+
try {
|
|
113
|
+
const listedRelays = inbound.listRelays();
|
|
114
|
+
const relays = Array.isArray(listedRelays) ? listedRelays : [];
|
|
115
|
+
const selfNames = typeof inbound.selfNames === 'function' ? (inbound.selfNames() || []) : [];
|
|
116
|
+
const mentions = findRemoteRelayMentions(stripRemotePromptHeader(text), relays, {
|
|
117
|
+
selfNames: Array.isArray(selfNames) ? selfNames : [],
|
|
118
|
+
});
|
|
119
|
+
return mentions.map(({ relayId }) => {
|
|
120
|
+
const listed = relays.find((relay) => String(relay?.id || '').trim() === relayId) || {};
|
|
121
|
+
let described = null;
|
|
122
|
+
try {
|
|
123
|
+
described = typeof inbound.describeRelay === 'function' ? inbound.describeRelay(relayId) : null;
|
|
124
|
+
} catch {
|
|
125
|
+
described = null;
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
relayId,
|
|
129
|
+
name: String(described?.name || listed.name || relayId),
|
|
130
|
+
online: typeof described?.online === 'boolean'
|
|
131
|
+
? described.online
|
|
132
|
+
: String(listed.lastStatus || '').trim().toLowerCase() === 'online',
|
|
133
|
+
version: String(described?.version || listed.version || '').trim(),
|
|
134
|
+
};
|
|
135
|
+
});
|
|
136
|
+
} catch {
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The one reminder block the agent gets with a turn whose message mentions
|
|
143
|
+
* remote relays ('' for none). It rides in the queued prompt only, never in
|
|
144
|
+
* the stored message, and the prompt sanitizers strip <system_reminder>
|
|
145
|
+
* blocks from anything displayed.
|
|
146
|
+
*/
|
|
147
|
+
export function formatRemoteRelayMentionHint(relays = []) {
|
|
148
|
+
const described = (Array.isArray(relays) ? relays : [])
|
|
149
|
+
.map((relay) => {
|
|
150
|
+
const name = hintText(relay?.name) || hintText(relay?.relayId);
|
|
151
|
+
if (!name) return '';
|
|
152
|
+
const version = hintText(relay?.version, 40);
|
|
153
|
+
const status = relay?.online ? 'online' : 'offline';
|
|
154
|
+
return `"${name}" (${version ? `${status}, OAR ${version}` : status})`;
|
|
155
|
+
})
|
|
156
|
+
.filter(Boolean);
|
|
157
|
+
if (!described.length) return '';
|
|
158
|
+
const subject = described.length === 1
|
|
159
|
+
? `the remote OAR relay ${described[0]}`
|
|
160
|
+
: `the remote OAR relays ${described.join(', ')}`;
|
|
161
|
+
return `<system_reminder>The user mentioned ${subject}. The ${REMOTE_RELAY_TOOL_NAME} tool can list, read, prompt and create sessions there.</system_reminder>`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The queued prompt with the mention hint after the user's text. */
|
|
165
|
+
export function withRemoteRelayMentionHint(promptText, hint) {
|
|
166
|
+
const text = String(promptText ?? '');
|
|
167
|
+
if (!hint) return text;
|
|
168
|
+
return text ? `${text}\n\n${hint}` : hint;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Persists the unlocks for a stored human message. Returns how many were new.
|
|
173
|
+
* Failures are swallowed: the message is already accepted, and the agent
|
|
174
|
+
* learns about a missing unlock from the tool's LOCKED answer.
|
|
175
|
+
*/
|
|
176
|
+
export function recordRemoteRelayUnlocks(inbound, { conversationId, messageId, relays = [] } = {}) {
|
|
177
|
+
if (!inbound || typeof inbound.recordUnlock !== 'function') return 0;
|
|
178
|
+
let created = 0;
|
|
179
|
+
for (const relay of Array.isArray(relays) ? relays : []) {
|
|
180
|
+
try {
|
|
181
|
+
if (inbound.recordUnlock(conversationId, relay?.relayId, messageId)) created += 1;
|
|
182
|
+
} catch {
|
|
183
|
+
// Keep going: one relay's failure must not cost the others their unlock.
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return created;
|
|
187
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
REMOTE_RELAY_ERROR_CODES,
|
|
5
|
+
REMOTE_RELAY_PROTOCOL,
|
|
6
|
+
checkRemoteRelayUrlPolicy,
|
|
7
|
+
normalizeRemoteRelayLink,
|
|
8
|
+
} from '../../shared/remote-relay-contract.mjs';
|
|
9
|
+
|
|
10
|
+
// Adding a remote relay and the mutual introduction that follows (plan §5.4).
|
|
11
|
+
//
|
|
12
|
+
// Outbound (`addFromLink`, behind POST /api/remote-relays): the user pastes
|
|
13
|
+
// the other relay's web address. We probe it with the best token we have,
|
|
14
|
+
// save it, and — when both sides speak protocol 1 and we know our own public
|
|
15
|
+
// URL — introduce ourselves so the other relay lists us too.
|
|
16
|
+
//
|
|
17
|
+
// Inbound (`acceptPairing`, behind POST /api/remote-relays/pair): another
|
|
18
|
+
// relay introduces itself. For a relay not listed yet, the token it may carry
|
|
19
|
+
// is the one to use for it; it is stored for that remote only, never echoed
|
|
20
|
+
// and never logged. A relay already listed keeps its stored address and token.
|
|
21
|
+
|
|
22
|
+
// The inbound check must finish well inside the caller's own request budget,
|
|
23
|
+
// or the caller reports "not paired back" although we saved the entry.
|
|
24
|
+
const PAIR_CHECK_TIMEOUT_MS = 5_000;
|
|
25
|
+
export const REMOTE_RELAY_SELF_CODE = 'SELF';
|
|
26
|
+
|
|
27
|
+
function toText(value) {
|
|
28
|
+
return String(value ?? '').trim();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function failure(status, code, error, extra = {}) {
|
|
32
|
+
return { ok: false, status, code, error, ...extra };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createRemoteRelayPairing({
|
|
36
|
+
registry,
|
|
37
|
+
client,
|
|
38
|
+
getOwnToken = () => '',
|
|
39
|
+
now = () => new Date(),
|
|
40
|
+
} = {}) {
|
|
41
|
+
function ownToken() {
|
|
42
|
+
return toText(getOwnToken?.());
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Stores the browser-derived address as our public URL, once, if none is set. */
|
|
46
|
+
function adoptSelfUrl(selfUrl) {
|
|
47
|
+
const raw = toText(selfUrl);
|
|
48
|
+
if (!raw || registry.getSelfSettings().publicUrl) return;
|
|
49
|
+
const link = normalizeRemoteRelayLink(raw);
|
|
50
|
+
if (!link.ok || !checkRemoteRelayUrlPolicy(link.baseUrl).ok) return;
|
|
51
|
+
registry.setSelfSettings({ publicUrl: link.baseUrl });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function findExisting(identity, baseUrl) {
|
|
55
|
+
const entries = registry.list();
|
|
56
|
+
if (identity.relayId) {
|
|
57
|
+
const byId = entries.find((entry) => entry.relayId === identity.relayId);
|
|
58
|
+
if (byId) return byId;
|
|
59
|
+
}
|
|
60
|
+
const url = baseUrl.toLowerCase();
|
|
61
|
+
return entries.find((entry) => entry.url.toLowerCase() === url) || null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function pairBackTo(relay, workingToken) {
|
|
65
|
+
const publicUrl = registry.getSelfSettings().publicUrl;
|
|
66
|
+
if (!publicUrl) {
|
|
67
|
+
return { pairedBack: false, pairBackError: 'This relay\'s public URL is not set, so the other relay cannot reach it' };
|
|
68
|
+
}
|
|
69
|
+
if (!(Number(relay.protocol) >= 1)) {
|
|
70
|
+
return { pairedBack: false, pairBackError: 'The other relay runs an older OAR without pairing; add this relay there by hand' };
|
|
71
|
+
}
|
|
72
|
+
const own = ownToken();
|
|
73
|
+
const body = {
|
|
74
|
+
relayId: registry.instanceId(),
|
|
75
|
+
name: registry.selfName(),
|
|
76
|
+
url: publicUrl,
|
|
77
|
+
protocol: REMOTE_RELAY_PROTOCOL,
|
|
78
|
+
// Only when the other relay needs a different token to call us back.
|
|
79
|
+
...(own && own !== workingToken ? { token: own } : {}),
|
|
80
|
+
};
|
|
81
|
+
try {
|
|
82
|
+
await client.request(relay, 'POST', '/api/remote-relays/pair', { body });
|
|
83
|
+
return { pairedBack: true };
|
|
84
|
+
} catch (error) {
|
|
85
|
+
return { pairedBack: false, pairBackError: toText(error?.message) || 'Pairing back failed' };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* POST /api/remote-relays `{ url, token?, pairBack = true, selfUrl? }`.
|
|
91
|
+
* Failures carry the intended HTTP `status`; a 401 is `{ ok:false,
|
|
92
|
+
* needsToken:true }` so the UI can show the token field.
|
|
93
|
+
*/
|
|
94
|
+
async function addFromLink({ url, token, pairBack = true, selfUrl } = {}) {
|
|
95
|
+
const link = normalizeRemoteRelayLink(url);
|
|
96
|
+
if (!link.ok) return failure(400, REMOTE_RELAY_ERROR_CODES.invalidInput, link.error);
|
|
97
|
+
|
|
98
|
+
// Explicit field, then the link's ?token=, then this relay's own token.
|
|
99
|
+
const workingToken = toText(token) || toText(link.token) || ownToken();
|
|
100
|
+
|
|
101
|
+
let identity;
|
|
102
|
+
try {
|
|
103
|
+
identity = await client.probe(link.baseUrl, workingToken);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (error?.code === REMOTE_RELAY_ERROR_CODES.unauthorized) {
|
|
106
|
+
return failure(200, error.code, 'The other relay rejected the token. Enter its token.', { needsToken: true });
|
|
107
|
+
}
|
|
108
|
+
const status = error?.code === REMOTE_RELAY_ERROR_CODES.invalidInput ? 400 : 502;
|
|
109
|
+
return failure(status, error?.code || REMOTE_RELAY_ERROR_CODES.offline, toText(error?.message) || 'The other relay could not be reached');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (identity.relayId && identity.relayId === registry.instanceId()) {
|
|
113
|
+
return failure(400, REMOTE_RELAY_SELF_CODE, 'That address is this relay itself');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
adoptSelfUrl(selfUrl);
|
|
117
|
+
|
|
118
|
+
const tokenFields = workingToken === ownToken()
|
|
119
|
+
? { tokenMode: 'own' }
|
|
120
|
+
: { tokenMode: 'custom', token: workingToken };
|
|
121
|
+
const existing = findExisting(identity, link.baseUrl);
|
|
122
|
+
let saved;
|
|
123
|
+
if (existing) {
|
|
124
|
+
const updated = registry.update(existing.id, { url: link.baseUrl, ...tokenFields });
|
|
125
|
+
if (!updated.ok) return failure(updated.status || 400, REMOTE_RELAY_ERROR_CODES.invalidInput, updated.error);
|
|
126
|
+
saved = registry.recordIdentity(existing.id, identity);
|
|
127
|
+
} else {
|
|
128
|
+
saved = registry.add({
|
|
129
|
+
relayId: identity.relayId,
|
|
130
|
+
name: identity.name,
|
|
131
|
+
url: link.baseUrl,
|
|
132
|
+
...tokenFields,
|
|
133
|
+
addedBy: 'user',
|
|
134
|
+
version: identity.version,
|
|
135
|
+
platform: identity.platform,
|
|
136
|
+
protocol: identity.protocol,
|
|
137
|
+
lastStatus: 'online',
|
|
138
|
+
lastSeenAt: now().toISOString(),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
if (!saved) return failure(409, REMOTE_RELAY_ERROR_CODES.unknown, 'The remote relay was removed while it was being added');
|
|
142
|
+
|
|
143
|
+
const pairing = pairBack === false
|
|
144
|
+
? { pairedBack: false }
|
|
145
|
+
: await pairBackTo(registry.get(saved.id) || saved, workingToken);
|
|
146
|
+
|
|
147
|
+
const relay = registry.listPublic().find((entry) => entry.id === saved.id) || null;
|
|
148
|
+
return {
|
|
149
|
+
ok: true,
|
|
150
|
+
relay,
|
|
151
|
+
updated: !!existing,
|
|
152
|
+
...pairing,
|
|
153
|
+
...(link.warning ? { warning: link.warning } : {}),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* POST /api/remote-relays/pair: another relay introduces itself as
|
|
159
|
+
* `{ relayId, name, url, protocol, token? }`. The entry is kept even when
|
|
160
|
+
* that URL is not reachable from here yet (a tunnel that is not up).
|
|
161
|
+
*/
|
|
162
|
+
async function acceptPairing(body = {}) {
|
|
163
|
+
const input = body && typeof body === 'object' ? body : {};
|
|
164
|
+
const relayId = toText(input.relayId).slice(0, 100);
|
|
165
|
+
if (!relayId) return failure(400, REMOTE_RELAY_ERROR_CODES.invalidInput, 'relayId is required');
|
|
166
|
+
if (relayId === registry.instanceId()) {
|
|
167
|
+
return failure(400, REMOTE_RELAY_SELF_CODE, 'A relay cannot pair with itself');
|
|
168
|
+
}
|
|
169
|
+
const link = normalizeRemoteRelayLink(input.url);
|
|
170
|
+
if (!link.ok) return failure(400, REMOTE_RELAY_ERROR_CODES.invalidInput, link.error);
|
|
171
|
+
|
|
172
|
+
// A relay already listed here keeps the address and token stored for it;
|
|
173
|
+
// only a new one takes the offered ones (see upsertFromPairing).
|
|
174
|
+
const protocol = Number(input.protocol);
|
|
175
|
+
const entry = registry.upsertFromPairing({
|
|
176
|
+
relayId,
|
|
177
|
+
name: toText(input.name).slice(0, 60),
|
|
178
|
+
url: link.baseUrl,
|
|
179
|
+
token: typeof input.token === 'string' ? input.token : '',
|
|
180
|
+
...(Number.isInteger(protocol) && protocol >= 0 ? { protocol } : {}),
|
|
181
|
+
});
|
|
182
|
+
try {
|
|
183
|
+
await registry.check(entry.id, { timeoutMs: PAIR_CHECK_TIMEOUT_MS });
|
|
184
|
+
} catch {}
|
|
185
|
+
return { ok: true, relayId: registry.instanceId(), name: registry.selfName() };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return { addFromLink, acceptPairing };
|
|
189
|
+
}
|