@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.
- package/CHANGELOG.md +314 -1
- package/README.md +156 -972
- package/package.json +13 -13
- package/server/README.md +95 -22
- package/server/claude-worker/claude-session-process.mjs +89 -11
- package/server/claude-worker/claude-session-worker.mjs +9 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +76 -11
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
- package/server/db-schema.mjs +59 -2
- package/server/grok-worker/acp-client.mjs +2 -1
- package/server/grok-worker/acp-host-services.mjs +4 -3
- 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/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 +251 -16
- package/server/public/app/ask-user-view.js +17 -15
- package/server/public/app/bootstrap.js +158 -32
- package/server/public/app/chat-actions-menu-state.mjs +20 -10
- 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-turn-state.mjs +81 -0
- package/server/public/app/conversation-view.js +136 -93
- 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 +787 -22
- 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-restart-error.mjs +16 -0
- package/server/public/app/settings-modal.js +40 -8
- 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 +80 -14
- package/server/public/app/store.js +96 -30
- package/server/public/app/subagent-row.mjs +3 -3
- package/server/public/app/subagent-viewer.mjs +30 -6
- package/server/public/app/thinking-follow.js +0 -4
- package/server/public/app/transcript-jump-bottom-view.mjs +194 -20
- package/server/public/app/transcript-jump-bottom.mjs +132 -21
- package/server/public/app/transcript-viewport-anchor.mjs +140 -0
- package/server/public/index.html +386 -15
- package/server/relay-tools.md +2 -1
- package/server/relay.mjs +2 -1
- package/server/repositories/conversation-group-repository.mjs +444 -0
- package/server/repositories/message-repository.mjs +1 -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 +184 -65
- package/server/routes/preview-routes.mjs +9 -1
- package/server/routes/sessions-routes.mjs +771 -193
- package/server/server-runtime.mjs +316 -100
- package/server/services/about-service.mjs +461 -0
- package/server/services/background-task-store.mjs +128 -0
- package/server/services/background-work-view.mjs +228 -0
- package/server/services/cache-rebuild-service.mjs +30 -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/remote-relay-dispatcher.mjs +57 -5
- package/server/services/request-boundary.mjs +183 -0
- package/server/services/session-activity-tracker.mjs +227 -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/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/preview-tool-core.mjs +15 -5
- package/shared/remote-relay-contract.mjs +3 -1
- package/shared/share-path-privacy.mjs +30 -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/worker-websocket-link.mjs +47 -15
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
normalizeWorkspaceRootKey,
|
|
25
25
|
} from './services/workspace-root-path-policy.mjs';
|
|
26
26
|
import { stopSessionWorkerProcesses } from './services/session-worker-stop-service.mjs';
|
|
27
|
+
import { createDetachedWorkerWatch } from './services/detached-worker-watch-service.mjs';
|
|
27
28
|
import { applyCdnNoStoreHeaders } from './services/file-serving.mjs';
|
|
28
29
|
import { pickLiveTurnRowId } from './services/live-turn-picker.mjs';
|
|
29
30
|
import { buildSteerSettleFailure, steerAgentLabelForProvider } from '../shared/steer-settle-failure.mjs';
|
|
@@ -45,6 +46,9 @@ import { registerPreviewRoutes } from './routes/preview-routes.mjs';
|
|
|
45
46
|
import { registerRemoteRelayRoutes } from './routes/remote-relay-routes.mjs';
|
|
46
47
|
import { registerRelayEventRoutes } from './routes/relay-event-routes.mjs';
|
|
47
48
|
import { createRemoteRelayRepository } from './repositories/remote-relay-repository.mjs';
|
|
49
|
+
import { createConversationGroupRepository } from './repositories/conversation-group-repository.mjs';
|
|
50
|
+
import { createShareRepository } from './repositories/share-repository.mjs';
|
|
51
|
+
import { registerConversationGroupRoutes } from './routes/conversation-group-routes.mjs';
|
|
48
52
|
import { createRemoteRelayClient } from './services/remote-relay-client.mjs';
|
|
49
53
|
import { createRemoteRelayLoopbackClient } from './services/remote-relay-loopback.mjs';
|
|
50
54
|
import { createRemoteRelayRegistry } from './services/remote-relay-registry-service.mjs';
|
|
@@ -69,7 +73,9 @@ import webpush from 'web-push';
|
|
|
69
73
|
import { createPushDispatchService, ensurePushVapidKeys } from './services/push-dispatch-service.mjs';
|
|
70
74
|
import { createHostSuspendService } from './services/host-suspend-service.mjs';
|
|
71
75
|
import { USAGE_LIMIT_ACCOUNT_SOCKET_EVENT, createUsageLimitPauseService } from './services/usage-limit-pause-service.mjs';
|
|
72
|
-
import { createSessionActivityTracker } from './services/session-activity-tracker.mjs';
|
|
76
|
+
import { createSessionActivityTracker, resolveBootReportingWindowMs } from './services/session-activity-tracker.mjs';
|
|
77
|
+
import { createBackgroundTaskStore } from './services/background-task-store.mjs';
|
|
78
|
+
import { createBackgroundWorkView } from './services/background-work-view.mjs';
|
|
73
79
|
import { createRelayEventService } from './services/relay-event-service.mjs';
|
|
74
80
|
import { createRelayEventLinkService } from './services/relay-event-link-service.mjs';
|
|
75
81
|
import { buildClaudeAttributionSettings, normalizeClaudeAttributionMode, resolveClaudeAttributionMode, DEFAULT_CLAUDE_ATTRIBUTION_MODE } from '../shared/claude-attribution.mjs';
|
|
@@ -78,6 +84,8 @@ import { createGitHubCiActivityService } from './services/github-ci-activity-ser
|
|
|
78
84
|
import { runHostSuspendToRam } from './services/host-suspend-command.mjs';
|
|
79
85
|
import { createActiveDeviceTracker } from './services/push-active-device-service.mjs';
|
|
80
86
|
import { registerPushRoutes } from './routes/push-routes.mjs';
|
|
87
|
+
import { registerAboutRoutes } from './routes/about-routes.mjs';
|
|
88
|
+
import { createAboutService, resolveBuildCommit } from './services/about-service.mjs';
|
|
81
89
|
import { createImageOperationService } from './services/image-operation-service.mjs';
|
|
82
90
|
import {
|
|
83
91
|
normalizeDriveAbsolutePath as _normalizeDriveAbsolutePath,
|
|
@@ -119,7 +127,16 @@ import {
|
|
|
119
127
|
} from './services/preview-registry-service.mjs';
|
|
120
128
|
import { createPreviewProxyServer } from './services/preview-proxy-server.mjs';
|
|
121
129
|
import { createPreviewHealthProbe } from './services/preview-health-probe.mjs';
|
|
130
|
+
import { createSqlitePreviewStore, isSameHostBoot, readHostBootId } from './services/preview-store.mjs';
|
|
131
|
+
import { validateStaticRootInAny } from './services/preview-static-handler.mjs';
|
|
122
132
|
import { createTunnelWorkerPathGuard } from './services/tunnel-worker-path-guard.mjs';
|
|
133
|
+
import {
|
|
134
|
+
createLocalRequestCheck,
|
|
135
|
+
expressRequestView,
|
|
136
|
+
isSameOriginRequest,
|
|
137
|
+
resolveTunnelMarkerHeaders,
|
|
138
|
+
} from './services/request-boundary.mjs';
|
|
139
|
+
import { createRelayAuth } from './services/relay-auth.mjs';
|
|
123
140
|
import { guardUpgradeSocket } from './services/upgrade-socket-guard.mjs';
|
|
124
141
|
import { createWindowsAutostartService } from './services/windows-autostart-service.mjs';
|
|
125
142
|
import { createWindowsBootAutostartService } from './services/windows-boot-autostart-service.mjs';
|
|
@@ -154,6 +171,11 @@ import {
|
|
|
154
171
|
readSubagentRecordRetentionSetting,
|
|
155
172
|
} from '../shared/subagent-record-retention.mjs';
|
|
156
173
|
import { createSubagentRecordRetention } from './services/subagent-record-retention.mjs';
|
|
174
|
+
import {
|
|
175
|
+
SHARE_PATH_PRIVACY_SETTING_KEY,
|
|
176
|
+
parseSharePathPrivacyUpdate,
|
|
177
|
+
readSharePathPrivacySetting,
|
|
178
|
+
} from '../shared/share-path-privacy.mjs';
|
|
157
179
|
import { isRelayShutdownQueued, queuedShutdownCanProceed, relayShutdownGateReply } from './services/relay-shutdown-gate.mjs';
|
|
158
180
|
import { createStaleRecoveryClockGuard } from './services/stale-recovery-clock-guard.mjs';
|
|
159
181
|
import {
|
|
@@ -198,6 +220,7 @@ import {
|
|
|
198
220
|
import { createModelVariantCatalogService } from './services/model-variant-catalog-service.mjs';
|
|
199
221
|
import { applyPwaManifestTheme, normalizePwaTheme } from './services/pwa-manifest-theme.mjs';
|
|
200
222
|
import { mapSubagentRunHeader } from './services/subagent-timeline-service.mjs';
|
|
223
|
+
import { describeExecError, describeSpawnEnvironment, windowsSystemToolPath } from '../shared/windows-system-tools.mjs';
|
|
201
224
|
|
|
202
225
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
203
226
|
const REPO_ROOT = path.resolve(__dirname, '..');
|
|
@@ -215,6 +238,10 @@ const RUNNING_VERSION = (() => {
|
|
|
215
238
|
}
|
|
216
239
|
})();
|
|
217
240
|
const INSTALL_METHOD = fs.existsSync(path.join(PACKAGE_ROOT, '.git')) ? 'git-checkout' : 'npm-global';
|
|
241
|
+
// When this relay process started, and the commit its code came from (read
|
|
242
|
+
// once: a checkout moved on later still runs the code it started with).
|
|
243
|
+
const RELAY_STARTED_AT = new Date(Date.now() - process.uptime() * 1000).toISOString();
|
|
244
|
+
const BUILD_COMMIT = resolveBuildCommit(PACKAGE_ROOT);
|
|
218
245
|
const PUBLIC_DIR = path.join(__dirname, 'public');
|
|
219
246
|
const INDEX_HTML_PATH = path.join(PUBLIC_DIR, 'index.html');
|
|
220
247
|
const PWA_MANIFEST_PATH = path.join(PUBLIC_DIR, 'manifest.webmanifest');
|
|
@@ -2038,6 +2065,35 @@ function setSubagentRecordRetentionDays(value) {
|
|
|
2038
2065
|
return { ok: true, retentionDays: parsed.days };
|
|
2039
2066
|
}
|
|
2040
2067
|
|
|
2068
|
+
function getSharePathPrivacyMode() {
|
|
2069
|
+
return readSharePathPrivacySetting(readAppSettingValue(SHARE_PATH_PRIVACY_SETTING_KEY));
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
function setSharePathPrivacyMode(value) {
|
|
2073
|
+
if (typeof stmts?.upsertAppSetting?.run !== 'function') {
|
|
2074
|
+
return { ok: false, error: 'Path privacy settings are unavailable' };
|
|
2075
|
+
}
|
|
2076
|
+
const parsed = parseSharePathPrivacyUpdate(value);
|
|
2077
|
+
if (!parsed.ok) return parsed;
|
|
2078
|
+
stmts.upsertAppSetting.run(SHARE_PATH_PRIVACY_SETTING_KEY, parsed.mode, new Date().toISOString());
|
|
2079
|
+
return { ok: true, mode: parsed.mode };
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
// The relay host's own roots a shared view must not show: home, the relay's
|
|
2083
|
+
// checkout, data and upload dirs, the workspace roots it knows, and the OS
|
|
2084
|
+
// user name. The share routes add the conversation's own roots.
|
|
2085
|
+
function sharePathKnownRoots() {
|
|
2086
|
+
const roots = [REPO_ROOT, DATA_DIR, UPLOAD_DIR, currentWorkspaceRootPath(), INITIAL_WORKSPACE_ROOT];
|
|
2087
|
+
const userNames = [];
|
|
2088
|
+
try { roots.push(os.homedir()); } catch {}
|
|
2089
|
+
try { userNames.push(os.userInfo().username); } catch {}
|
|
2090
|
+
try { roots.push(resolveDefaultSessionWorkspaceRootState()?.path || ''); } catch {}
|
|
2091
|
+
try { roots.push(...listRecentWorkspaceRoots()); } catch {}
|
|
2092
|
+
try { roots.push(resolveSessionStateRoot?.() || ''); } catch {}
|
|
2093
|
+
roots.push(...workspaceRootAllowList);
|
|
2094
|
+
return { roots: roots.filter(Boolean), userNames: userNames.filter(Boolean) };
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2041
2097
|
const PWA_APP_NAME_SETTING_KEY = 'pwa_app_name';
|
|
2042
2098
|
|
|
2043
2099
|
/** '' means "no custom name": the manifest falls back to the template default. */
|
|
@@ -2508,7 +2564,7 @@ function runCopilotCliCommand(args = [], timeoutMs = 30_000) {
|
|
|
2508
2564
|
if (process.platform === 'win32') {
|
|
2509
2565
|
const escapedArgs = commandArgs.map((part) => `'${String(part).replace(/'/g, "''")}'`).join(' ');
|
|
2510
2566
|
const script = escapedArgs ? `& 'copilot' ${escapedArgs}` : "& 'copilot'";
|
|
2511
|
-
execFile('powershell.exe', ['-NoLogo', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script], { windowsHide: true, timeout }, (error, stdout, stderr) => {
|
|
2567
|
+
execFile(windowsSystemToolPath('powershell.exe'), ['-NoLogo', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script], { windowsHide: true, timeout }, (error, stdout, stderr) => {
|
|
2512
2568
|
if (error) {
|
|
2513
2569
|
const detail = String(stderr || stdout || error.message || '').trim();
|
|
2514
2570
|
reject(new Error(detail || error.message || 'copilot command failed'));
|
|
@@ -2579,6 +2635,21 @@ const stmts = {
|
|
|
2579
2635
|
...createImageConversationRepository(db),
|
|
2580
2636
|
};
|
|
2581
2637
|
const statusEventService = createStatusEventService(db);
|
|
2638
|
+
// Public shares and their access log (migration 0016).
|
|
2639
|
+
const shareRepository = createShareRepository(db);
|
|
2640
|
+
// Session groups in the sidebar (migration 0014). Without the migration it
|
|
2641
|
+
// sees no groups and writes nothing. What a delete that did not know groups
|
|
2642
|
+
// left behind (a head whose row is gone, a group without a member) is
|
|
2643
|
+
// repaired once at boot.
|
|
2644
|
+
const conversationGroups = createConversationGroupRepository(db);
|
|
2645
|
+
try {
|
|
2646
|
+
const repaired = conversationGroups.repair();
|
|
2647
|
+
if (repaired.headsDetached > 0 || repaired.groupsRemoved > 0) {
|
|
2648
|
+
console.log(`[session-groups] repaired: ${repaired.headsDetached} headless group(s), ${repaired.groupsRemoved} empty group(s) removed`);
|
|
2649
|
+
}
|
|
2650
|
+
} catch (error) {
|
|
2651
|
+
console.warn(`[session-groups] repair failed: ${error?.message || error}`);
|
|
2652
|
+
}
|
|
2582
2653
|
// Relay events (built further down, next to the usage-limit pause): declared
|
|
2583
2654
|
// here so recovery code that runs before then finds null, not a TDZ error.
|
|
2584
2655
|
let sessionActivityTracker = null;
|
|
@@ -2727,7 +2798,7 @@ const imageOperationService = createImageOperationService({
|
|
|
2727
2798
|
|
|
2728
2799
|
modelVariantCatalog.bindDatabase(db);
|
|
2729
2800
|
|
|
2730
|
-
const deleteArchiveService = createDeleteArchiveService(db, null, { resolveSessionStateRoot });
|
|
2801
|
+
const deleteArchiveService = createDeleteArchiveService(db, null, { resolveSessionStateRoot, conversationGroups });
|
|
2731
2802
|
void deleteArchiveService.retryPendingDeletesOnStartup()
|
|
2732
2803
|
.then((result) => {
|
|
2733
2804
|
if (result?.pendingCount > 0) {
|
|
@@ -2955,8 +3026,13 @@ async function spawnSessionWorkerCli(targetSessionId, { allowProcessReuse = true
|
|
|
2955
3026
|
}
|
|
2956
3027
|
// An unreadable process list must not stop every launch on the relay
|
|
2957
3028
|
// (2026-10-01: a control byte in one command line did, for an hour).
|
|
3029
|
+
// The environment goes with the first report of a launch only; the pid poll
|
|
3030
|
+
// reads the list up to ten more times.
|
|
3031
|
+
let processListEnvironmentLogged = false;
|
|
2958
3032
|
const warnProcessListUnreadable = (error) => {
|
|
2959
|
-
|
|
3033
|
+
const environment = processListEnvironmentLogged ? '' : `; ${describeSpawnEnvironment(process.env)}`;
|
|
3034
|
+
processListEnvironmentLogged = true;
|
|
3035
|
+
console.warn(`${runtimeLogPrefix()}worker launcher: process list unreadable, launching session=${normalizedTargetSessionId.slice(0, 8)} without the reuse check: ${describeExecError(error)}${environment}`);
|
|
2960
3036
|
};
|
|
2961
3037
|
if (allowProcessReuse) {
|
|
2962
3038
|
let liveWorker = null;
|
|
@@ -2983,6 +3059,7 @@ async function spawnSessionWorkerCli(targetSessionId, { allowProcessReuse = true
|
|
|
2983
3059
|
processInspector: sessionWorkerProcessInspector,
|
|
2984
3060
|
allowProcessReuse,
|
|
2985
3061
|
onProcessListError: warnProcessListUnreadable,
|
|
3062
|
+
log: (message) => console.warn(`${runtimeLogPrefix()}${message}`),
|
|
2986
3063
|
});
|
|
2987
3064
|
const workerPid = Number.isInteger(Number(launched?.pid)) ? Number(launched.pid) : null;
|
|
2988
3065
|
const workerId = `worker-${normalizedTargetSessionId.slice(0, 8)}`;
|
|
@@ -4372,7 +4449,7 @@ function fetchDriveDirectoryEntries(absoluteDirPath, { includeHidden = false } =
|
|
|
4372
4449
|
'$rows | ConvertTo-Json -Depth 4 -Compress',
|
|
4373
4450
|
].join(' ');
|
|
4374
4451
|
|
|
4375
|
-
execFile('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', psScript], { windowsHide: true }, (err, stdout, stderr) => {
|
|
4452
|
+
execFile(windowsSystemToolPath('powershell.exe'), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', psScript], { windowsHide: true }, (err, stdout, stderr) => {
|
|
4376
4453
|
if (err) return cb(new Error(`drive list failed: ${stderr || err.message}`));
|
|
4377
4454
|
const text = String(stdout || '').trim();
|
|
4378
4455
|
if (!text) return cb(null, []);
|
|
@@ -4965,6 +5042,9 @@ function createCompactedConversation(sourceConversationId) {
|
|
|
4965
5042
|
stmts.insertConv.run(targetConversationId, nextTitle, now, now);
|
|
4966
5043
|
stmts.updateConvSeed.run(summarySeed, 1, sourceConversationId, now, targetConversationId);
|
|
4967
5044
|
stmts.markConvCompacted.run(targetConversationId, now, sourceConversationId);
|
|
5045
|
+
// The new conversation stays in the old one's session group, and heads
|
|
5046
|
+
// it if the old one did; the archived source stays a member.
|
|
5047
|
+
conversationGroups.carryToCompacted(sourceConversationId, targetConversationId, now);
|
|
4968
5048
|
targetRuntimeSession = ensureRuntimeSessionBinding(targetConversationId, null, now);
|
|
4969
5049
|
});
|
|
4970
5050
|
tx();
|
|
@@ -5124,25 +5204,10 @@ function previewCardsForResponse(responseMessageId) {
|
|
|
5124
5204
|
.slice(0, 5);
|
|
5125
5205
|
}
|
|
5126
5206
|
|
|
5127
|
-
// Live background-task sets
|
|
5128
|
-
//
|
|
5129
|
-
//
|
|
5130
|
-
|
|
5131
|
-
const backgroundTaskStore = {
|
|
5132
|
-
sets: new Map(),
|
|
5133
|
-
replace(conversationId, tasks) {
|
|
5134
|
-
const id = String(conversationId || '').trim();
|
|
5135
|
-
if (!id) return;
|
|
5136
|
-
if (!Array.isArray(tasks) || tasks.length === 0) {
|
|
5137
|
-
this.sets.delete(id);
|
|
5138
|
-
return;
|
|
5139
|
-
}
|
|
5140
|
-
this.sets.set(id, { tasks, updatedAt: Date.now() });
|
|
5141
|
-
},
|
|
5142
|
-
get(conversationId) {
|
|
5143
|
-
return this.sets.get(String(conversationId || '').trim())?.tasks || [];
|
|
5144
|
-
},
|
|
5145
|
-
};
|
|
5207
|
+
// Live background-task sets, as the session workers publish them. Memory
|
|
5208
|
+
// only: after a runtime start the heartbeat's digest brings each set back
|
|
5209
|
+
// (services/background-task-store.mjs).
|
|
5210
|
+
const backgroundTaskStore = createBackgroundTaskStore();
|
|
5146
5211
|
|
|
5147
5212
|
// ─── Deferred host suspend ───────────────────────────────────────────────────
|
|
5148
5213
|
// "💤 Suspend host" waits until nothing runs any more: no queued/processing
|
|
@@ -5175,6 +5240,17 @@ function hostActivityTitle(conversationId, fallbackTitle = null) {
|
|
|
5175
5240
|
const title = String(fallbackTitle || stmts.getConv?.get?.(conversationId)?.title || '').trim();
|
|
5176
5241
|
return title || String(conversationId || '').slice(0, 8);
|
|
5177
5242
|
}
|
|
5243
|
+
const backgroundWorkView = createBackgroundWorkView({
|
|
5244
|
+
store: backgroundTaskStore,
|
|
5245
|
+
db,
|
|
5246
|
+
getRegistry: () => sessionWorkerRegistry,
|
|
5247
|
+
getTracker: () => sessionActivityTracker,
|
|
5248
|
+
isWorkerAlive: hostActivityWorkerAlive,
|
|
5249
|
+
sessionIdOf: (conversationId) => stmts.getRuntimeSessionByConversation?.get?.(conversationId)?.sdk_session_id,
|
|
5250
|
+
conversationIdOf: (sessionId) => resolveConversationRecord({ conversationId: sessionId, sdkSessionId: sessionId })?.id,
|
|
5251
|
+
providerOf: (conversationId) => stmts.getRuntimeSessionByConversation?.get?.(conversationId)?.provider_type,
|
|
5252
|
+
titleOf: (conversationId) => hostActivityTitle(conversationId),
|
|
5253
|
+
});
|
|
5178
5254
|
function collectHostActivity({ request = null } = {}) {
|
|
5179
5255
|
const blockers = [];
|
|
5180
5256
|
const turnsByConversation = stmts.listActiveQueueCountsByConversation?.all?.() || [];
|
|
@@ -5198,20 +5274,11 @@ function collectHostActivity({ request = null } = {}) {
|
|
|
5198
5274
|
: (count > 1 ? `${count} turns running` : 'turn running'),
|
|
5199
5275
|
});
|
|
5200
5276
|
}
|
|
5201
|
-
|
|
5202
|
-
|
|
5203
|
-
|
|
5204
|
-
|
|
5205
|
-
|
|
5206
|
-
hostSuspendWatchedConversationIds.add(conversationId);
|
|
5207
|
-
const what = tasks.slice(0, 3).map((task) => String(task?.description || task?.taskType || 'task').slice(0, 80)).join('; ');
|
|
5208
|
-
blockers.push({
|
|
5209
|
-
kind: 'background',
|
|
5210
|
-
conversationId,
|
|
5211
|
-
title: hostActivityTitle(conversationId),
|
|
5212
|
-
count: tasks.length,
|
|
5213
|
-
detail: `${tasks.length} background agent${tasks.length === 1 ? '' : 's'}: ${what}`,
|
|
5214
|
-
});
|
|
5277
|
+
// Background work of live workers, and a runtime too young to know of it
|
|
5278
|
+
// (services/background-work-view.mjs).
|
|
5279
|
+
for (const blocker of backgroundWorkView.blockers()) {
|
|
5280
|
+
if (blocker.conversationId) hostSuspendWatchedConversationIds.add(blocker.conversationId);
|
|
5281
|
+
blockers.push(blocker);
|
|
5215
5282
|
}
|
|
5216
5283
|
const runningSubagents = Number(stmts.countRunningSubagentRuns?.get?.()?.cnt || 0);
|
|
5217
5284
|
// CI is only worth a network round-trip for workspaces that were busy since
|
|
@@ -5259,6 +5326,10 @@ const usageLimitPauseService = createUsageLimitPauseService({
|
|
|
5259
5326
|
sessionActivityTracker = createSessionActivityTracker({
|
|
5260
5327
|
db,
|
|
5261
5328
|
onEdge: (edge) => relayEventService?.noteTaskEdge(edge),
|
|
5329
|
+
hasUnreportedBackgroundWork: (conversationId) => backgroundWorkView.hasUnreportedWork(conversationId),
|
|
5330
|
+
listUnreportedBackgroundWork: () => backgroundWorkView.listUnreportedConversationIds(),
|
|
5331
|
+
runListedByWorker: (conversationId, row) => backgroundWorkView.runListed(conversationId, row),
|
|
5332
|
+
bootReportingWindowMs: resolveBootReportingWindowMs(process.env, (line) => console.log(`[activity] ${line}`)),
|
|
5262
5333
|
logger: console,
|
|
5263
5334
|
});
|
|
5264
5335
|
relayEventService = createRelayEventService({
|
|
@@ -5804,9 +5875,11 @@ const app = express();
|
|
|
5804
5875
|
// Trust boundary for X-Forwarded-* headers. Default 'loopback' matches the
|
|
5805
5876
|
// localhost-first deployment (a cloudflared/caddy tunnel runs on the same host
|
|
5806
5877
|
// and connects over 127.0.0.1), so req.protocol/req.secure/req.ip reflect the
|
|
5807
|
-
// real edge only when the immediate peer is trusted — a direct client
|
|
5808
|
-
//
|
|
5809
|
-
//
|
|
5878
|
+
// real edge only when the immediate peer is trusted — a direct client cannot
|
|
5879
|
+
// forge those headers. Code that needs the client's address, host or scheme
|
|
5880
|
+
// reads req.ip / req.protocol / requestHostOf (services/request-boundary.mjs),
|
|
5881
|
+
// never the raw X-Forwarded-* headers. Operators behind a remote proxy can
|
|
5882
|
+
// widen this via config.trustProxy (e.g. a hop count or subnet).
|
|
5810
5883
|
app.set('trust proxy', config.trustProxy ?? 'loopback');
|
|
5811
5884
|
const httpServer = http.createServer(app);
|
|
5812
5885
|
// Node closes an idle keep-alive connection after 5 s. A client that pools
|
|
@@ -5826,15 +5899,23 @@ httpServer.prependListener('upgrade', (req, _socket, _head) => {
|
|
|
5826
5899
|
httpServer.prependListener('upgrade', (_req, socket, _head) => {
|
|
5827
5900
|
guardUpgradeSocket(socket);
|
|
5828
5901
|
});
|
|
5902
|
+
// "Truly local": a loopback peer with no tunnel marker and no forwarding
|
|
5903
|
+
// header (services/request-boundary.mjs). The marker headers are `cf-ray`
|
|
5904
|
+
// plus config.tunnelMarkerHeaders plus COPILOT_TUNNEL_MARKER_HEADERS, merged:
|
|
5905
|
+
// neither source switches the other off.
|
|
5906
|
+
const localRequestCheck = createLocalRequestCheck({
|
|
5907
|
+
markerHeaders: resolveTunnelMarkerHeaders(
|
|
5908
|
+
config.tunnelMarkerHeaders,
|
|
5909
|
+
process.env.COPILOT_TUNNEL_MARKER_HEADERS,
|
|
5910
|
+
),
|
|
5911
|
+
});
|
|
5829
5912
|
// Public tunnels (Cloudflare, or a VPS/Caddy front) forward every path on the
|
|
5830
5913
|
// bound hostname to this port, including the session-worker WebSocket plumbing.
|
|
5831
|
-
// Reject
|
|
5832
|
-
//
|
|
5914
|
+
// Reject every worker-path request that is not truly local before auth; local
|
|
5915
|
+
// workers connect over 127.0.0.1 without proxy traces and are unaffected.
|
|
5833
5916
|
const tunnelWorkerPathGuard = createTunnelWorkerPathGuard({
|
|
5834
5917
|
pathPrefix: remotePath,
|
|
5835
|
-
|
|
5836
|
-
|| process.env.COPILOT_TUNNEL_MARKER_HEADERS
|
|
5837
|
-
|| [],
|
|
5918
|
+
localRequestCheck,
|
|
5838
5919
|
logger: console,
|
|
5839
5920
|
});
|
|
5840
5921
|
httpServer.prependListener('upgrade', (req, socket, _head) => {
|
|
@@ -5846,8 +5927,27 @@ httpServer.prependListener('upgrade', (req, socket, _head) => {
|
|
|
5846
5927
|
// High-volume stream chunks are emitted volatile and deliberately excluded from
|
|
5847
5928
|
// the replay buffer; see the io.volatile.emit('relay_stream') call site.
|
|
5848
5929
|
const SOCKET_RECOVERY_WINDOW_MS = 10 * 60 * 1000;
|
|
5930
|
+
// The browser app opens its socket on the page's own origin (same host, the
|
|
5931
|
+
// `remotePath` socket.io path), so a handshake from another origin is refused:
|
|
5932
|
+
// a page elsewhere must not ride the relay cookie into a live socket. The
|
|
5933
|
+
// host compared is the one the client addressed (X-Forwarded-Host only from a
|
|
5934
|
+
// trusted proxy); config.allowedOrigins lists extra origins for a proxy that
|
|
5935
|
+
// rewrites the Host header. Clients that send no Origin (Node) pass.
|
|
5936
|
+
const socketAllowedOrigins = Array.isArray(config.allowedOrigins) ? config.allowedOrigins : [];
|
|
5937
|
+
const refusedSocketOrigins = new Set();
|
|
5849
5938
|
const io = new Server(httpServer, {
|
|
5850
|
-
|
|
5939
|
+
allowRequest: (req, callback) => {
|
|
5940
|
+
if (isSameOriginRequest(expressRequestView(app, req), { allowedOrigins: socketAllowedOrigins })) {
|
|
5941
|
+
callback(null, true);
|
|
5942
|
+
return;
|
|
5943
|
+
}
|
|
5944
|
+
const origin = String(req.headers?.origin || '').slice(0, 200);
|
|
5945
|
+
if (refusedSocketOrigins.size < 32 && !refusedSocketOrigins.has(origin)) {
|
|
5946
|
+
refusedSocketOrigins.add(origin);
|
|
5947
|
+
console.warn(`[socket] Refused a handshake from origin ${origin} (host ${String(req.headers?.host || '').slice(0, 200)}); list it in allowedOrigins if a proxy rewrites the Host header`);
|
|
5948
|
+
}
|
|
5949
|
+
callback('Origin not allowed', false);
|
|
5950
|
+
},
|
|
5851
5951
|
path: socketIoPath(),
|
|
5852
5952
|
connectionStateRecovery: {
|
|
5853
5953
|
maxDisconnectionDuration: SOCKET_RECOVERY_WINDOW_MS,
|
|
@@ -6321,6 +6421,7 @@ function handleWorkerSocketClosed({ sessionId, pid = null } = {}) {
|
|
|
6321
6421
|
if (runtimeShutdownStarted) return;
|
|
6322
6422
|
const sid = String(sessionId || '').trim();
|
|
6323
6423
|
if (!sid) return;
|
|
6424
|
+
detachedWorkerWatch.noteSocketEnded(sid);
|
|
6324
6425
|
const owned = stmts.listProcessingRowsForOwner?.all?.(sid) || [];
|
|
6325
6426
|
// A dead worker's steering snapshot must not keep holding the composer even
|
|
6326
6427
|
// when it owned no rows (the hold can outlive them, e.g. a continuation's).
|
|
@@ -6378,6 +6479,64 @@ async function sweepDeadWorkerProcessingRowsOnce() {
|
|
|
6378
6479
|
return recovered;
|
|
6379
6480
|
}
|
|
6380
6481
|
|
|
6482
|
+
// A worker alive without its socket while a message waits: replaced after a
|
|
6483
|
+
// grace time (see the service). Stops the way the kill route does, but only
|
|
6484
|
+
// names the registry's pid when the process list confirms it is the
|
|
6485
|
+
// session's worker, then spawns the replacement.
|
|
6486
|
+
async function replaceDetachedSessionWorker(sid) {
|
|
6487
|
+
const worker = sessionWorkerRegistry?.getWorker?.(sid) || null;
|
|
6488
|
+
const found = process.platform === 'win32'
|
|
6489
|
+
? await sessionWorkerProcessInspector.findWindowsProcessTreeForSessionAsync(sid, { fresh: true })
|
|
6490
|
+
: await sessionWorkerProcessInspector.findProcessesForSessionAsync(sid, { fresh: true });
|
|
6491
|
+
const foundPids = new Set((found || []).map((entry) => Number(entry?.processId)));
|
|
6492
|
+
const stopped = await stopSessionWorkerProcesses({
|
|
6493
|
+
sdkSessionId: sid,
|
|
6494
|
+
worker: worker && foundPids.has(Number(worker.pid)) ? worker : null,
|
|
6495
|
+
processInspector: sessionWorkerProcessInspector,
|
|
6496
|
+
isPidAliveImpl: isProcessAlive,
|
|
6497
|
+
killTmuxSessionImpl: killTmuxSession,
|
|
6498
|
+
});
|
|
6499
|
+
if (!stopped.ok) return { ok: false, error: stopped.error || 'worker-survived-stop' };
|
|
6500
|
+
sessionWorkerRegistry?.removeWorker?.(sid);
|
|
6501
|
+
sessionWorkerSupervisor?.clearRestartSchedule?.(sid);
|
|
6502
|
+
sessionWorkerSupervisor?.resetHealth?.(sid, { clearFailureCount: false });
|
|
6503
|
+
const launched = await sessionWorkerSupervisor?.ensureWorker?.(sid);
|
|
6504
|
+
return { ok: launched?.ok === true, error: launched?.error || null };
|
|
6505
|
+
}
|
|
6506
|
+
|
|
6507
|
+
function sessionHasBackgroundWork(sid) {
|
|
6508
|
+
if ((stmts.listRunningBackgroundSubagentRunsForSession?.all?.(sid, sid) || []).length) return true;
|
|
6509
|
+
if (backgroundWorkView.hasUnreportedWork(sid)) return true;
|
|
6510
|
+
const conversationId = resolveConversationRecord({ conversationId: sid, sdkSessionId: sid })?.id || '';
|
|
6511
|
+
if (backgroundTaskStore.get(sid).length || (conversationId && backgroundTaskStore.get(conversationId).length)) return true;
|
|
6512
|
+
return backgroundWorkView.backgroundBlockers()
|
|
6513
|
+
.some((blocker) => blocker.conversationId === sid || (conversationId && blocker.conversationId === conversationId));
|
|
6514
|
+
}
|
|
6515
|
+
|
|
6516
|
+
const detachedWorkerWatch = createDetachedWorkerWatch({
|
|
6517
|
+
isEnabled: () => featureFlags?.SESSION_WORKER_ROUTING_ENABLED === true
|
|
6518
|
+
&& !runtimeShutdownStarted
|
|
6519
|
+
&& !pendingRelayShutdownRequest,
|
|
6520
|
+
listWaitingSessionIds: () => (stmts.listPendingWorkerOwnerSessionIds?.all?.(25) || [])
|
|
6521
|
+
.map((row) => row?.sdk_session_id),
|
|
6522
|
+
hasWorkerSocket: (sid) => sessionWorkerWebSocketService.hasWorkerSocket(sid) === true,
|
|
6523
|
+
hasRunningTurn: (sid) => (stmts.listProcessingRowsForOwner?.all?.(sid) || []).length > 0,
|
|
6524
|
+
hasBackgroundWork: sessionHasBackgroundWork,
|
|
6525
|
+
isLaunchBlocked: (sid) => sessionWorkerSupervisor?.isKillBlocked?.(sid) === true,
|
|
6526
|
+
getLaunchState: (sid) => {
|
|
6527
|
+
const lifecycle = sessionWorkerSupervisor?.getLifecycleState?.(sid) || null;
|
|
6528
|
+
if (!lifecycle) return null;
|
|
6529
|
+
return {
|
|
6530
|
+
launchMode: lifecycle.launchMode,
|
|
6531
|
+
launchAtMs: lifecycle.launchAt ? Date.parse(lifecycle.launchAt) : null,
|
|
6532
|
+
awaitingHeartbeat: lifecycle.awaitingHeartbeat === true,
|
|
6533
|
+
};
|
|
6534
|
+
},
|
|
6535
|
+
isWorkerProcessAlive: async (sid) => Boolean(await sessionWorkerProcessInspector?.findProcessForSessionAsync?.(sid)),
|
|
6536
|
+
replaceWorker: replaceDetachedSessionWorker,
|
|
6537
|
+
log: (message) => console.warn(`${runtimeLogPrefix()}${message}`),
|
|
6538
|
+
});
|
|
6539
|
+
|
|
6381
6540
|
const sessionWorkerWebSocketService = createSessionWorkerWebSocketService({
|
|
6382
6541
|
WebSocketServerImpl: WebSocketServer,
|
|
6383
6542
|
httpServer,
|
|
@@ -6512,7 +6671,27 @@ app.get(['/shared/:token', '/shared/:token/'], (req, res, next) => {
|
|
|
6512
6671
|
try {
|
|
6513
6672
|
const rawToken = String(req.params?.token || '').trim().toLowerCase();
|
|
6514
6673
|
const shareToken = /^[a-f0-9]{32,128}$/.test(rawToken) ? rawToken : '';
|
|
6674
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
6675
|
+
res.setHeader('X-Robots-Tag', 'noindex, nofollow, noarchive');
|
|
6676
|
+
// An unknown, revoked or renewed link is a plain 404: no app shell, and
|
|
6677
|
+
// nothing that tells it apart from a link that never existed.
|
|
6678
|
+
if (!shareToken || !shareRepository.findLiveShare(shareToken)) {
|
|
6679
|
+
res.status(404).type('text/plain').send('Not Found');
|
|
6680
|
+
return;
|
|
6681
|
+
}
|
|
6515
6682
|
const html = renderIndexHtmlWithPwaVersion({ sharedToken: shareToken });
|
|
6683
|
+
res.type('html').send(html);
|
|
6684
|
+
} catch (error) {
|
|
6685
|
+
next(error);
|
|
6686
|
+
}
|
|
6687
|
+
});
|
|
6688
|
+
// "Preview as shared": the shared reader for the owner. The page holds
|
|
6689
|
+
// nothing of the conversation; its data comes from the owner-only preview route.
|
|
6690
|
+
app.get(['/share-preview/:conversationId', '/share-preview/:conversationId/'], (req, res, next) => {
|
|
6691
|
+
try {
|
|
6692
|
+
const conversationId = String(req.params?.conversationId || '').trim();
|
|
6693
|
+
const sharePreviewConversationId = /^[A-Za-z0-9_.:-]{1,200}$/.test(conversationId) ? conversationId : '';
|
|
6694
|
+
const html = renderIndexHtmlWithPwaVersion({ sharePreviewConversationId });
|
|
6516
6695
|
res.setHeader('Cache-Control', 'no-store');
|
|
6517
6696
|
res.setHeader('X-Robots-Tag', 'noindex, nofollow, noarchive');
|
|
6518
6697
|
res.type('html').send(html);
|
|
@@ -6659,6 +6838,7 @@ const remoteRelayDispatcher = createRemoteRelayDispatcher({
|
|
|
6659
6838
|
|
|
6660
6839
|
const sharedRouteDeps = {
|
|
6661
6840
|
auth,
|
|
6841
|
+
isTrulyLocalRequest: localRequestCheck.isTrulyLocal,
|
|
6662
6842
|
gitChangesService,
|
|
6663
6843
|
gitRemoteService,
|
|
6664
6844
|
currentWorkspaceRootPath,
|
|
@@ -6778,9 +6958,12 @@ const sharedRouteDeps = {
|
|
|
6778
6958
|
inFlightStateForConversation,
|
|
6779
6959
|
resolveLiveTurnQueueRow,
|
|
6780
6960
|
backgroundTaskStore,
|
|
6961
|
+
backgroundWorkView,
|
|
6781
6962
|
// A closure, not the registry itself: this deps object is built before the
|
|
6782
6963
|
// preview lane is constructed further down the file.
|
|
6783
6964
|
listConversationPreviews: (conversationId) => previewRegistry.listForConversation(conversationId),
|
|
6965
|
+
closeConversationPreviews: (conversationId) => previewRegistry.closeForConversation(conversationId),
|
|
6966
|
+
listKnownPreviewTokens: () => previewRegistry.knownTokens(),
|
|
6784
6967
|
sendWorkerControl: (sessionId, control) => sessionWorkerWebSocketService.sendControlToSession(sessionId, control),
|
|
6785
6968
|
emitToClientsExceptSessionId,
|
|
6786
6969
|
relayBridgeOwnerService,
|
|
@@ -6844,6 +7027,9 @@ const sharedRouteDeps = {
|
|
|
6844
7027
|
setTurnCeilingMinutes,
|
|
6845
7028
|
getSubagentRecordRetentionDays,
|
|
6846
7029
|
setSubagentRecordRetentionDays,
|
|
7030
|
+
getSharePathPrivacyMode,
|
|
7031
|
+
setSharePathPrivacyMode,
|
|
7032
|
+
sharePathKnownRoots,
|
|
6847
7033
|
getBackgroundTaskTimeoutMinutes,
|
|
6848
7034
|
setBackgroundTaskTimeoutMinutes,
|
|
6849
7035
|
getFeatureFlagsSettingsState,
|
|
@@ -6874,9 +7060,12 @@ const sharedRouteDeps = {
|
|
|
6874
7060
|
windowsBootAutostartService,
|
|
6875
7061
|
pushDispatchService,
|
|
6876
7062
|
remoteRelayInbound,
|
|
7063
|
+
conversationGroups,
|
|
7064
|
+
shareRepository,
|
|
6877
7065
|
};
|
|
6878
7066
|
registerMessagesRoutes(app, sharedRouteDeps);
|
|
6879
7067
|
registerSessionsRoutes(app, sharedRouteDeps);
|
|
7068
|
+
registerConversationGroupRoutes(app, sharedRouteDeps);
|
|
6880
7069
|
registerAskUserRoutes(app, sharedRouteDeps);
|
|
6881
7070
|
registerRelayBoardRoutes(app, sharedRouteDeps);
|
|
6882
7071
|
registerCacheRoutes(app, sharedRouteDeps);
|
|
@@ -6899,6 +7088,30 @@ registerRelayEventRoutes(app, {
|
|
|
6899
7088
|
relayEventService,
|
|
6900
7089
|
remoteRelayInbound,
|
|
6901
7090
|
});
|
|
7091
|
+
registerAboutRoutes(app, {
|
|
7092
|
+
auth,
|
|
7093
|
+
aboutService: createAboutService({
|
|
7094
|
+
db,
|
|
7095
|
+
dbPath: DB_PATH,
|
|
7096
|
+
version: RUNNING_VERSION,
|
|
7097
|
+
commit: BUILD_COMMIT,
|
|
7098
|
+
installMethod: INSTALL_METHOD,
|
|
7099
|
+
startedAt: RELAY_STARTED_AT,
|
|
7100
|
+
packageRoot: PACKAGE_ROOT,
|
|
7101
|
+
getProviderSettings: () => ({
|
|
7102
|
+
github: { enabled: true, engine: getCopilotEngine() },
|
|
7103
|
+
openai: { enabled: getOpenAIProviderSettings()?.enabled === true },
|
|
7104
|
+
claude: { enabled: getClaudeProviderSettings()?.enabled === true },
|
|
7105
|
+
'claude-cloud': { enabled: getClaudeCloudProviderSettings()?.enabled === true },
|
|
7106
|
+
cursor: { enabled: getCursorProviderSettings()?.enabled === true },
|
|
7107
|
+
grok: { enabled: getGrokProviderSettings()?.enabled === true },
|
|
7108
|
+
}),
|
|
7109
|
+
getCachedCliProviders: () => cliInstallService.getCachedStatusSnapshot()?.providers || {},
|
|
7110
|
+
isRelayCliOnline: () => runtimeState.cliOnline === true,
|
|
7111
|
+
listWorkers: () => sessionWorkerRegistry.listWorkers(),
|
|
7112
|
+
getUpdateState: () => buildUpdateStatePayload(),
|
|
7113
|
+
}),
|
|
7114
|
+
});
|
|
6902
7115
|
function markCliOffline(reason = 'offline', { clearOwner = true } = {}) {
|
|
6903
7116
|
cliLastSeen = null;
|
|
6904
7117
|
const wasOnline = cliOnline;
|
|
@@ -7049,38 +7262,21 @@ function runtimeLogPrefix() {
|
|
|
7049
7262
|
}
|
|
7050
7263
|
|
|
7051
7264
|
// ─── Auth Middleware ──────────────────────────────────────────────────────────
|
|
7052
|
-
//
|
|
7053
|
-
//
|
|
7054
|
-
//
|
|
7055
|
-
|
|
7056
|
-
|
|
7057
|
-
|
|
7058
|
-
|
|
7059
|
-
|
|
7060
|
-
|
|
7265
|
+
// Token check, wrong-token lockout and the Socket.IO handshake gate live in
|
|
7266
|
+
// services/relay-auth.mjs. `auth` stays a hoisted function: the route deps
|
|
7267
|
+
// above hand it out before this line runs.
|
|
7268
|
+
const relayAuth = createRelayAuth({
|
|
7269
|
+
app,
|
|
7270
|
+
getAuthToken: () => config.authToken,
|
|
7271
|
+
cookieName: AUTH_COOKIE,
|
|
7272
|
+
cookiePath: COOKIE_PATH,
|
|
7273
|
+
parseCookies,
|
|
7274
|
+
appendSetCookie,
|
|
7275
|
+
localRequestCheck,
|
|
7276
|
+
logger: console,
|
|
7277
|
+
});
|
|
7061
7278
|
function auth(req, res, next) {
|
|
7062
|
-
|
|
7063
|
-
// req.secure is derived through the configured `trust proxy` boundary, so a
|
|
7064
|
-
// direct (untrusted) client can no longer force a non-Secure cookie by
|
|
7065
|
-
// omitting X-Forwarded-Proto.
|
|
7066
|
-
const secureAttr = req.secure ? '; Secure' : '';
|
|
7067
|
-
// Note: the token is intentionally NOT read from req.body — a request body is
|
|
7068
|
-
// an easy CSRF/logging vector and has no legitimate use here. Header, query
|
|
7069
|
-
// (first-load bootstrap only), and the HttpOnly cookie remain.
|
|
7070
|
-
const token =
|
|
7071
|
-
(req.headers['authorization'] || '').replace(/^Bearer\s+/i, '') ||
|
|
7072
|
-
req.query.token ||
|
|
7073
|
-
cookies[AUTH_COOKIE];
|
|
7074
|
-
if (tokensMatch(token, config.authToken)) {
|
|
7075
|
-
if (res && !tokensMatch(cookies[AUTH_COOKIE], config.authToken)) {
|
|
7076
|
-
appendSetCookie(res, `${AUTH_COOKIE}=${encodeURIComponent(config.authToken)}; Path=${COOKIE_PATH}; Max-Age=2592000; SameSite=Lax; HttpOnly${secureAttr}`);
|
|
7077
|
-
}
|
|
7078
|
-
return next();
|
|
7079
|
-
}
|
|
7080
|
-
if (res && cookies[AUTH_COOKIE]) {
|
|
7081
|
-
appendSetCookie(res, `${AUTH_COOKIE}=; Path=${COOKIE_PATH}; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax; HttpOnly${secureAttr}`);
|
|
7082
|
-
}
|
|
7083
|
-
res.status(401).json({ error: 'Unauthorized' });
|
|
7279
|
+
return relayAuth.auth(req, res, next);
|
|
7084
7280
|
}
|
|
7085
7281
|
|
|
7086
7282
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
@@ -7244,6 +7440,9 @@ relayEventService.start();
|
|
|
7244
7440
|
relayEventLinkService.start();
|
|
7245
7441
|
runtimeTimers.pendingWorkerPrime = setInterval(() => {
|
|
7246
7442
|
void primePendingSessionWorkers('maintenance');
|
|
7443
|
+
detachedWorkerWatch.sweep().catch((error) => {
|
|
7444
|
+
console.warn(`${runtimeLogPrefix()}WORKER DETACHED sweep failed: ${error?.message || error}`);
|
|
7445
|
+
});
|
|
7247
7446
|
}, 5_000);
|
|
7248
7447
|
if (typeof runtimeTimers.pendingWorkerPrime.unref === 'function') runtimeTimers.pendingWorkerPrime.unref();
|
|
7249
7448
|
|
|
@@ -7284,18 +7483,13 @@ function sanitizeRelayQuestionContext(rawContext) {
|
|
|
7284
7483
|
|
|
7285
7484
|
|
|
7286
7485
|
// ─── Socket.io Auth ───────────────────────────────────────────────────────────
|
|
7287
|
-
function socketAuthToken(socket) {
|
|
7288
|
-
const cookies = parseCookies(socket.request.headers.cookie);
|
|
7289
|
-
return socket.handshake.auth?.token || socket.handshake.query?.token || cookies[AUTH_COOKIE];
|
|
7290
|
-
}
|
|
7291
7486
|
// A sub-agent's record goes live only to the pages whose viewer shows it.
|
|
7292
7487
|
const subagentViewerRooms = createSubagentViewerRooms({
|
|
7293
7488
|
getRun: (runId) => stmts.getSubagentRun?.get?.(runId) || null,
|
|
7294
7489
|
});
|
|
7295
|
-
|
|
7296
|
-
|
|
7297
|
-
|
|
7298
|
-
});
|
|
7490
|
+
// The handshake presents the token as `auth.token` or the cookie; a query
|
|
7491
|
+
// `token` is not read (see services/relay-auth.mjs).
|
|
7492
|
+
io.use(relayAuth.socketMiddleware);
|
|
7299
7493
|
|
|
7300
7494
|
io.engine.on('initial_headers', (headers, req) => {
|
|
7301
7495
|
const sessionId = ensureSessionId(req);
|
|
@@ -7307,7 +7501,7 @@ io.on('connection', (socket) => {
|
|
|
7307
7501
|
// socket bypasses the io.use() token gate above. Re-validate here and drop it
|
|
7308
7502
|
// on mismatch so a recovered session still requires the auth token (and a
|
|
7309
7503
|
// rotated token takes effect immediately).
|
|
7310
|
-
if (socket.recovered && !
|
|
7504
|
+
if (socket.recovered && !relayAuth.socketTokenValid(socket)) {
|
|
7311
7505
|
socket.disconnect(true);
|
|
7312
7506
|
return;
|
|
7313
7507
|
}
|
|
@@ -7378,11 +7572,42 @@ const previewsConfig = normalizePreviewsConfig(config.previews || {}, {
|
|
|
7378
7572
|
for (const previewConfigError of previewsConfig.errors) {
|
|
7379
7573
|
console.warn(`${runtimeLogPrefix()}PREVIEW LANE disabled: ${previewConfigError}`);
|
|
7380
7574
|
}
|
|
7575
|
+
// Static previews are jailed to the conversation's workspace root; a preview
|
|
7576
|
+
// registered without a conversation falls back to the server-wide root.
|
|
7577
|
+
const resolvePreviewWorkspaceRoot = (conversationId) => {
|
|
7578
|
+
if (String(conversationId || '').trim()) {
|
|
7579
|
+
const state = resolveConversationWorkspaceState({ conversationId });
|
|
7580
|
+
const rootPath = String(state?.currentWorkspaceRootPath || '').trim();
|
|
7581
|
+
if (rootPath) return rootPath;
|
|
7582
|
+
}
|
|
7583
|
+
return currentWorkspaceRootPath();
|
|
7584
|
+
};
|
|
7585
|
+
// Persisted in the previews table and restored here, each row checked again
|
|
7586
|
+
// with the registration rules (see createPreviewRegistry).
|
|
7381
7587
|
const previewRegistry = createPreviewRegistry({
|
|
7382
7588
|
config: previewsConfig,
|
|
7383
7589
|
onChange: ({ previews }) => {
|
|
7384
7590
|
try { io.emit('previews', { previews }); } catch {}
|
|
7385
7591
|
},
|
|
7592
|
+
store: createSqlitePreviewStore(db, { logger: console }),
|
|
7593
|
+
hostBootId: readHostBootId(),
|
|
7594
|
+
isSameHostBoot,
|
|
7595
|
+
checkConversation: (conversationId) => {
|
|
7596
|
+
const row = stmts.getConvAnyStatus.get(conversationId);
|
|
7597
|
+
return !!row && row.status !== 'deleted';
|
|
7598
|
+
},
|
|
7599
|
+
// At boot no worker has reported, so the configured root comes first while
|
|
7600
|
+
// the preview may have been registered under the worker's runtime root:
|
|
7601
|
+
// either of the conversation's roots keeps it.
|
|
7602
|
+
checkStaticRoot: (rootDir, conversationId) => {
|
|
7603
|
+
const state = String(conversationId || '').trim()
|
|
7604
|
+
? resolveConversationWorkspaceState({ conversationId }) : null;
|
|
7605
|
+
return validateStaticRootInAny(rootDir, [
|
|
7606
|
+
resolvePreviewWorkspaceRoot(conversationId),
|
|
7607
|
+
state?.runtimeWorkspaceRootPath,
|
|
7608
|
+
state?.configuredWorkspaceRootPath,
|
|
7609
|
+
]);
|
|
7610
|
+
},
|
|
7386
7611
|
});
|
|
7387
7612
|
const previewProxyServer = createPreviewProxyServer({
|
|
7388
7613
|
registry: previewRegistry,
|
|
@@ -7406,16 +7631,7 @@ registerPreviewRoutes(app, {
|
|
|
7406
7631
|
auth,
|
|
7407
7632
|
previewRegistry,
|
|
7408
7633
|
previewHealthProbe,
|
|
7409
|
-
|
|
7410
|
-
// registered without a conversation falls back to the server-wide root.
|
|
7411
|
-
resolvePreviewWorkspaceRoot: (conversationId) => {
|
|
7412
|
-
if (String(conversationId || '').trim()) {
|
|
7413
|
-
const state = resolveConversationWorkspaceState({ conversationId });
|
|
7414
|
-
const rootPath = String(state?.currentWorkspaceRootPath || '').trim();
|
|
7415
|
-
if (rootPath) return rootPath;
|
|
7416
|
-
}
|
|
7417
|
-
return currentWorkspaceRootPath();
|
|
7418
|
-
},
|
|
7634
|
+
resolvePreviewWorkspaceRoot,
|
|
7419
7635
|
// A preview published while a turn is processing gets pinned to that turn's
|
|
7420
7636
|
// transcript (snapshot, keyed on the queue id and linked to the response at
|
|
7421
7637
|
// finalize — the relay_activity two-step). No in-flight turn → no card.
|
|
@@ -7495,9 +7711,9 @@ function shutdownRuntime(reason = 'unknown', { exitCode = 0 } = {}) {
|
|
|
7495
7711
|
cloudflaredTunnelManager.stop();
|
|
7496
7712
|
previewHealthProbe.stop();
|
|
7497
7713
|
try { remoteRelayRegistry.stopHealthLoop(); } catch {}
|
|
7498
|
-
// Previews
|
|
7499
|
-
//
|
|
7500
|
-
previewRegistry.
|
|
7714
|
+
// Previews persist across the restart (the next runtime restores them); only
|
|
7715
|
+
// the throttled last-use times still need writing.
|
|
7716
|
+
previewRegistry.flushHits();
|
|
7501
7717
|
void previewProxyServer.stop().catch(() => {});
|
|
7502
7718
|
try { relaySingletonGuard.release(); } catch (error) {
|
|
7503
7719
|
console.warn(`${runtimeLogPrefix()}Failed to release singleton lock: ${error?.message || error}`);
|