@oar-sh/oar 0.9.10 → 0.9.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/CHANGELOG.md +314 -1
  2. package/README.md +156 -972
  3. package/package.json +13 -13
  4. package/server/README.md +95 -22
  5. package/server/claude-worker/claude-session-process.mjs +89 -11
  6. package/server/claude-worker/claude-session-worker.mjs +9 -1
  7. package/server/copilot-worker/copilot-sdk-session-process.mjs +76 -11
  8. package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
  9. package/server/db-schema.mjs +59 -2
  10. package/server/grok-worker/acp-client.mjs +2 -1
  11. package/server/grok-worker/acp-host-services.mjs +4 -3
  12. package/server/migrations/0013-previews.mjs +130 -0
  13. package/server/migrations/0014-conversation-groups.mjs +89 -0
  14. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  15. package/server/migrations/0016-share-access-log.mjs +110 -0
  16. package/server/public/app/about-ui.js +73 -0
  17. package/server/public/app/about-view.mjs +234 -0
  18. package/server/public/app/action-confirmations.js +31 -14
  19. package/server/public/app/agent-exchange-fold-view.mjs +40 -101
  20. package/server/public/app/api-client.js +251 -16
  21. package/server/public/app/ask-user-view.js +17 -15
  22. package/server/public/app/bootstrap.js +158 -32
  23. package/server/public/app/chat-actions-menu-state.mjs +20 -10
  24. package/server/public/app/context-menu.mjs +90 -6
  25. package/server/public/app/conversation-group-drag.mjs +191 -0
  26. package/server/public/app/conversation-group-order.mjs +265 -0
  27. package/server/public/app/conversation-group-state.mjs +100 -0
  28. package/server/public/app/conversation-list-drag.mjs +449 -0
  29. package/server/public/app/conversation-turn-state.mjs +81 -0
  30. package/server/public/app/conversation-view.js +136 -93
  31. package/server/public/app/cwd-picker.js +1 -1
  32. package/server/public/app/hidden-messages-view.mjs +312 -0
  33. package/server/public/app/host-suspend-ui.js +7 -1
  34. package/server/public/app/journal-view.js +787 -22
  35. package/server/public/app/preview-cards.mjs +117 -31
  36. package/server/public/app/preview-command.mjs +10 -1
  37. package/server/public/app/relay-board-view.js +19 -6
  38. package/server/public/app/relay-restart-error.mjs +16 -0
  39. package/server/public/app/settings-modal.js +40 -8
  40. package/server/public/app/settings-tabs.js +1 -1
  41. package/server/public/app/share-format.mjs +49 -0
  42. package/server/public/app/share-preview-marks.mjs +167 -0
  43. package/server/public/app/share-state.js +109 -0
  44. package/server/public/app/shared-conversations-settings.js +480 -0
  45. package/server/public/app/socket-handlers.js +80 -14
  46. package/server/public/app/store.js +96 -30
  47. package/server/public/app/subagent-row.mjs +3 -3
  48. package/server/public/app/subagent-viewer.mjs +30 -6
  49. package/server/public/app/thinking-follow.js +0 -4
  50. package/server/public/app/transcript-jump-bottom-view.mjs +194 -20
  51. package/server/public/app/transcript-jump-bottom.mjs +132 -21
  52. package/server/public/app/transcript-viewport-anchor.mjs +140 -0
  53. package/server/public/index.html +386 -15
  54. package/server/relay-tools.md +2 -1
  55. package/server/relay.mjs +2 -1
  56. package/server/repositories/conversation-group-repository.mjs +444 -0
  57. package/server/repositories/message-repository.mjs +1 -0
  58. package/server/repositories/session-repository.mjs +12 -37
  59. package/server/repositories/share-repository.mjs +322 -0
  60. package/server/repositories/share-visibility-statements.mjs +10 -0
  61. package/server/routes/about-routes.mjs +15 -0
  62. package/server/routes/cache-routes.mjs +4 -0
  63. package/server/routes/conversation-group-routes.mjs +171 -0
  64. package/server/routes/messages-routes.mjs +184 -65
  65. package/server/routes/preview-routes.mjs +9 -1
  66. package/server/routes/sessions-routes.mjs +771 -193
  67. package/server/server-runtime.mjs +316 -100
  68. package/server/services/about-service.mjs +461 -0
  69. package/server/services/background-task-store.mjs +128 -0
  70. package/server/services/background-work-view.mjs +228 -0
  71. package/server/services/cache-rebuild-service.mjs +30 -0
  72. package/server/services/delete-archive-service.mjs +4 -1
  73. package/server/services/detached-worker-watch-service.mjs +150 -0
  74. package/server/services/hidden-message-list.mjs +21 -0
  75. package/server/services/host-suspend-command.mjs +2 -1
  76. package/server/services/message-pin-list.mjs +1 -1
  77. package/server/services/preview-health-probe.mjs +8 -1
  78. package/server/services/preview-proxy-server.mjs +8 -4
  79. package/server/services/preview-registry-service.mjs +676 -26
  80. package/server/services/preview-static-handler.mjs +40 -7
  81. package/server/services/preview-store.mjs +274 -0
  82. package/server/services/rate-limit-service.mjs +12 -1
  83. package/server/services/relay-auth.mjs +183 -0
  84. package/server/services/relay-cli-launcher-service.mjs +3 -1
  85. package/server/services/relay-event-service.mjs +17 -0
  86. package/server/services/remote-relay-dispatcher.mjs +57 -5
  87. package/server/services/request-boundary.mjs +183 -0
  88. package/server/services/session-activity-tracker.mjs +227 -19
  89. package/server/services/session-worker-launch-service.mjs +190 -6
  90. package/server/services/session-worker-process-service.mjs +10 -6
  91. package/server/services/share-access-log-service.mjs +123 -0
  92. package/server/services/share-path-redaction.mjs +424 -0
  93. package/server/services/status-event-service.mjs +7 -3
  94. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  95. package/server/services/windows-autostart-service.mjs +6 -1
  96. package/server/services/windows-boot-autostart-service.mjs +207 -23
  97. package/shared/preview-tool-core.mjs +15 -5
  98. package/shared/remote-relay-contract.mjs +3 -1
  99. package/shared/share-path-privacy.mjs +30 -0
  100. package/shared/windows-system-tools.mjs +106 -0
  101. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  102. package/shared/worker-runtime/heartbeat.mjs +24 -0
  103. package/shared/worker-runtime/kept-posts.mjs +83 -0
  104. package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
@@ -1238,7 +1238,21 @@ export function createRemoteRelayDispatcher({
1238
1238
  }
1239
1239
  }
1240
1240
 
1241
- function mapSession(row, workerIds, ctx = null) {
1241
+ /** The session group of a listed row: id, display name and the row's role. */
1242
+ function sessionGroupField(row, groups) {
1243
+ const groupId = toText(row?.groupId);
1244
+ if (!groupId) return {};
1245
+ const info = isPlainObject(groups?.[groupId]) ? groups[groupId] : null;
1246
+ return {
1247
+ group: {
1248
+ id: groupId,
1249
+ name: toText(info?.displayName) || toText(info?.name) || null,
1250
+ role: toText(row?.groupRole) === 'head' ? 'head' : 'member',
1251
+ },
1252
+ };
1253
+ }
1254
+
1255
+ function mapSession(row, workerIds, ctx = null, groups = null) {
1242
1256
  const id = toText(row?.id);
1243
1257
  const via = toText(row?.origin?.relayName);
1244
1258
  // A session an agent of that relay created there, and from which session.
@@ -1256,6 +1270,9 @@ export function createRemoteRelayDispatcher({
1256
1270
  ...(via ? { via } : {}),
1257
1271
  ...(createdBy ? { createdBy } : {}),
1258
1272
  ...(createdBy && ctx?.relay?.local && createdBy === ctx.conversationId ? { startedByYou: true } : {}),
1273
+ // The session group it sits in (sessions an agent started here are
1274
+ // grouped under that agent's session; the owner can group by hand).
1275
+ ...sessionGroupField(row, groups),
1259
1276
  };
1260
1277
  }
1261
1278
 
@@ -1296,7 +1313,7 @@ export function createRemoteRelayDispatcher({
1296
1313
  // pages, all = the whole list page by page (only it honours the cursor).
1297
1314
  let cursor = null;
1298
1315
  if (scope === 'all' && args.cursor) {
1299
- cursor = decodeCursor(args.cursor, ['beforeConversationId', 'beforeUpdatedAt']);
1316
+ cursor = decodeCursor(args.cursor, ['beforeConversationId', 'beforeUpdatedAt', 'beforeGroupId']);
1300
1317
  if (!cursor) throw invalidInput('cursor is not a nextCursor from list_sessions');
1301
1318
  }
1302
1319
  const workerIds = scope === 'active' ? await liveWorkerConversationIds(ctx) : null;
@@ -1305,6 +1322,7 @@ export function createRemoteRelayDispatcher({
1305
1322
  && (scope !== 'active' || row?.activeTurn === true || workerIds.has(toText(row?.id)) || isBusyState(row?.sessionState));
1306
1323
 
1307
1324
  const sessions = [];
1325
+ const seen = new Set();
1308
1326
  let hasMore = false;
1309
1327
  let scanned = 0;
1310
1328
  for (let page = 0; page < (filtering ? LIST_SCAN_PAGES : 1); page += 1) {
@@ -1313,18 +1331,32 @@ export function createRemoteRelayDispatcher({
1313
1331
  limit: filtering ? LIST_PAGE_MAX : limit,
1314
1332
  beforeConversationId: cursor?.beforeConversationId,
1315
1333
  beforeUpdatedAt: cursor?.beforeUpdatedAt,
1334
+ beforeGroupId: cursor?.beforeGroupId,
1316
1335
  },
1317
1336
  });
1318
1337
  const rows = Array.isArray(response?.conversations) ? response.conversations : [];
1338
+ const groups = isPlainObject(response?.groups) ? response.groups : null;
1319
1339
  let consumed = 0;
1320
1340
  for (const row of rows) {
1321
1341
  consumed += 1;
1322
- if (keep(row)) sessions.push(mapSession(row, workerIds, ctx));
1342
+ // A group served again whole (its cursor row vanished) repeats rows.
1343
+ if (seen.has(toText(row?.id))) continue;
1344
+ seen.add(toText(row?.id));
1345
+ if (keep(row)) sessions.push(mapSession(row, workerIds, ctx, groups));
1323
1346
  if (sessions.length >= limit) break;
1324
1347
  }
1325
1348
  scanned += consumed;
1326
1349
  const last = rows[consumed - 1];
1327
- if (last) cursor = { beforeConversationId: toText(last.id), beforeUpdatedAt: toText(last.updatedAt) };
1350
+ // The row's sort time (its group's newest row, `activityAt`) and its
1351
+ // group, so the next page continues from the right place even if the
1352
+ // row is archived or deleted before then.
1353
+ if (last) {
1354
+ cursor = {
1355
+ beforeConversationId: toText(last.id),
1356
+ beforeUpdatedAt: toText(last.activityAt) || toText(last.updatedAt),
1357
+ ...(toText(last.groupId) ? { beforeGroupId: toText(last.groupId) } : {}),
1358
+ };
1359
+ }
1328
1360
  hasMore = consumed < rows.length || response?.pageInfo?.hasMore === true;
1329
1361
  if (sessions.length >= limit || !hasMore || !rows.length) break;
1330
1362
  }
@@ -1405,7 +1437,9 @@ export function createRemoteRelayDispatcher({
1405
1437
  if (!before) throw invalidInput('before is not an olderCursor from read_session');
1406
1438
  }
1407
1439
  const conversation = await call(ctx, 'GET', sessionPath(args.session), {
1408
- query: { limit: args.last, beforeMessageId: before?.beforeMessageId, beforeTimestamp: before?.beforeTimestamp },
1440
+ // groupMembers: the session group's live members; the relay gives them
1441
+ // to its own sessions only.
1442
+ query: { limit: args.last, beforeMessageId: before?.beforeMessageId, beforeTimestamp: before?.beforeTimestamp, groupMembers: 1 },
1409
1443
  });
1410
1444
  const conversationId = toText(conversation?.id) || args.session;
1411
1445
  const pendingQuestions = await loadPendingQuestions(ctx, conversationId);
@@ -1425,6 +1459,23 @@ export function createRemoteRelayDispatcher({
1425
1459
  const runtime = isPlainObject(conversation?.runtimeSession) ? conversation.runtimeSession : {};
1426
1460
  const via = toText(conversation?.origin?.relayName);
1427
1461
  const inFlight = progressSnapshot(conversation?.inFlight);
1462
+ // Its session group, with the other members and what each is doing.
1463
+ const groupInfo = isPlainObject(conversation?.group) ? conversation.group : null;
1464
+ const group = groupInfo && toText(groupInfo.id) ? {
1465
+ id: toText(groupInfo.id),
1466
+ name: toText(groupInfo.displayName) || toText(groupInfo.name) || null,
1467
+ role: toText(groupInfo.role) === 'head' ? 'head' : 'member',
1468
+ members: (Array.isArray(groupInfo.members) ? groupInfo.members : [])
1469
+ .filter(isPlainObject)
1470
+ .slice(0, 50)
1471
+ .map((member) => ({
1472
+ id: toText(member.id),
1473
+ title: toText(member.title),
1474
+ role: toText(member.role) === 'head' ? 'head' : 'member',
1475
+ state: SESSION_STATES.has(toText(member.state)) ? toText(member.state) : 'idle',
1476
+ ...(member.archived === true ? { archived: true } : {}),
1477
+ })),
1478
+ } : null;
1428
1479
  return {
1429
1480
  summary: `read_session → ${ctx.relay.name} session ${shortId(conversationId)}: ${messages.length} message${messages.length === 1 ? '' : 's'}${inFlight ? ', turn running' : ''}${pendingQuestions.length ? `, ${pendingQuestions.length} question${pendingQuestions.length === 1 ? '' : 's'} pending` : ''}`,
1430
1481
  session: {
@@ -1438,6 +1489,7 @@ export function createRemoteRelayDispatcher({
1438
1489
  active: conversation?.activeTurn === true || isBusyState(conversation?.sessionState),
1439
1490
  ...sessionActivityFields(conversation),
1440
1491
  ...(via ? { via } : {}),
1492
+ ...(group ? { group } : {}),
1441
1493
  openUrl: remoteConversationUrl(ctx.relay.url, conversationId) || null,
1442
1494
  },
1443
1495
  messages,
@@ -0,0 +1,183 @@
1
+ 'use strict';
2
+
3
+ // Where a request came from, as far as the relay can tell.
4
+ //
5
+ // "Truly local" is the boundary for the few things only a process on the
6
+ // relay's own machine may do (run an image operation, open a session-worker
7
+ // socket): the TCP peer is loopback AND nothing in front of the relay touched
8
+ // the request. A same-host reverse proxy (Caddy, nginx), a hand-run
9
+ // cloudflared and the built-in tunnel all connect from 127.0.0.1 too, so the
10
+ // peer alone proves nothing; what tells them apart is what they add: an edge
11
+ // marker (`cf-ray`, or a configured one) or a forwarding header. Local workers
12
+ // and agent tools call the relay over 127.0.0.1 and send none of these.
13
+ //
14
+ // A forwarder that adds no header at all (an SSH reverse tunnel, a bare TCP
15
+ // proxy) cannot be told apart from a local process; the access token is then
16
+ // the only boundary, as it is for every other route.
17
+
18
+ // Cloudflare's edge stamps `cf-ray` on every proxied request and a remote
19
+ // client cannot strip it.
20
+ export const DEFAULT_TUNNEL_MARKER_HEADERS = Object.freeze(['cf-ray']);
21
+
22
+ // Headers a proxy adds to say who it forwards for. Read here only as a trace
23
+ // that a proxy was involved, never as an address.
24
+ export const FORWARDING_HEADERS = Object.freeze(['x-forwarded-for', 'forwarded', 'x-real-ip']);
25
+
26
+ function normalizeHeaderName(value) {
27
+ return String(value || '').trim().toLowerCase();
28
+ }
29
+
30
+ function headerNamesFrom(source) {
31
+ if (Array.isArray(source)) return source;
32
+ if (source === undefined || source === null) return [];
33
+ return String(source).split(',');
34
+ }
35
+
36
+ /**
37
+ * The marker headers to look for: `cf-ray` always, plus every name from every
38
+ * source given (an array or a comma-separated string). Sources are merged,
39
+ * none replaces another, so `tunnelMarkerHeaders: []` in the config does not
40
+ * switch off COPILOT_TUNNEL_MARKER_HEADERS.
41
+ */
42
+ export function resolveTunnelMarkerHeaders(...sources) {
43
+ const names = [...DEFAULT_TUNNEL_MARKER_HEADERS];
44
+ for (const source of sources) {
45
+ for (const entry of headerNamesFrom(source)) {
46
+ const name = normalizeHeaderName(entry);
47
+ if (name && !names.includes(name)) names.push(name);
48
+ }
49
+ }
50
+ return names;
51
+ }
52
+
53
+ /** 127.0.0.0/8 and ::1, plain or IPv4-mapped. */
54
+ export function isLoopbackAddress(value) {
55
+ let text = String(value || '').trim().toLowerCase();
56
+ if (!text) return false;
57
+ if (text === '::1' || text === '0:0:0:0:0:0:0:1') return true;
58
+ if (text.startsWith('::ffff:')) text = text.slice(7);
59
+ if (text === '7f00:1') return true;
60
+ return /^127(?:\.\d{1,3}){3}$/.test(text);
61
+ }
62
+
63
+ function headerPresent(headers, name) {
64
+ const value = headers?.[name];
65
+ if (Array.isArray(value)) return value.some((entry) => String(entry || '').trim());
66
+ return typeof value === 'string' ? !!value.trim() : !!value;
67
+ }
68
+
69
+ function peerAddressOf(req) {
70
+ return req?.socket?.remoteAddress || req?.connection?.remoteAddress || '';
71
+ }
72
+
73
+ /**
74
+ * `markerHeaders`: names as resolveTunnelMarkerHeaders returns them (the
75
+ * default `cf-ray` is added if missing).
76
+ */
77
+ export function createLocalRequestCheck({ markerHeaders = DEFAULT_TUNNEL_MARKER_HEADERS } = {}) {
78
+ const markers = resolveTunnelMarkerHeaders(markerHeaders);
79
+
80
+ function matchedMarkerHeader(headers) {
81
+ for (const name of markers) {
82
+ if (headerPresent(headers, name)) return name;
83
+ }
84
+ return null;
85
+ }
86
+
87
+ function matchedForwardingHeader(headers) {
88
+ for (const name of FORWARDING_HEADERS) {
89
+ if (headerPresent(headers, name)) return name;
90
+ }
91
+ return null;
92
+ }
93
+
94
+ /**
95
+ * Why a request is not truly local: `{ reason: 'peer' | 'marker' |
96
+ * 'forwarded', header }`, or null when it is.
97
+ */
98
+ function explain(req) {
99
+ if (!isLoopbackAddress(peerAddressOf(req))) return { reason: 'peer', header: null };
100
+ const marker = matchedMarkerHeader(req?.headers);
101
+ if (marker) return { reason: 'marker', header: marker };
102
+ const forwarded = matchedForwardingHeader(req?.headers);
103
+ if (forwarded) return { reason: 'forwarded', header: forwarded };
104
+ return null;
105
+ }
106
+
107
+ function isTrulyLocal(req) {
108
+ return explain(req) === null;
109
+ }
110
+
111
+ return {
112
+ markerHeaders: markers,
113
+ matchedMarkerHeader,
114
+ explain,
115
+ isTrulyLocal,
116
+ };
117
+ }
118
+
119
+ function trustsPeer(req) {
120
+ const trust = typeof req?.app?.get === 'function' ? req.app.get('trust proxy fn') : null;
121
+ return typeof trust === 'function' && trust(peerAddressOf(req), 0) === true;
122
+ }
123
+
124
+ function firstListEntry(value) {
125
+ return String(Array.isArray(value) ? value[0] : (value || '')).split(',')[0].trim();
126
+ }
127
+
128
+ /**
129
+ * The host (with port) the client addressed: X-Forwarded-Host when the
130
+ * immediate peer is a trusted proxy (Express `trust proxy`), else Host. A
131
+ * direct client cannot forge it. Express's own `req.hostname` drops the port,
132
+ * which a LAN address needs.
133
+ */
134
+ export function requestHostOf(req) {
135
+ if (trustsPeer(req)) {
136
+ const forwardedHost = firstListEntry(req?.headers?.['x-forwarded-host']);
137
+ if (forwardedHost) return forwardedHost;
138
+ }
139
+ return firstListEntry(req?.headers?.host);
140
+ }
141
+
142
+ /** An Express-style view of a raw request (a socket.io handshake), so `ip`, `protocol` and the trust checks follow `trust proxy`. */
143
+ export function expressRequestView(app, rawReq) {
144
+ const view = Object.create(app.request);
145
+ view.headers = rawReq?.headers || {};
146
+ view.socket = rawReq?.socket || rawReq?.connection || null;
147
+ view.connection = view.socket;
148
+ view.method = rawReq?.method;
149
+ view.url = rawReq?.url;
150
+ return view;
151
+ }
152
+
153
+ function normalizedHost(protocol, host) {
154
+ try {
155
+ return new URL(`${protocol}//${host}`).host.toLowerCase();
156
+ } catch {
157
+ return '';
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Same-origin check for a browser handshake. No Origin header (a non-browser
163
+ * client) passes; otherwise the Origin's host must be the host the request
164
+ * was addressed to (see requestHostOf), or be listed in `allowedOrigins`.
165
+ */
166
+ export function isSameOriginRequest(req, { allowedOrigins = [] } = {}) {
167
+ const origin = firstListEntry(req?.headers?.origin);
168
+ if (!origin) return true;
169
+ let parsed;
170
+ try {
171
+ parsed = new URL(origin);
172
+ } catch {
173
+ return false;
174
+ }
175
+ if (parsed.origin === 'null') return false;
176
+ const allowed = (Array.isArray(allowedOrigins) ? allowedOrigins : headerNamesFrom(allowedOrigins))
177
+ .map((entry) => String(entry || '').trim().replace(/\/+$/, '').toLowerCase())
178
+ .filter(Boolean);
179
+ if (allowed.includes(parsed.origin.toLowerCase())) return true;
180
+ const host = requestHostOf(req);
181
+ if (!host) return false;
182
+ return normalizedHost(parsed.protocol, host) === parsed.host.toLowerCase();
183
+ }