@oar-sh/oar 0.9.6 → 0.9.7
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/CHANGELOG.md +172 -0
- package/README.md +176 -22
- package/package.json +1 -1
- package/server/README.md +174 -7
- package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
- package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
- package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +18 -2
- package/server/claude-worker/claude-session-process.mjs +33 -0
- package/server/claude-worker/claude-session-worker.mjs +6 -0
- package/server/db-schema.mjs +17 -0
- package/server/migrations/0006-workspace-root-settings.mjs +67 -0
- package/server/migrations/0007-claude-cloud.mjs +82 -0
- package/server/public/app/agent-sessions-settings-ui.js +197 -0
- package/server/public/app/agent-sessions-settings.mjs +134 -0
- package/server/public/app/api-client.js +143 -4
- package/server/public/app/attachments-view.js +20 -1
- package/server/public/app/attribution-control.mjs +46 -0
- package/server/public/app/bootstrap.js +344 -31
- package/server/public/app/claude-cloud-conversation-ui.js +110 -0
- package/server/public/app/claude-cloud-settings-ui.js +374 -0
- package/server/public/app/claude-cloud-ui.mjs +611 -0
- package/server/public/app/composer-control-state.mjs +11 -0
- package/server/public/app/context-menu.mjs +140 -0
- package/server/public/app/context-tier-options.mjs +4 -0
- package/server/public/app/conversation-origin-ui.js +27 -0
- package/server/public/app/conversation-provider-indicator.mjs +22 -4
- package/server/public/app/conversation-view.js +83 -4
- package/server/public/app/cwd-picker.js +12 -0
- package/server/public/app/journal-view.js +698 -28
- package/server/public/app/math-dollars.mjs +89 -0
- package/server/public/app/model-selector-options.mjs +1 -1
- package/server/public/app/plan-usage-view.mjs +36 -8
- package/server/public/app/relay-error-ctas.mjs +16 -2
- package/server/public/app/remote-relay-origin-view.mjs +120 -4
- package/server/public/app/router.js +49 -12
- package/server/public/app/settings-modal.js +33 -2
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +51 -0
- package/server/public/index.html +381 -7
- package/server/relay-tools.md +13 -1
- package/server/repositories/remote-relay-repository.mjs +42 -1
- package/server/repositories/session-repository.mjs +47 -1
- package/server/routes/claude-cloud-routes.mjs +140 -0
- package/server/routes/git-routes.mjs +37 -0
- package/server/routes/messages-routes.mjs +120 -15
- package/server/routes/remote-relay-routes.mjs +15 -1
- package/server/routes/sessions-routes.mjs +502 -72
- package/server/server-runtime.mjs +258 -25
- package/server/services/claude-account-usage-service.mjs +176 -0
- package/server/services/claude-cloud-repo-service.mjs +408 -0
- package/server/services/claude-cloud-session-service.mjs +326 -0
- package/server/services/claude-cloud-settings-service.mjs +269 -0
- package/server/services/git-remote-service.mjs +115 -0
- package/server/services/plan-usage-claude-cloud.mjs +211 -0
- package/server/services/plan-usage-claude.mjs +202 -40
- package/server/services/plan-usage-contract.mjs +39 -3
- package/server/services/plan-usage-service.mjs +82 -2
- package/server/services/provider-reasoning-effort.mjs +3 -0
- package/server/services/remote-relay-caller-context.mjs +49 -10
- package/server/services/remote-relay-client.mjs +9 -0
- package/server/services/remote-relay-dispatcher.mjs +383 -35
- package/server/services/remote-relay-inbound.mjs +49 -2
- package/server/services/remote-relay-loopback.mjs +245 -0
- package/server/services/remote-relay-registry-service.mjs +52 -0
- package/server/services/session-worker-launch-service.mjs +100 -15
- package/server/services/session-worker-process-service.mjs +348 -129
- package/server/services/session-worker-stop-service.mjs +24 -13
- package/server/services/session-worker-supervisor-service.mjs +56 -1
- package/server/services/usage-limit-pause-service.mjs +3 -1
- package/server/services/worker-launch-note-service.mjs +151 -0
- package/shared/claude-attribution.mjs +117 -0
- package/shared/claude-cloud/api-client.mjs +809 -0
- package/shared/claude-cloud/base-url.mjs +47 -0
- package/shared/claude-cloud/credentials.mjs +221 -0
- package/shared/claude-cloud/repo-url.mjs +111 -0
- package/shared/provider-routing.mjs +4 -2
- package/shared/remote-relay-contract.mjs +153 -22
- package/shared/remote-relay-tool-core.mjs +11 -8
- package/shared/steer-settle-failure.mjs +1 -0
|
@@ -10,10 +10,19 @@
|
|
|
10
10
|
// { listRelays, selfNames, inboundEnabled, recordUnlock, describeRelay, repository }
|
|
11
11
|
// Every helper treats a missing one as "feature absent": nothing is remote,
|
|
12
12
|
// nothing unlocks, and the routes behave exactly as before.
|
|
13
|
+
//
|
|
14
|
+
// An agent of THIS relay reaches the same routes through the in-process
|
|
15
|
+
// loopback (remote-relay-loopback.mjs), which marks its requests here. Such a
|
|
16
|
+
// request is an agent's like any other relay's (provenance is stored, approval
|
|
17
|
+
// cards stay hidden, nothing unlocks), with two differences: the inbound
|
|
18
|
+
// switch does not apply to it (the local target has its own setting, which the
|
|
19
|
+
// dispatcher checks), and only it may store an origin with `local: true`.
|
|
13
20
|
|
|
14
21
|
import {
|
|
15
22
|
REMOTE_RELAY_ERROR_CODES,
|
|
16
23
|
REMOTE_RELAY_HEADERS,
|
|
24
|
+
REMOTE_RELAY_LOCAL_ALIAS,
|
|
25
|
+
REMOTE_RELAY_LOCAL_ID,
|
|
17
26
|
REMOTE_RELAY_TOOL_NAME,
|
|
18
27
|
normalizeRemoteRelayOrigin,
|
|
19
28
|
stripRemotePromptHeader,
|
|
@@ -25,6 +34,19 @@ export const REMOTE_INBOUND_DISABLED_RESPONSE = Object.freeze({
|
|
|
25
34
|
code: REMOTE_RELAY_ERROR_CODES.inboundDisabled,
|
|
26
35
|
});
|
|
27
36
|
|
|
37
|
+
// Requests the loopback built. Membership cannot be claimed over HTTP.
|
|
38
|
+
const loopbackRequests = new WeakSet();
|
|
39
|
+
|
|
40
|
+
/** Marks a request object as built in-process for an agent of this relay. */
|
|
41
|
+
export function markRemoteRelayLoopbackRequest(req) {
|
|
42
|
+
if (req && typeof req === 'object') loopbackRequests.add(req);
|
|
43
|
+
return req;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isRemoteRelayLoopbackRequest(req) {
|
|
47
|
+
return !!req && typeof req === 'object' && loopbackRequests.has(req);
|
|
48
|
+
}
|
|
49
|
+
|
|
28
50
|
function headerValue(req, name) {
|
|
29
51
|
const value = req?.headers?.[name];
|
|
30
52
|
return String((Array.isArray(value) ? value[0] : value) ?? '').trim();
|
|
@@ -44,8 +66,21 @@ function headerHops(req) {
|
|
|
44
66
|
* built from the header (its value is the sender's relay id), so a turn an
|
|
45
67
|
* agent started is always recognisable as one. The hop count is the larger of
|
|
46
68
|
* the body's and the X-OAR-Remote-Hops header's.
|
|
69
|
+
* `local` is true for a request of this relay's own agent (the loopback);
|
|
70
|
+
* an origin's `local` flag is kept for those alone.
|
|
47
71
|
*/
|
|
48
72
|
export function readRemoteRelayRequest(req, inbound, { body = req?.body } = {}) {
|
|
73
|
+
const facts = readRemoteRelayRequestFacts(req, inbound, body);
|
|
74
|
+
if (!facts.remote) return facts;
|
|
75
|
+
const local = isRemoteRelayLoopbackRequest(req);
|
|
76
|
+
if (facts.origin?.local && !local) {
|
|
77
|
+
const { local: _claimed, ...origin } = facts.origin;
|
|
78
|
+
return { remote: true, origin };
|
|
79
|
+
}
|
|
80
|
+
return local ? { ...facts, local: true } : facts;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function readRemoteRelayRequestFacts(req, inbound, body) {
|
|
49
84
|
if (!inbound) return { remote: false, origin: null };
|
|
50
85
|
const rawOrigin = body && typeof body === 'object' ? body.origin : undefined;
|
|
51
86
|
const senderRelayId = headerValue(req, REMOTE_RELAY_HEADERS.origin);
|
|
@@ -78,7 +113,9 @@ export function remoteRelayInboundEnabled(inbound) {
|
|
|
78
113
|
*/
|
|
79
114
|
export function admitRemoteRelayRequest(req, res, inbound) {
|
|
80
115
|
const request = readRemoteRelayRequest(req, inbound);
|
|
81
|
-
|
|
116
|
+
// The switch is about OTHER relays' agents; this relay's own are let in by
|
|
117
|
+
// the agent-sessions setting, before the loopback is ever reached.
|
|
118
|
+
if (!request.remote || request.local || remoteRelayInboundEnabled(inbound)) return request;
|
|
82
119
|
res.status(403).json({ ...REMOTE_INBOUND_DISABLED_RESPONSE });
|
|
83
120
|
return null;
|
|
84
121
|
}
|
|
@@ -164,9 +201,13 @@ export function formatRemoteRelayMentionHint(relays = []) {
|
|
|
164
201
|
/**
|
|
165
202
|
* The paired relay a remote prompt came from: `{ relayId, name }` (`relayId`
|
|
166
203
|
* is the local registry id), or null when the sender is not paired here or
|
|
167
|
-
* names no relay id.
|
|
204
|
+
* names no relay id. For a prompt from an agent of this relay itself:
|
|
205
|
+
* `{ relayId, name, local: true }` with the local target's id.
|
|
168
206
|
*/
|
|
169
207
|
export function findOriginRemoteRelay(inbound, origin) {
|
|
208
|
+
if (inbound && origin?.local === true) {
|
|
209
|
+
return { relayId: REMOTE_RELAY_LOCAL_ID, name: String(origin.relayName || '').trim() || REMOTE_RELAY_LOCAL_ALIAS, local: true };
|
|
210
|
+
}
|
|
170
211
|
const instance = String(origin?.relayId || '').trim();
|
|
171
212
|
if (!inbound || typeof inbound.listRelays !== 'function' || !instance) return null;
|
|
172
213
|
try {
|
|
@@ -188,6 +229,12 @@ export function findOriginRemoteRelay(inbound, origin) {
|
|
|
188
229
|
export function formatRemoteRelayOriginHint(relay, origin) {
|
|
189
230
|
const name = hintText(relay?.name);
|
|
190
231
|
if (!name) return '';
|
|
232
|
+
if (relay?.local === true) {
|
|
233
|
+
const title = hintText(origin?.conversationTitle, 80);
|
|
234
|
+
return `<system_reminder>This prompt came from an agent in another session on this relay${title ? ` ("${title}")` : ''}, which started or prompted this session for a part of its own task. `
|
|
235
|
+
+ 'Your reply to it is handed back to that agent by itself, so end with the result it needs. '
|
|
236
|
+
+ 'You see nothing of its conversation: work from the prompt alone.</system_reminder>';
|
|
237
|
+
}
|
|
191
238
|
const session = hintText(origin?.conversationId, 80);
|
|
192
239
|
const where = session ? `relay "${name}", session "${session}"` : `relay "${name}"`;
|
|
193
240
|
return `<system_reminder>This prompt came from an agent on the paired OAR relay "${name}". `
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import { IncomingMessage, ServerResponse } from 'node:http';
|
|
4
|
+
import { Duplex } from 'node:stream';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
REMOTE_RELAY_ERROR_CODES,
|
|
8
|
+
REMOTE_RELAY_HEADERS,
|
|
9
|
+
REMOTE_RELAY_LIMITS,
|
|
10
|
+
} from '../../shared/remote-relay-contract.mjs';
|
|
11
|
+
import { RemoteRelayError, remoteRelayHttpError } from './remote-relay-client.mjs';
|
|
12
|
+
import { markRemoteRelayLoopbackRequest } from './remote-relay-inbound.mjs';
|
|
13
|
+
|
|
14
|
+
// The client for the local target: this relay as a relay its own agents work
|
|
15
|
+
// on. It has the outbound client's `request(relay, method, path, options)`,
|
|
16
|
+
// but instead of opening a connection it hands the request to the relay's own
|
|
17
|
+
// HTTP handler in-process. So a session an agent creates here goes through the
|
|
18
|
+
// very routes a paired relay's request reaches (bootstrap, message,
|
|
19
|
+
// conversation, questions, cancel-turn, archive) with their validation, their
|
|
20
|
+
// provenance and their events, and there is no second implementation of what a
|
|
21
|
+
// created session is or how reading, stopping and answering work.
|
|
22
|
+
//
|
|
23
|
+
// The request carries what the outbound client sends: the bearer token (the
|
|
24
|
+
// routes sit behind `auth`), Accept, and the two x-oar-remote-* headers, so the
|
|
25
|
+
// routes treat it as an agent's request (approval cards stay hidden, nothing
|
|
26
|
+
// unlocks, the composer draft is left alone). It is also marked as built here
|
|
27
|
+
// (markRemoteRelayLoopbackRequest), which is what lets it past the inbound
|
|
28
|
+
// switch and lets its origin say `local: true`; nothing sent over HTTP can
|
|
29
|
+
// claim that.
|
|
30
|
+
|
|
31
|
+
function toText(value) {
|
|
32
|
+
return String(value ?? '').trim();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isPlainObject(value) {
|
|
36
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function relayLabel(relay) {
|
|
40
|
+
const name = toText(relay?.name);
|
|
41
|
+
return name ? `Relay "${name}"` : 'This relay';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function buildUrl(path, query) {
|
|
45
|
+
const route = String(path || '');
|
|
46
|
+
if (!route.startsWith('/') || route.startsWith('//')) {
|
|
47
|
+
throw new RemoteRelayError(REMOTE_RELAY_ERROR_CODES.invalidInput, `Invalid path: ${route.slice(0, 80)}`);
|
|
48
|
+
}
|
|
49
|
+
const params = new URLSearchParams();
|
|
50
|
+
if (isPlainObject(query)) {
|
|
51
|
+
for (const [key, value] of Object.entries(query)) {
|
|
52
|
+
if (value === undefined || value === null) continue;
|
|
53
|
+
params.set(key, String(value));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const search = params.toString();
|
|
57
|
+
return search ? `${route}?${search}` : route;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A socket that goes nowhere: the response's bytes are read off the response itself. */
|
|
61
|
+
function createNullSocket() {
|
|
62
|
+
const socket = new Duplex({
|
|
63
|
+
read() {},
|
|
64
|
+
write(_chunk, _encoding, callback) { callback(); },
|
|
65
|
+
});
|
|
66
|
+
socket.remoteAddress = '127.0.0.1';
|
|
67
|
+
socket.setTimeout = () => socket;
|
|
68
|
+
socket.setNoDelay = () => socket;
|
|
69
|
+
socket.setKeepAlive = () => socket;
|
|
70
|
+
return socket;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function toBuffer(chunk, encoding) {
|
|
74
|
+
if (chunk === undefined || chunk === null || typeof chunk === 'function') return null;
|
|
75
|
+
if (Buffer.isBuffer(chunk)) return chunk;
|
|
76
|
+
if (chunk instanceof Uint8Array) return Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
|
77
|
+
return Buffer.from(String(chunk), typeof encoding === 'string' ? encoding : 'utf8');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function createRemoteRelayLoopbackClient({
|
|
81
|
+
// The relay's own request handler: `(req, res) => void` (the Express app).
|
|
82
|
+
handle,
|
|
83
|
+
getOwnRelayId = () => '',
|
|
84
|
+
getOwnToken = () => '',
|
|
85
|
+
timeoutMs: defaultTimeoutMs = REMOTE_RELAY_LIMITS.requestTimeoutMs,
|
|
86
|
+
maxBytes = REMOTE_RELAY_LIMITS.responseMaxBytes,
|
|
87
|
+
setTimeoutImpl = setTimeout,
|
|
88
|
+
clearTimeoutImpl = clearTimeout,
|
|
89
|
+
} = {}) {
|
|
90
|
+
/** Runs one request through the handler. Resolves `{ status, text }`. */
|
|
91
|
+
function inject({ method, url, headers, body, hasBody, timeoutMs }) {
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
const socket = createNullSocket();
|
|
94
|
+
const req = new IncomingMessage(socket);
|
|
95
|
+
req.method = method;
|
|
96
|
+
req.url = url;
|
|
97
|
+
req.headers = headers;
|
|
98
|
+
req.rawHeaders = Object.entries(headers).flat();
|
|
99
|
+
req.httpVersionMajor = 1;
|
|
100
|
+
req.httpVersionMinor = 1;
|
|
101
|
+
req.httpVersion = '1.1';
|
|
102
|
+
if (hasBody) {
|
|
103
|
+
// What the wire would deliver: JSON, parsed. `_body` tells the JSON
|
|
104
|
+
// body parser the body is already there.
|
|
105
|
+
req.body = JSON.parse(JSON.stringify(body));
|
|
106
|
+
req._body = true;
|
|
107
|
+
}
|
|
108
|
+
req.complete = true;
|
|
109
|
+
req.push(null);
|
|
110
|
+
markRemoteRelayLoopbackRequest(req);
|
|
111
|
+
|
|
112
|
+
const res = new ServerResponse(req);
|
|
113
|
+
res.assignSocket(socket);
|
|
114
|
+
// An HTTP server forwards its socket's drain to the response; a handler
|
|
115
|
+
// that streams its answer waits for it.
|
|
116
|
+
socket.on('drain', () => res.emit('drain'));
|
|
117
|
+
const chunks = [];
|
|
118
|
+
let total = 0;
|
|
119
|
+
let settled = false;
|
|
120
|
+
let timer = null;
|
|
121
|
+
const finish = (error, value) => {
|
|
122
|
+
if (settled) return;
|
|
123
|
+
settled = true;
|
|
124
|
+
if (timer) clearTimeoutImpl(timer);
|
|
125
|
+
try { res.detachSocket(socket); } catch {}
|
|
126
|
+
try { socket.destroy(); } catch {}
|
|
127
|
+
if (error) reject(error);
|
|
128
|
+
else resolve(value);
|
|
129
|
+
};
|
|
130
|
+
const collect = (chunk, encoding) => {
|
|
131
|
+
const buffer = toBuffer(chunk, encoding);
|
|
132
|
+
if (!buffer || !buffer.length) return;
|
|
133
|
+
total += buffer.length;
|
|
134
|
+
if (total <= maxBytes) chunks.push(buffer);
|
|
135
|
+
};
|
|
136
|
+
const write = res.write;
|
|
137
|
+
const end = res.end;
|
|
138
|
+
res.write = function writeCollected(chunk, encoding, callback) {
|
|
139
|
+
collect(chunk, encoding);
|
|
140
|
+
return write.call(this, chunk, encoding, callback);
|
|
141
|
+
};
|
|
142
|
+
res.end = function endCollected(chunk, encoding, callback) {
|
|
143
|
+
collect(chunk, encoding);
|
|
144
|
+
return end.call(this, chunk, encoding, callback);
|
|
145
|
+
};
|
|
146
|
+
res.once('finish', () => {
|
|
147
|
+
if (total > maxBytes) {
|
|
148
|
+
finish(new RemoteRelayError(
|
|
149
|
+
REMOTE_RELAY_ERROR_CODES.unsupported,
|
|
150
|
+
`This relay's answer is larger than ${maxBytes} bytes`,
|
|
151
|
+
{ detail: 'response too large' },
|
|
152
|
+
));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
finish(null, { status: res.statusCode, text: Buffer.concat(chunks).toString('utf8') });
|
|
156
|
+
});
|
|
157
|
+
res.once('error', (error) => finish(error));
|
|
158
|
+
|
|
159
|
+
if (timeoutMs > 0) {
|
|
160
|
+
timer = setTimeoutImpl(() => finish(new RemoteRelayError(
|
|
161
|
+
REMOTE_RELAY_ERROR_CODES.offline,
|
|
162
|
+
`This relay did not answer its own request within ${Math.round(timeoutMs / 100) / 10} s`,
|
|
163
|
+
{ detail: 'loopback timeout' },
|
|
164
|
+
)), timeoutMs);
|
|
165
|
+
timer?.unref?.();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
handle(req, res);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
finish(error);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* One call to this relay's own API. `relay` is the dispatcher's local target
|
|
178
|
+
* (only its name is used, for messages). Resolves to the parsed JSON body
|
|
179
|
+
* (null when empty); rejects with a RemoteRelayError, like the outbound
|
|
180
|
+
* client's request.
|
|
181
|
+
*/
|
|
182
|
+
async function request(relay, method, path, { body, query, hops = 0, timeoutMs } = {}) {
|
|
183
|
+
if (typeof handle !== 'function') {
|
|
184
|
+
throw new RemoteRelayError(REMOTE_RELAY_ERROR_CODES.unsupported, 'This relay cannot call its own API', { detail: 'no handler' });
|
|
185
|
+
}
|
|
186
|
+
const token = toText(getOwnToken?.());
|
|
187
|
+
if (!token) {
|
|
188
|
+
throw new RemoteRelayError(REMOTE_RELAY_ERROR_CODES.unauthorized, 'This relay has no token configured');
|
|
189
|
+
}
|
|
190
|
+
const hopCount = Number.isInteger(Number(hops)) && Number(hops) >= 0 ? Number(hops) : 0;
|
|
191
|
+
const hasBody = body !== undefined;
|
|
192
|
+
const headers = {
|
|
193
|
+
authorization: `Bearer ${token}`,
|
|
194
|
+
accept: 'application/json',
|
|
195
|
+
host: 'localhost',
|
|
196
|
+
[REMOTE_RELAY_HEADERS.origin]: toText(getOwnRelayId?.()) || 'self',
|
|
197
|
+
[REMOTE_RELAY_HEADERS.hops]: String(hopCount),
|
|
198
|
+
...(hasBody ? { 'content-type': 'application/json' } : {}),
|
|
199
|
+
};
|
|
200
|
+
const effectiveTimeoutMs = Number.isFinite(Number(timeoutMs)) && Number(timeoutMs) > 0
|
|
201
|
+
? Number(timeoutMs)
|
|
202
|
+
: defaultTimeoutMs;
|
|
203
|
+
|
|
204
|
+
let answer;
|
|
205
|
+
try {
|
|
206
|
+
answer = await inject({
|
|
207
|
+
method: String(method || 'GET').toUpperCase(),
|
|
208
|
+
url: buildUrl(path, query),
|
|
209
|
+
headers,
|
|
210
|
+
body,
|
|
211
|
+
hasBody,
|
|
212
|
+
timeoutMs: effectiveTimeoutMs,
|
|
213
|
+
});
|
|
214
|
+
} catch (error) {
|
|
215
|
+
if (error instanceof RemoteRelayError) throw error;
|
|
216
|
+
throw new RemoteRelayError(
|
|
217
|
+
REMOTE_RELAY_ERROR_CODES.internal,
|
|
218
|
+
`${relayLabel(relay)} failed on its own request: ${toText(error?.message || error).slice(0, 200)}`,
|
|
219
|
+
{ detail: 'loopback failure' },
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const status = Number(answer.status) || 0;
|
|
224
|
+
let parsed = null;
|
|
225
|
+
let json = true;
|
|
226
|
+
if (answer.text.trim()) {
|
|
227
|
+
try {
|
|
228
|
+
parsed = JSON.parse(answer.text);
|
|
229
|
+
} catch {
|
|
230
|
+
json = false;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (status < 200 || status >= 300) throw remoteRelayHttpError(relay, status, parsed);
|
|
234
|
+
if (!json) {
|
|
235
|
+
throw new RemoteRelayError(
|
|
236
|
+
REMOTE_RELAY_ERROR_CODES.unsupported,
|
|
237
|
+
`${relayLabel(relay)} did not answer its own request with JSON`,
|
|
238
|
+
{ status, detail: 'not json' },
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
return parsed;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return { request };
|
|
245
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { randomUUID as nodeRandomUUID } from 'crypto';
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
|
+
AGENT_SESSIONS_SOCKET_EVENT,
|
|
6
7
|
REMOTE_RELAY_LIMITS,
|
|
7
8
|
REMOTE_RELAY_PERMISSIONS,
|
|
8
9
|
REMOTE_RELAY_PROTOCOL,
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
REMOTE_RELAY_ERROR_CODES,
|
|
12
13
|
checkRemoteRelayUrlPolicy,
|
|
13
14
|
normalizeRemoteRelayLink,
|
|
15
|
+
normalizeRemoteRelayMaxWaitSeconds,
|
|
14
16
|
normalizeRemoteRelayPermission,
|
|
15
17
|
} from '../../shared/remote-relay-contract.mjs';
|
|
16
18
|
import { remoteRelayAliases } from '../../shared/remote-relay-mentions.mjs';
|
|
@@ -23,6 +25,9 @@ import { remoteRelayAliases } from '../../shared/remote-relay-mentions.mjs';
|
|
|
23
25
|
// remote_relays the entries, tokens included
|
|
24
26
|
// relay_public_url how other relays reach this one
|
|
25
27
|
// remote_relay_inbound_enabled accept prompts from other relays' agents
|
|
28
|
+
// agent_sessions_enabled this relay's own agents may start and use
|
|
29
|
+
// sessions on it (the local target; off by default)
|
|
30
|
+
// remote_relay_max_wait_seconds the longest wait of one remote_relay call
|
|
26
31
|
//
|
|
27
32
|
// Tokens never leave through listPublic(), the socket event or any route; only
|
|
28
33
|
// the outbound client reads them (list()/get()/resolve() are server-internal).
|
|
@@ -260,6 +265,51 @@ export function createRemoteRelayRegistry({
|
|
|
260
265
|
return { ok: true, ...getSelfSettings() };
|
|
261
266
|
}
|
|
262
267
|
|
|
268
|
+
// ─── Agent sessions ────────────────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
/** The payload of GET /api/settings/agent-sessions and of the socket event. */
|
|
271
|
+
function getAgentSessionsSettings() {
|
|
272
|
+
const enabled = readJsonSetting(REMOTE_RELAY_SETTING_KEYS.agentSessionsEnabled);
|
|
273
|
+
return {
|
|
274
|
+
enabled: enabled === true || enabled === 1 || ['true', '1', 'on'].includes(toText(enabled).toLowerCase()),
|
|
275
|
+
maxWaitSeconds: normalizeRemoteRelayMaxWaitSeconds(readJsonSetting(REMOTE_RELAY_SETTING_KEYS.maxWaitSeconds)),
|
|
276
|
+
limits: {
|
|
277
|
+
minWaitSeconds: REMOTE_RELAY_LIMITS.waitMaxFloorSeconds,
|
|
278
|
+
maxWaitSeconds: REMOTE_RELAY_LIMITS.waitMaxCeilingSeconds,
|
|
279
|
+
stepSeconds: REMOTE_RELAY_LIMITS.waitMaxStepSeconds,
|
|
280
|
+
},
|
|
281
|
+
maxActiveSessions: REMOTE_RELAY_LIMITS.localActiveSessions,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* `{ enabled?, maxWaitSeconds? }`. Nothing is rounded or clamped here: a
|
|
287
|
+
* value outside the range, or off the slider's step, is refused, so what the
|
|
288
|
+
* settings page shows is what was stored.
|
|
289
|
+
*/
|
|
290
|
+
function setAgentSessionsSettings(patch = {}) {
|
|
291
|
+
const input = patch && typeof patch === 'object' && !Array.isArray(patch) ? patch : {};
|
|
292
|
+
if (input.enabled !== undefined && typeof input.enabled !== 'boolean') {
|
|
293
|
+
return invalid('enabled must be true or false');
|
|
294
|
+
}
|
|
295
|
+
if (input.maxWaitSeconds !== undefined) {
|
|
296
|
+
const { waitMaxFloorSeconds: min, waitMaxCeilingSeconds: max, waitMaxStepSeconds: step } = REMOTE_RELAY_LIMITS;
|
|
297
|
+
const seconds = input.maxWaitSeconds;
|
|
298
|
+
if (typeof seconds !== 'number' || !Number.isInteger(seconds) || seconds < min || seconds > max || seconds % step !== 0) {
|
|
299
|
+
return invalid(`maxWaitSeconds must be a whole number of seconds from ${min} to ${max}, in steps of ${step}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (input.enabled !== undefined) writeJsonSetting(REMOTE_RELAY_SETTING_KEYS.agentSessionsEnabled, input.enabled);
|
|
303
|
+
if (input.maxWaitSeconds !== undefined) writeJsonSetting(REMOTE_RELAY_SETTING_KEYS.maxWaitSeconds, input.maxWaitSeconds);
|
|
304
|
+
const settings = getAgentSessionsSettings();
|
|
305
|
+
try {
|
|
306
|
+
emit(AGENT_SESSIONS_SOCKET_EVENT, settings);
|
|
307
|
+
} catch (error) {
|
|
308
|
+
logger?.warn?.(`[remote-relays] agent sessions event failed: ${error?.message || error}`);
|
|
309
|
+
}
|
|
310
|
+
return { ok: true, ...settings };
|
|
311
|
+
}
|
|
312
|
+
|
|
263
313
|
// ─── Remote relays ─────────────────────────────────────────────────────────
|
|
264
314
|
|
|
265
315
|
function list() {
|
|
@@ -562,6 +612,8 @@ export function createRemoteRelayRegistry({
|
|
|
562
612
|
selfIdentity,
|
|
563
613
|
getSelfSettings,
|
|
564
614
|
setSelfSettings,
|
|
615
|
+
getAgentSessionsSettings,
|
|
616
|
+
setAgentSessionsSettings,
|
|
565
617
|
list,
|
|
566
618
|
listPublic,
|
|
567
619
|
get,
|
|
@@ -160,6 +160,23 @@ export function applyGrokProviderEnvironment(env = {}, {
|
|
|
160
160
|
return next;
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
export function applyClaudeCloudProviderEnvironment(env = {}, {
|
|
164
|
+
enabled = false,
|
|
165
|
+
model = '',
|
|
166
|
+
} = {}) {
|
|
167
|
+
const next = { ...env };
|
|
168
|
+
if (resolveWorkerKind(next) === 'claude-cloud') delete next.COPILOT_WEB_RELAY_WORKER_KIND;
|
|
169
|
+
delete next.CLAUDE_CLOUD_RELAY_MODEL;
|
|
170
|
+
if (!enabled) return next;
|
|
171
|
+
// No credential is added here: the worker reads the Claude CLI's login
|
|
172
|
+
// itself. A relay that runs on CLAUDE_CODE_OAUTH_TOKEN instead has it in its
|
|
173
|
+
// own environment; workerSecretEnvVarsFor hands that to the cloud worker.
|
|
174
|
+
next.COPILOT_WEB_RELAY_WORKER_KIND = 'claude-cloud';
|
|
175
|
+
const normalizedModel = normalizeText(model);
|
|
176
|
+
if (normalizedModel) next.CLAUDE_CLOUD_RELAY_MODEL = normalizedModel;
|
|
177
|
+
return next;
|
|
178
|
+
}
|
|
179
|
+
|
|
163
180
|
/**
|
|
164
181
|
* The Copilot SDK engine: the same `github`/`openai` providers the extension
|
|
165
182
|
* serves, run as a plain Node worker against the CLI's headless runtime.
|
|
@@ -245,6 +262,10 @@ export function isGrokWorkerEnvironment(env = {}) {
|
|
|
245
262
|
return resolveWorkerKind(env) === 'grok';
|
|
246
263
|
}
|
|
247
264
|
|
|
265
|
+
export function isClaudeCloudWorkerEnvironment(env = {}) {
|
|
266
|
+
return resolveWorkerKind(env) === 'claude-cloud';
|
|
267
|
+
}
|
|
268
|
+
|
|
248
269
|
export function isCopilotSdkWorkerEnvironment(env = {}) {
|
|
249
270
|
return resolveWorkerKind(env) === 'copilot-sdk';
|
|
250
271
|
}
|
|
@@ -279,6 +300,16 @@ export function resolveGrokWorkerScriptPath(env = {}) {
|
|
|
279
300
|
return path.join(process.cwd(), 'server', 'grok-worker', 'grok-session-worker.mjs');
|
|
280
301
|
}
|
|
281
302
|
|
|
303
|
+
export function resolveClaudeCloudWorkerScriptPath(env = {}) {
|
|
304
|
+
const explicit = normalizeText(env?.COPILOT_WEB_RELAY_CLAUDE_CLOUD_WORKER_PATH);
|
|
305
|
+
if (explicit) return explicit;
|
|
306
|
+
const repoRoot = normalizeText(env?.COPILOT_WEB_RELAY_ROOT);
|
|
307
|
+
if (repoRoot) return path.join(repoRoot, 'server', 'claude-cloud-worker', 'claude-cloud-session-worker.mjs');
|
|
308
|
+
const serverDir = normalizeText(env?.COPILOT_WEB_RELAY_SERVER_DIR);
|
|
309
|
+
if (serverDir) return path.join(serverDir, 'claude-cloud-worker', 'claude-cloud-session-worker.mjs');
|
|
310
|
+
return path.join(process.cwd(), 'server', 'claude-cloud-worker', 'claude-cloud-session-worker.mjs');
|
|
311
|
+
}
|
|
312
|
+
|
|
282
313
|
export function resolveCopilotSdkWorkerScriptPath(env = {}) {
|
|
283
314
|
const explicit = normalizeText(env?.COPILOT_WEB_RELAY_COPILOT_SDK_WORKER_PATH);
|
|
284
315
|
if (explicit) return explicit;
|
|
@@ -293,11 +324,16 @@ export function resolveCopilotSdkWorkerScriptPath(env = {}) {
|
|
|
293
324
|
// `copilot` kind is intentionally absent: that is the extension engine, whose
|
|
294
325
|
// launch path (the real TUI under a `script` pseudo-TTY) must stay exactly
|
|
295
326
|
// as-is. `copilot-sdk` is the SAME provider on the headless SDK engine, and it
|
|
296
|
-
// belongs here with Claude/Cursor/Grok.
|
|
327
|
+
// belongs here with Claude/Cursor/Grok. `claude-cloud` is a provider of its
|
|
328
|
+
// own: its worker follows a session in Anthropic's cloud and runs no CLI.
|
|
297
329
|
const NODE_WORKER_DESCRIPTORS = Object.freeze({
|
|
298
330
|
claude: Object.freeze({ resolveScriptPath: resolveClaudeWorkerScriptPath, windowsTitle: 'Claude Worker' }),
|
|
299
331
|
cursor: Object.freeze({ resolveScriptPath: resolveCursorWorkerScriptPath, windowsTitle: 'Cursor Worker' }),
|
|
300
332
|
grok: Object.freeze({ resolveScriptPath: resolveGrokWorkerScriptPath, windowsTitle: 'Grok Worker' }),
|
|
333
|
+
'claude-cloud': Object.freeze({
|
|
334
|
+
resolveScriptPath: resolveClaudeCloudWorkerScriptPath,
|
|
335
|
+
windowsTitle: 'Claude Cloud Worker',
|
|
336
|
+
}),
|
|
301
337
|
'copilot-sdk': Object.freeze({
|
|
302
338
|
resolveScriptPath: resolveCopilotSdkWorkerScriptPath,
|
|
303
339
|
windowsTitle: 'Copilot SDK Worker',
|
|
@@ -472,14 +508,38 @@ export function getTmuxPanePid(sessionName, {
|
|
|
472
508
|
}
|
|
473
509
|
}
|
|
474
510
|
|
|
475
|
-
export const WORKER_SECRET_ENV_VARS = Object.freeze(['COPILOT_PROVIDER_API_KEY', 'CURSOR_API_KEY']);
|
|
511
|
+
export const WORKER_SECRET_ENV_VARS = Object.freeze(['COPILOT_PROVIDER_API_KEY', 'CURSOR_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN']);
|
|
512
|
+
|
|
513
|
+
// Secrets that are not every worker's. The two API keys are put into the
|
|
514
|
+
// launch environment by their own provider's applier and cleared for every
|
|
515
|
+
// other one, so they need no entry. The Claude login token is different: it
|
|
516
|
+
// sits in the relay's own environment (a relay run on CLAUDE_CODE_OAUTH_TOKEN
|
|
517
|
+
// instead of the CLI's credentials file), so it would otherwise travel with
|
|
518
|
+
// every launch. Only the Claude Cloud worker gets it: its cloud client reads
|
|
519
|
+
// the login from there exactly as the relay's does (credentials.mjs).
|
|
520
|
+
const WORKER_SECRET_ENV_VAR_KINDS = Object.freeze({
|
|
521
|
+
CLAUDE_CODE_OAUTH_TOKEN: Object.freeze(['claude-cloud']),
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* The secret variables the worker of this launch environment is handed, and
|
|
526
|
+
* only through the secret env file: never as a tmux `export`, never in the
|
|
527
|
+
* environment tmux itself is started with.
|
|
528
|
+
*/
|
|
529
|
+
export function workerSecretEnvVarsFor(env = {}) {
|
|
530
|
+
const kind = resolveWorkerKind(env);
|
|
531
|
+
return WORKER_SECRET_ENV_VARS.filter((key) => {
|
|
532
|
+
const kinds = WORKER_SECRET_ENV_VAR_KINDS[key];
|
|
533
|
+
return !kinds || kinds.includes(kind);
|
|
534
|
+
});
|
|
535
|
+
}
|
|
476
536
|
|
|
477
537
|
export function createWorkerSecretEnvFile(env = {}, {
|
|
478
538
|
fsImpl = fs,
|
|
479
539
|
tempRoot = os.tmpdir(),
|
|
480
540
|
} = {}) {
|
|
481
541
|
const secretLines = [];
|
|
482
|
-
for (const key of
|
|
542
|
+
for (const key of workerSecretEnvVarsFor(env)) {
|
|
483
543
|
const value = String(env?.[key] || '').trim();
|
|
484
544
|
if (!value) continue;
|
|
485
545
|
secretLines.push(`export ${key}=${shellQuote(value)}\n`);
|
|
@@ -555,7 +615,7 @@ export function buildTmuxWorkerShellCommand(targetSessionId, env = {}, {
|
|
|
555
615
|
...env,
|
|
556
616
|
SESSION_ID: String(targetSessionId || '').trim() || String(env?.SESSION_ID || '').trim(),
|
|
557
617
|
};
|
|
558
|
-
const hasSecretEnvValue =
|
|
618
|
+
const hasSecretEnvValue = workerSecretEnvVarsFor(launchEnv)
|
|
559
619
|
.some((key) => String(launchEnv[key] || '').trim());
|
|
560
620
|
const normalizedSecretEnvFilePath = String(secretEnvFilePath || '').trim();
|
|
561
621
|
if (hasSecretEnvValue && !normalizedSecretEnvFilePath) {
|
|
@@ -602,6 +662,16 @@ export function buildTmuxWorkerShellCommand(targetSessionId, env = {}, {
|
|
|
602
662
|
// counterpart, has always been on this list.
|
|
603
663
|
'CLAUDE_CODE_EXECUTABLE',
|
|
604
664
|
'CLAUDE_RELAY_MODEL',
|
|
665
|
+
// The Claude Cloud worker's model. Its login is not listed here: the
|
|
666
|
+
// worker reads the CLI's credentials file (CLAUDE_CONFIG_DIR above tells
|
|
667
|
+
// it where), and a relay-level CLAUDE_CODE_OAUTH_TOKEN is a secret
|
|
668
|
+
// (workerSecretEnvVarsFor), so it comes through the secret file.
|
|
669
|
+
'CLAUDE_CLOUD_RELAY_MODEL',
|
|
670
|
+
// Tests point the relay's cloud client at a fake API on this machine;
|
|
671
|
+
// the worker's client has to follow, or the relay would talk to the fake
|
|
672
|
+
// and its worker to the real API. Not a secret, and harmless as a value:
|
|
673
|
+
// shared/claude-cloud/base-url.mjs ignores everything but a loopback URL.
|
|
674
|
+
'OAR_CLAUDE_CLOUD_API_BASE_URL',
|
|
605
675
|
'CURSOR_RELAY_MODEL',
|
|
606
676
|
'CURSOR_AGENT_STORE_DIR',
|
|
607
677
|
'GROK_RELAY_MODEL',
|
|
@@ -688,10 +758,30 @@ export async function launchSessionCli({
|
|
|
688
758
|
// Windows console launch: resolves (and rotates) the worker log the worker
|
|
689
759
|
// then writes itself; returns its path or null.
|
|
690
760
|
prepareWorkerLogFileImpl = prepareWorkerLogFile,
|
|
761
|
+
// Told when the process list could not be read (the error); the launch
|
|
762
|
+
// goes on without it.
|
|
763
|
+
onProcessListError = null,
|
|
691
764
|
} = {}) {
|
|
692
765
|
const target = String(targetSessionId || '').trim();
|
|
693
766
|
if (!target) throw new Error('missing-target-session-id');
|
|
694
767
|
|
|
768
|
+
// On win32 the process list comes from PowerShell and can be unreadable for
|
|
769
|
+
// a reason that has nothing to do with this session (2026-10-01: a control
|
|
770
|
+
// byte in another process's command line, for an hour). A launch is not
|
|
771
|
+
// stopped by that: an unreadable list counts as "no process found".
|
|
772
|
+
// The asynchronous finder reads the list off the relay's thread (shared
|
|
773
|
+
// between concurrent callers, cached for a moment); `fresh` is for the poll
|
|
774
|
+
// that waits for the process this launch just started.
|
|
775
|
+
const findLiveProcess = async ({ fresh = false } = {}) => {
|
|
776
|
+
if (typeof processInspector?.findProcessForSessionAsync !== 'function') return null;
|
|
777
|
+
try {
|
|
778
|
+
return (await processInspector.findProcessForSessionAsync(target, { fresh })) || null;
|
|
779
|
+
} catch (error) {
|
|
780
|
+
try { onProcessListError?.(error); } catch {}
|
|
781
|
+
return null;
|
|
782
|
+
}
|
|
783
|
+
};
|
|
784
|
+
|
|
695
785
|
// Kill switch for test/e2e servers: a relay started with this env var must
|
|
696
786
|
// never spawn real session workers (Copilot CLI clients or Claude workers).
|
|
697
787
|
if (String(env?.COPILOT_WEB_RELAY_DISABLE_CLI_SPAWN || '').trim()) {
|
|
@@ -699,9 +789,7 @@ export async function launchSessionCli({
|
|
|
699
789
|
}
|
|
700
790
|
|
|
701
791
|
if (allowProcessReuse) {
|
|
702
|
-
const liveProcess =
|
|
703
|
-
? processInspector.findProcessForSession(target)
|
|
704
|
-
: null;
|
|
792
|
+
const liveProcess = await findLiveProcess();
|
|
705
793
|
const liveProcessPid = parsePositiveInt(liveProcess?.processId);
|
|
706
794
|
if (liveProcessPid && isPidAlive(liveProcessPid)) {
|
|
707
795
|
return {
|
|
@@ -741,8 +829,9 @@ export async function launchSessionCli({
|
|
|
741
829
|
const tmuxEnv = { ...launchSessionEnv };
|
|
742
830
|
delete tmuxEnv.TMUX;
|
|
743
831
|
delete tmuxEnv.TMUX_PANE;
|
|
744
|
-
|
|
745
|
-
|
|
832
|
+
// What the secret file carries is in no environment tmux is started with
|
|
833
|
+
// (a tmux server started by this call would keep it as its global one).
|
|
834
|
+
for (const key of workerSecretEnvVarsFor(launchSessionEnv)) delete tmuxEnv[key];
|
|
746
835
|
if (allowProcessReuse) {
|
|
747
836
|
const existingPanePid = getTmuxPanePid(sessionName, { execFileSyncImpl });
|
|
748
837
|
if (existingPanePid && isPidAlive(existingPanePid)) {
|
|
@@ -785,9 +874,7 @@ export async function launchSessionCli({
|
|
|
785
874
|
const attempts = Math.max(1, Number(tmuxPollAttempts) || 1);
|
|
786
875
|
for (let index = 0; index < attempts; index += 1) {
|
|
787
876
|
await sleep(Math.max(50, Number(tmuxPollDelayMs) || 200));
|
|
788
|
-
const processMatch =
|
|
789
|
-
? processInspector.findProcessForSession(target)
|
|
790
|
-
: null;
|
|
877
|
+
const processMatch = await findLiveProcess({ fresh: true });
|
|
791
878
|
if (processMatch?.processId) {
|
|
792
879
|
return {
|
|
793
880
|
pid: Number(processMatch.processId),
|
|
@@ -886,9 +973,7 @@ export async function launchSessionCli({
|
|
|
886
973
|
const attempts = Math.max(1, Number(detachedPollAttempts) || 1);
|
|
887
974
|
for (let index = 0; index < attempts; index += 1) {
|
|
888
975
|
await sleep(Math.max(50, Number(detachedPollDelayMs) || 200));
|
|
889
|
-
const processMatch =
|
|
890
|
-
? processInspector.findProcessForSession(target)
|
|
891
|
-
: null;
|
|
976
|
+
const processMatch = await findLiveProcess({ fresh: true });
|
|
892
977
|
const processPid = parsePositiveInt(processMatch?.processId);
|
|
893
978
|
if (processPid && isPidAlive(processPid)) {
|
|
894
979
|
return {
|