@oar-sh/oar 0.9.10 → 0.9.12
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 +440 -1
- package/README.md +156 -972
- package/package.json +13 -13
- package/server/README.md +112 -25
- package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
- package/server/claude-worker/claude-session-process.mjs +217 -11
- package/server/claude-worker/claude-session-worker.mjs +26 -1
- package/server/claude-worker/task-output-discovery.mjs +126 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +235 -14
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -0
- package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
- package/server/copilot-worker/copilot-task-settle.mjs +96 -0
- package/server/db-schema.mjs +68 -2
- package/server/grok-worker/acp-client.mjs +2 -1
- package/server/grok-worker/acp-host-services.mjs +46 -6
- package/server/grok-worker/grok-background-tasks.mjs +241 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
- package/server/grok-worker/grok-session-worker.mjs +33 -1
- package/server/grok-worker/grok-turn-runner.mjs +11 -0
- package/server/migrations/0013-previews.mjs +130 -0
- package/server/migrations/0014-conversation-groups.mjs +89 -0
- package/server/migrations/0015-conversation-group-position.mjs +76 -0
- package/server/migrations/0016-share-access-log.mjs +110 -0
- package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
- package/server/public/app/about-ui.js +73 -0
- package/server/public/app/about-view.mjs +234 -0
- package/server/public/app/action-confirmations.js +31 -14
- package/server/public/app/agent-exchange-fold-view.mjs +40 -101
- package/server/public/app/api-client.js +264 -16
- package/server/public/app/ask-user-view.js +17 -15
- package/server/public/app/background-tasks-view.mjs +285 -16
- package/server/public/app/bootstrap.js +253 -37
- package/server/public/app/chat-actions-menu-state.mjs +20 -10
- package/server/public/app/claude-background-command-limit-options.mjs +21 -0
- package/server/public/app/context-menu.mjs +90 -6
- package/server/public/app/conversation-group-drag.mjs +191 -0
- package/server/public/app/conversation-group-order.mjs +265 -0
- package/server/public/app/conversation-group-state.mjs +100 -0
- package/server/public/app/conversation-list-drag.mjs +449 -0
- package/server/public/app/conversation-payload-cache.mjs +357 -0
- package/server/public/app/conversation-turn-state.mjs +81 -0
- package/server/public/app/conversation-view.js +567 -94
- package/server/public/app/conversation-window-retention.mjs +96 -0
- package/server/public/app/cwd-picker.js +1 -1
- package/server/public/app/hidden-messages-view.mjs +312 -0
- package/server/public/app/host-suspend-ui.js +7 -1
- package/server/public/app/journal-view.js +1033 -82
- package/server/public/app/preview-cards.mjs +117 -31
- package/server/public/app/preview-command.mjs +10 -1
- package/server/public/app/relay-board-view.js +19 -6
- package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
- package/server/public/app/relay-restart-error.mjs +16 -0
- package/server/public/app/settings-modal.js +150 -10
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/share-format.mjs +49 -0
- package/server/public/app/share-preview-marks.mjs +167 -0
- package/server/public/app/share-state.js +109 -0
- package/server/public/app/shared-conversations-settings.js +480 -0
- package/server/public/app/socket-handlers.js +138 -20
- package/server/public/app/store.js +121 -30
- package/server/public/app/subagent-row.mjs +3 -3
- package/server/public/app/subagent-viewer.mjs +37 -7
- package/server/public/app/task-console-render.mjs +237 -0
- package/server/public/app/task-console-watch.mjs +42 -0
- package/server/public/app/task-console.mjs +630 -0
- package/server/public/app/task-output-text.mjs +234 -0
- package/server/public/app/thinking-follow.js +0 -4
- package/server/public/app/transcript-island.mjs +73 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +230 -26
- package/server/public/app/transcript-jump-bottom.mjs +170 -22
- package/server/public/app/transcript-reading-anchor.mjs +256 -0
- package/server/public/app/transcript-reload-freshness.mjs +17 -1
- package/server/public/app/transcript-viewport-anchor.mjs +159 -0
- package/server/public/index.html +625 -21
- package/server/relay-tools.md +3 -1
- package/server/relay.mjs +2 -1
- package/server/repositories/conversation-group-repository.mjs +444 -0
- package/server/repositories/message-repository.mjs +9 -0
- package/server/repositories/question-repository.mjs +4 -0
- package/server/repositories/session-repository.mjs +12 -37
- package/server/repositories/share-repository.mjs +322 -0
- package/server/repositories/share-visibility-statements.mjs +10 -0
- package/server/routes/about-routes.mjs +15 -0
- package/server/routes/cache-routes.mjs +4 -0
- package/server/routes/conversation-group-routes.mjs +171 -0
- package/server/routes/messages-routes.mjs +433 -68
- package/server/routes/preview-routes.mjs +9 -1
- package/server/routes/sessions-routes.mjs +901 -253
- package/server/server-runtime.mjs +392 -99
- package/server/services/about-service.mjs +461 -0
- package/server/services/background-task-store.mjs +365 -0
- package/server/services/background-work-view.mjs +228 -0
- package/server/services/cache-rebuild-service.mjs +30 -0
- package/server/services/cli-process-runner.mjs +4 -21
- package/server/services/conversation-history-page.mjs +199 -0
- package/server/services/delete-archive-service.mjs +4 -1
- package/server/services/detached-worker-watch-service.mjs +150 -0
- package/server/services/hidden-message-list.mjs +21 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/message-pin-list.mjs +1 -1
- package/server/services/preview-health-probe.mjs +8 -1
- package/server/services/preview-proxy-server.mjs +8 -4
- package/server/services/preview-registry-service.mjs +676 -26
- package/server/services/preview-static-handler.mjs +40 -7
- package/server/services/preview-store.mjs +274 -0
- package/server/services/rate-limit-service.mjs +12 -1
- package/server/services/relay-auth.mjs +183 -0
- package/server/services/relay-cli-launcher-service.mjs +3 -1
- package/server/services/relay-event-service.mjs +17 -0
- package/server/services/relay-prompt-sanitizer.mjs +11 -4
- package/server/services/remote-relay-dispatcher.mjs +187 -7
- package/server/services/request-boundary.mjs +183 -0
- package/server/services/session-activity-tracker.mjs +231 -19
- package/server/services/session-worker-launch-service.mjs +190 -6
- package/server/services/session-worker-process-service.mjs +10 -6
- package/server/services/share-access-log-service.mjs +123 -0
- package/server/services/share-path-redaction.mjs +424 -0
- package/server/services/status-event-service.mjs +7 -3
- package/server/services/task-output-rooms.mjs +259 -0
- package/server/services/task-output-service.mjs +562 -0
- package/server/services/tunnel-worker-path-guard.mjs +24 -41
- package/server/services/windows-autostart-service.mjs +6 -1
- package/server/services/windows-boot-autostart-service.mjs +207 -23
- package/shared/claude-background-command-limit.mjs +83 -0
- package/shared/preview-tool-core.mjs +15 -5
- package/shared/remote-relay-contract.mjs +35 -3
- package/shared/share-path-privacy.mjs +30 -0
- package/shared/task-output-text.mjs +737 -0
- package/shared/windows-system-tools.mjs +106 -0
- package/shared/worker-runtime/background-task-digest.mjs +30 -0
- package/shared/worker-runtime/heartbeat.mjs +24 -0
- package/shared/worker-runtime/kept-posts.mjs +83 -0
- package/shared/worker-runtime/task-output-spool.mjs +181 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
import { schemaFields } from '../../shared/question-schema.mjs';
|
|
28
28
|
import { ANSWERED_ELSEWHERE_KIND } from '../../shared/steer-settle-markers.mjs';
|
|
29
29
|
import { RELAY_EVENT_DEFAULT_TYPES } from '../../shared/relay-events.mjs';
|
|
30
|
+
import { cleanTaskOutput, parseRunnerSummary, stallMinutes, stripSgr } from '../../shared/task-output-text.mjs';
|
|
30
31
|
import {
|
|
31
32
|
RELAY_EVENT_REMOTE_PULL_PATH,
|
|
32
33
|
RELAY_EVENT_REMOTE_SUBSCRIBE_PATH,
|
|
@@ -1238,7 +1239,21 @@ export function createRemoteRelayDispatcher({
|
|
|
1238
1239
|
}
|
|
1239
1240
|
}
|
|
1240
1241
|
|
|
1241
|
-
|
|
1242
|
+
/** The session group of a listed row: id, display name and the row's role. */
|
|
1243
|
+
function sessionGroupField(row, groups) {
|
|
1244
|
+
const groupId = toText(row?.groupId);
|
|
1245
|
+
if (!groupId) return {};
|
|
1246
|
+
const info = isPlainObject(groups?.[groupId]) ? groups[groupId] : null;
|
|
1247
|
+
return {
|
|
1248
|
+
group: {
|
|
1249
|
+
id: groupId,
|
|
1250
|
+
name: toText(info?.displayName) || toText(info?.name) || null,
|
|
1251
|
+
role: toText(row?.groupRole) === 'head' ? 'head' : 'member',
|
|
1252
|
+
},
|
|
1253
|
+
};
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
function mapSession(row, workerIds, ctx = null, groups = null) {
|
|
1242
1257
|
const id = toText(row?.id);
|
|
1243
1258
|
const via = toText(row?.origin?.relayName);
|
|
1244
1259
|
// A session an agent of that relay created there, and from which session.
|
|
@@ -1256,6 +1271,9 @@ export function createRemoteRelayDispatcher({
|
|
|
1256
1271
|
...(via ? { via } : {}),
|
|
1257
1272
|
...(createdBy ? { createdBy } : {}),
|
|
1258
1273
|
...(createdBy && ctx?.relay?.local && createdBy === ctx.conversationId ? { startedByYou: true } : {}),
|
|
1274
|
+
// The session group it sits in (sessions an agent started here are
|
|
1275
|
+
// grouped under that agent's session; the owner can group by hand).
|
|
1276
|
+
...sessionGroupField(row, groups),
|
|
1259
1277
|
};
|
|
1260
1278
|
}
|
|
1261
1279
|
|
|
@@ -1264,7 +1282,37 @@ export function createRemoteRelayDispatcher({
|
|
|
1264
1282
|
* `lastActivityAt` of a session, from the relay's `sessionState` /
|
|
1265
1283
|
* `sessionTasks`. A relay that predates them gives none.
|
|
1266
1284
|
*/
|
|
1267
|
-
|
|
1285
|
+
/**
|
|
1286
|
+
* The console facts read_session adds to a task (`row` is the conversation
|
|
1287
|
+
* payload): `taskId` for read_task_output, `output` whether there is any to
|
|
1288
|
+
* read, and `health` (minutes since the last output, the quiet threshold,
|
|
1289
|
+
* test-run counts) for a task with an output file.
|
|
1290
|
+
*/
|
|
1291
|
+
function taskConsoleFields(row, id) {
|
|
1292
|
+
const taskId = toText(id);
|
|
1293
|
+
if (!taskId || !isPlainObject(row)) return {};
|
|
1294
|
+
const live = (Array.isArray(row.backgroundTasks) ? row.backgroundTasks : []).find((task) => toText(task?.taskId) === taskId);
|
|
1295
|
+
const ended = live ? null : (Array.isArray(row.endedBackgroundTasks) ? row.endedBackgroundTasks : []).find((task) => toText(task?.taskId) === taskId);
|
|
1296
|
+
const task = live || ended;
|
|
1297
|
+
if (!task) return {};
|
|
1298
|
+
const kind = toText(task.outputKind) || (toText(task.outputPath) ? 'file' : 'none');
|
|
1299
|
+
const health = isPlainObject(task.health) ? task.health : null;
|
|
1300
|
+
const quietAfter = Number(row.taskStallMinutes) || null;
|
|
1301
|
+
const minutes = live && health ? stallMinutes(health.lastOutputAt, now()) : null;
|
|
1302
|
+
return {
|
|
1303
|
+
taskId,
|
|
1304
|
+
output: kind !== 'none',
|
|
1305
|
+
...(health ? {
|
|
1306
|
+
health: {
|
|
1307
|
+
...(minutes !== null ? { minutesSinceOutput: minutes } : {}),
|
|
1308
|
+
...(quietAfter && minutes !== null ? { quiet: minutes >= quietAfter, quietAfterMinutes: quietAfter } : {}),
|
|
1309
|
+
...(isPlainObject(health.counts) ? { counts: { runner: toText(health.runner) || null, ...health.counts } } : {}),
|
|
1310
|
+
},
|
|
1311
|
+
} : {}),
|
|
1312
|
+
};
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
function sessionActivityFields(row, { console: withConsole = false } = {}) {
|
|
1268
1316
|
const state = toText(row?.sessionState);
|
|
1269
1317
|
if (!SESSION_STATES.has(state)) return {};
|
|
1270
1318
|
const tasks = (Array.isArray(row?.sessionTasks) ? row.sessionTasks : [])
|
|
@@ -1281,6 +1329,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1281
1329
|
...(toText(task.summary) && toText(task.status) !== 'running' ? { summary: clipLine(task.summary, 200) } : {}),
|
|
1282
1330
|
...(task.endedAt ? { endedAt: task.endedAt } : {}),
|
|
1283
1331
|
...(toText(task.id) ? { id: toText(task.id) } : {}),
|
|
1332
|
+
...(withConsole ? taskConsoleFields(row, task.id) : {}),
|
|
1284
1333
|
}));
|
|
1285
1334
|
return {
|
|
1286
1335
|
state,
|
|
@@ -1296,7 +1345,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1296
1345
|
// pages, all = the whole list page by page (only it honours the cursor).
|
|
1297
1346
|
let cursor = null;
|
|
1298
1347
|
if (scope === 'all' && args.cursor) {
|
|
1299
|
-
cursor = decodeCursor(args.cursor, ['beforeConversationId', 'beforeUpdatedAt']);
|
|
1348
|
+
cursor = decodeCursor(args.cursor, ['beforeConversationId', 'beforeUpdatedAt', 'beforeGroupId']);
|
|
1300
1349
|
if (!cursor) throw invalidInput('cursor is not a nextCursor from list_sessions');
|
|
1301
1350
|
}
|
|
1302
1351
|
const workerIds = scope === 'active' ? await liveWorkerConversationIds(ctx) : null;
|
|
@@ -1305,6 +1354,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1305
1354
|
&& (scope !== 'active' || row?.activeTurn === true || workerIds.has(toText(row?.id)) || isBusyState(row?.sessionState));
|
|
1306
1355
|
|
|
1307
1356
|
const sessions = [];
|
|
1357
|
+
const seen = new Set();
|
|
1308
1358
|
let hasMore = false;
|
|
1309
1359
|
let scanned = 0;
|
|
1310
1360
|
for (let page = 0; page < (filtering ? LIST_SCAN_PAGES : 1); page += 1) {
|
|
@@ -1313,18 +1363,32 @@ export function createRemoteRelayDispatcher({
|
|
|
1313
1363
|
limit: filtering ? LIST_PAGE_MAX : limit,
|
|
1314
1364
|
beforeConversationId: cursor?.beforeConversationId,
|
|
1315
1365
|
beforeUpdatedAt: cursor?.beforeUpdatedAt,
|
|
1366
|
+
beforeGroupId: cursor?.beforeGroupId,
|
|
1316
1367
|
},
|
|
1317
1368
|
});
|
|
1318
1369
|
const rows = Array.isArray(response?.conversations) ? response.conversations : [];
|
|
1370
|
+
const groups = isPlainObject(response?.groups) ? response.groups : null;
|
|
1319
1371
|
let consumed = 0;
|
|
1320
1372
|
for (const row of rows) {
|
|
1321
1373
|
consumed += 1;
|
|
1322
|
-
|
|
1374
|
+
// A group served again whole (its cursor row vanished) repeats rows.
|
|
1375
|
+
if (seen.has(toText(row?.id))) continue;
|
|
1376
|
+
seen.add(toText(row?.id));
|
|
1377
|
+
if (keep(row)) sessions.push(mapSession(row, workerIds, ctx, groups));
|
|
1323
1378
|
if (sessions.length >= limit) break;
|
|
1324
1379
|
}
|
|
1325
1380
|
scanned += consumed;
|
|
1326
1381
|
const last = rows[consumed - 1];
|
|
1327
|
-
|
|
1382
|
+
// The row's sort time (its group's newest row, `activityAt`) and its
|
|
1383
|
+
// group, so the next page continues from the right place even if the
|
|
1384
|
+
// row is archived or deleted before then.
|
|
1385
|
+
if (last) {
|
|
1386
|
+
cursor = {
|
|
1387
|
+
beforeConversationId: toText(last.id),
|
|
1388
|
+
beforeUpdatedAt: toText(last.activityAt) || toText(last.updatedAt),
|
|
1389
|
+
...(toText(last.groupId) ? { beforeGroupId: toText(last.groupId) } : {}),
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1328
1392
|
hasMore = consumed < rows.length || response?.pageInfo?.hasMore === true;
|
|
1329
1393
|
if (sessions.length >= limit || !hasMore || !rows.length) break;
|
|
1330
1394
|
}
|
|
@@ -1405,7 +1469,9 @@ export function createRemoteRelayDispatcher({
|
|
|
1405
1469
|
if (!before) throw invalidInput('before is not an olderCursor from read_session');
|
|
1406
1470
|
}
|
|
1407
1471
|
const conversation = await call(ctx, 'GET', sessionPath(args.session), {
|
|
1408
|
-
|
|
1472
|
+
// groupMembers: the session group's live members; the relay gives them
|
|
1473
|
+
// to its own sessions only.
|
|
1474
|
+
query: { limit: args.last, beforeMessageId: before?.beforeMessageId, beforeTimestamp: before?.beforeTimestamp, groupMembers: 1 },
|
|
1409
1475
|
});
|
|
1410
1476
|
const conversationId = toText(conversation?.id) || args.session;
|
|
1411
1477
|
const pendingQuestions = await loadPendingQuestions(ctx, conversationId);
|
|
@@ -1425,6 +1491,23 @@ export function createRemoteRelayDispatcher({
|
|
|
1425
1491
|
const runtime = isPlainObject(conversation?.runtimeSession) ? conversation.runtimeSession : {};
|
|
1426
1492
|
const via = toText(conversation?.origin?.relayName);
|
|
1427
1493
|
const inFlight = progressSnapshot(conversation?.inFlight);
|
|
1494
|
+
// Its session group, with the other members and what each is doing.
|
|
1495
|
+
const groupInfo = isPlainObject(conversation?.group) ? conversation.group : null;
|
|
1496
|
+
const group = groupInfo && toText(groupInfo.id) ? {
|
|
1497
|
+
id: toText(groupInfo.id),
|
|
1498
|
+
name: toText(groupInfo.displayName) || toText(groupInfo.name) || null,
|
|
1499
|
+
role: toText(groupInfo.role) === 'head' ? 'head' : 'member',
|
|
1500
|
+
members: (Array.isArray(groupInfo.members) ? groupInfo.members : [])
|
|
1501
|
+
.filter(isPlainObject)
|
|
1502
|
+
.slice(0, 50)
|
|
1503
|
+
.map((member) => ({
|
|
1504
|
+
id: toText(member.id),
|
|
1505
|
+
title: toText(member.title),
|
|
1506
|
+
role: toText(member.role) === 'head' ? 'head' : 'member',
|
|
1507
|
+
state: SESSION_STATES.has(toText(member.state)) ? toText(member.state) : 'idle',
|
|
1508
|
+
...(member.archived === true ? { archived: true } : {}),
|
|
1509
|
+
})),
|
|
1510
|
+
} : null;
|
|
1428
1511
|
return {
|
|
1429
1512
|
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
1513
|
session: {
|
|
@@ -1436,8 +1519,9 @@ export function createRemoteRelayDispatcher({
|
|
|
1436
1519
|
cwd: toText(conversation?.currentWorkspaceRootPath) || null,
|
|
1437
1520
|
archived: conversation?.archived === true,
|
|
1438
1521
|
active: conversation?.activeTurn === true || isBusyState(conversation?.sessionState),
|
|
1439
|
-
...sessionActivityFields(conversation),
|
|
1522
|
+
...sessionActivityFields(conversation, { console: true }),
|
|
1440
1523
|
...(via ? { via } : {}),
|
|
1524
|
+
...(group ? { group } : {}),
|
|
1441
1525
|
openUrl: remoteConversationUrl(ctx.relay.url, conversationId) || null,
|
|
1442
1526
|
},
|
|
1443
1527
|
messages,
|
|
@@ -1448,6 +1532,101 @@ export function createRemoteRelayDispatcher({
|
|
|
1448
1532
|
};
|
|
1449
1533
|
}
|
|
1450
1534
|
|
|
1535
|
+
/**
|
|
1536
|
+
* read_task_output: the end of a background shell or Monitor task's
|
|
1537
|
+
* output (GET …/background-task/:taskId/output on the target relay, which
|
|
1538
|
+
* scrubs it), as plain lines without colours, at most `lines` lines and
|
|
1539
|
+
* 32 KB; minutes since its last output; test-run counts when it is one.
|
|
1540
|
+
*/
|
|
1541
|
+
async function readTaskOutput(ctx, args) {
|
|
1542
|
+
let answer;
|
|
1543
|
+
try {
|
|
1544
|
+
answer = await call(ctx, 'GET', sessionPath(args.session, `/background-task/${encodeURIComponent(args.task_id)}/output`), {
|
|
1545
|
+
query: { lines: args.lines },
|
|
1546
|
+
});
|
|
1547
|
+
} catch (error) {
|
|
1548
|
+
const status = Number(error?.status) || null;
|
|
1549
|
+
if (isRemoteRelayError(error) && (status === 404 || toText(error.code) === REMOTE_RELAY_ERROR_CODES.notFound)) {
|
|
1550
|
+
const body = isPlainObject(error.remoteBody) ? error.remoteBody : {};
|
|
1551
|
+
if (toText(body.code) === 'task-not-found') {
|
|
1552
|
+
throw new DispatchFailure(404, REMOTE_RELAY_ERROR_CODES.notFound,
|
|
1553
|
+
`Task ${args.task_id} is not running in session ${args.session} on relay "${ctx.relay.name}" and did not end there within the last hour; read_session lists the session's tasks.`);
|
|
1554
|
+
}
|
|
1555
|
+
if (!/conversation not found/i.test(toText(body.error))) {
|
|
1556
|
+
throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.unsupported,
|
|
1557
|
+
`Relay "${ctx.relay.name}" cannot show background task output (it runs an older OAR version).`);
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
throw error;
|
|
1561
|
+
}
|
|
1562
|
+
const model = Array.isArray(answer?.lines) ? answer.lines : [];
|
|
1563
|
+
const task = isPlainObject(answer?.task) ? answer.task : {};
|
|
1564
|
+
const ended = answer?.ended === true;
|
|
1565
|
+
// Plain text, a repeat folded as ×N; cleaned again, whatever the other
|
|
1566
|
+
// side did.
|
|
1567
|
+
const rendered = model.map((line) => {
|
|
1568
|
+
const text = stripSgr(cleanTaskOutput(String(line?.text ?? '')));
|
|
1569
|
+
const count = Number(line?.count) > 1 ? Number(line.count) : 1;
|
|
1570
|
+
return count > 1 ? `${text} [×${count}]` : text;
|
|
1571
|
+
});
|
|
1572
|
+
let tail = rendered.slice(-args.lines);
|
|
1573
|
+
let cut = tail.length < rendered.length;
|
|
1574
|
+
let bytes = 0;
|
|
1575
|
+
let keepFrom = tail.length;
|
|
1576
|
+
for (let index = tail.length - 1; index >= 0; index -= 1) {
|
|
1577
|
+
bytes += Buffer.byteLength(tail[index], 'utf8') + 1;
|
|
1578
|
+
if (bytes > REMOTE_RELAY_LIMITS.taskOutputBytesMax) break;
|
|
1579
|
+
keepFrom = index;
|
|
1580
|
+
}
|
|
1581
|
+
if (keepFrom > 0) {
|
|
1582
|
+
tail = tail.slice(keepFrom);
|
|
1583
|
+
cut = true;
|
|
1584
|
+
}
|
|
1585
|
+
const health = isPlainObject(answer?.health) ? answer.health : null;
|
|
1586
|
+
const lastOutputAt = Number(health?.lastOutputAt) || Number(answer?.mtime) || null;
|
|
1587
|
+
const minutes = !ended && lastOutputAt ? stallMinutes(lastOutputAt, now()) : null;
|
|
1588
|
+
const quietAfter = Number(answer?.taskStallMinutes) || null;
|
|
1589
|
+
let counts = null;
|
|
1590
|
+
if (isPlainObject(health?.counts)) {
|
|
1591
|
+
counts = { runner: toText(health.runner) || null, ...health.counts, final: health.final === true };
|
|
1592
|
+
} else {
|
|
1593
|
+
const parsed = parseRunnerSummary(model);
|
|
1594
|
+
if (parsed) counts = { runner: parsed.runner, passed: parsed.passed, failed: parsed.failed, skipped: parsed.skipped, final: parsed.final };
|
|
1595
|
+
}
|
|
1596
|
+
const kind = toText(answer?.kind) || 'file';
|
|
1597
|
+
let note = null;
|
|
1598
|
+
if (kind === 'none') note = toText(answer?.message) || 'Output is not available for this provider.';
|
|
1599
|
+
else if (answer?.pending) note = 'The session\'s worker was asked for the output; call again in a few seconds.';
|
|
1600
|
+
else if (answer?.unavailable) note = 'The output file is not there any more (the provider cleaned it up).';
|
|
1601
|
+
else if (kind === 'snapshot') note = 'Only the end of this task\'s output is available (its provider keeps no file).';
|
|
1602
|
+
const status = ended ? (toText(answer?.status) || 'unknown') : 'running';
|
|
1603
|
+
const exitCode = Number.isInteger(answer?.exitCode) ? answer.exitCode : null;
|
|
1604
|
+
const parts = [`${tail.length} line${tail.length === 1 ? '' : 's'}`, ended ? `ended (${status}${exitCode !== null ? `, exit ${exitCode}` : ''})` : 'running'];
|
|
1605
|
+
if (minutes !== null) parts.push(`last output ${minutes} min ago`);
|
|
1606
|
+
if (counts) parts.push(`${counts.passed} passed, ${counts.failed} failed`);
|
|
1607
|
+
return {
|
|
1608
|
+
summary: `read_task_output → ${ctx.relay.name} session ${shortId(args.session)} task ${shortId(args.task_id)}: ${parts.join(', ')}`,
|
|
1609
|
+
task: {
|
|
1610
|
+
id: args.task_id,
|
|
1611
|
+
description: toText(task.description),
|
|
1612
|
+
type: toText(task.taskType) || null,
|
|
1613
|
+
startedAt: task.startedAt || null,
|
|
1614
|
+
status,
|
|
1615
|
+
exitCode,
|
|
1616
|
+
...(task.endedAt ? { endedAt: task.endedAt } : {}),
|
|
1617
|
+
},
|
|
1618
|
+
stall: minutes === null ? null : {
|
|
1619
|
+
minutes,
|
|
1620
|
+
since: new Date(lastOutputAt).toISOString(),
|
|
1621
|
+
...(quietAfter ? { quiet: minutes >= quietAfter, quietAfterMinutes: quietAfter } : {}),
|
|
1622
|
+
},
|
|
1623
|
+
counts,
|
|
1624
|
+
tail,
|
|
1625
|
+
truncated: cut || answer?.hasMoreBefore === true,
|
|
1626
|
+
...(note ? { note } : {}),
|
|
1627
|
+
};
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1451
1630
|
function outcomeSummary(action, ctx, outcome) {
|
|
1452
1631
|
const questions = Array.isArray(outcome.pendingQuestions) ? outcome.pendingQuestions.length : 0;
|
|
1453
1632
|
return `${action} → ${ctx.relay.name} session ${shortId(outcome.session)}: ${outcome.status}`
|
|
@@ -2220,6 +2399,7 @@ export function createRemoteRelayDispatcher({
|
|
|
2220
2399
|
usage: usageAction,
|
|
2221
2400
|
list_sessions: listSessions,
|
|
2222
2401
|
read_session: readSession,
|
|
2402
|
+
read_task_output: readTaskOutput,
|
|
2223
2403
|
wait: waitAction,
|
|
2224
2404
|
send,
|
|
2225
2405
|
create_session: createSession,
|
|
@@ -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
|
+
}
|