@oar-sh/oar 0.9.9 → 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 +561 -1
- package/README.md +162 -909
- package/bin/bat/oar.bat +46 -46
- package/package.json +14 -14
- package/server/README.md +369 -13
- package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
- package/server/claude-worker/claude-session-process.mjs +121 -9
- package/server/claude-worker/claude-session-worker.mjs +9 -1
- package/server/claude-worker/claude-turn-publisher.mjs +3 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
- package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +79 -12
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
- package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
- package/server/db-schema.mjs +95 -2
- package/server/grok-worker/acp-client.mjs +2 -1
- package/server/grok-worker/acp-host-services.mjs +4 -3
- package/server/migrations/0009-subagent-timeline.mjs +135 -0
- package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
- package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
- package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -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/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 +187 -0
- package/server/public/app/api-client.js +312 -16
- package/server/public/app/archive-confirm.mjs +28 -0
- package/server/public/app/ask-user-view.js +17 -12
- package/server/public/app/attachments-view.js +516 -6
- package/server/public/app/background-tasks-view.mjs +18 -26
- package/server/public/app/bootstrap.js +336 -59
- package/server/public/app/chat-actions-menu-state.mjs +126 -0
- package/server/public/app/claude-cloud-conversation-ui.js +10 -22
- package/server/public/app/composer-control-state.mjs +16 -0
- package/server/public/app/composer-keyboard.mjs +54 -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-turn-state.mjs +81 -0
- package/server/public/app/conversation-view.js +436 -117
- package/server/public/app/cwd-picker.js +1 -1
- package/server/public/app/explorer-edit-state.mjs +68 -0
- package/server/public/app/hidden-messages-view.mjs +312 -0
- package/server/public/app/host-suspend-ui.js +13 -4
- package/server/public/app/journal-view.js +930 -45
- package/server/public/app/no-conversation-state.mjs +58 -0
- package/server/public/app/preview-cards.mjs +117 -31
- package/server/public/app/preview-command.mjs +10 -1
- package/server/public/app/provider-readiness.mjs +120 -0
- package/server/public/app/relay-board-view.js +19 -3
- package/server/public/app/relay-event-subscriptions-ui.js +184 -0
- package/server/public/app/relay-event-view.mjs +234 -0
- package/server/public/app/relay-restart-error.mjs +16 -0
- package/server/public/app/settings-modal.js +93 -9
- 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 +137 -40
- package/server/public/app/store.js +115 -32
- package/server/public/app/subagent-row.mjs +138 -0
- package/server/public/app/subagent-timeline-view.mjs +186 -0
- package/server/public/app/subagent-viewer-watch.mjs +50 -0
- package/server/public/app/subagent-viewer.mjs +558 -0
- package/server/public/app/task-row-format.mjs +29 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +464 -0
- package/server/public/app/transcript-jump-bottom.mjs +210 -0
- package/server/public/app/transcript-reload-freshness.mjs +25 -0
- package/server/public/app/transcript-viewport-anchor.mjs +140 -0
- package/server/public/app/update-ui.js +1 -1
- package/server/public/app/viewer-history.mjs +5 -3
- package/server/public/index.html +774 -12
- 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 +30 -3
- package/server/repositories/question-repository.mjs +9 -3
- package/server/repositories/remote-relay-repository.mjs +6 -2
- 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/repositories/subagent-timeline-statements.mjs +170 -0
- package/server/routes/about-routes.mjs +15 -0
- package/server/routes/ask-user-routes.mjs +3 -0
- package/server/routes/cache-routes.mjs +4 -0
- package/server/routes/conversation-group-routes.mjs +171 -0
- package/server/routes/messages-routes.mjs +698 -114
- package/server/routes/preview-routes.mjs +9 -1
- package/server/routes/relay-event-routes.mjs +157 -0
- package/server/routes/remote-relay-routes.mjs +15 -0
- package/server/routes/sessions-routes.mjs +843 -193
- package/server/server-runtime.mjs +559 -160
- 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/explorer-entry-name.mjs +26 -0
- package/server/services/explorer-mutations.mjs +122 -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 +49 -27
- 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/push-dispatch-service.mjs +18 -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-link-service.mjs +289 -0
- package/server/services/relay-event-service.mjs +1952 -0
- package/server/services/relay-shutdown-gate.mjs +31 -0
- package/server/services/remote-relay-caller-context.mjs +52 -7
- package/server/services/remote-relay-dispatcher.mjs +551 -7
- package/server/services/remote-relay-registry-service.mjs +11 -1
- package/server/services/request-boundary.mjs +183 -0
- package/server/services/session-activity-tracker.mjs +695 -0
- package/server/services/session-history-refresh-service.mjs +7 -0
- 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/stale-attempt-log-limiter.mjs +96 -0
- package/server/services/stale-recovery-clock-guard.mjs +57 -0
- package/server/services/stale-subagent-run-sweep.mjs +109 -0
- package/server/services/status-event-service.mjs +7 -3
- package/server/services/subagent-record-retention.mjs +164 -0
- package/server/services/subagent-timeline-service.mjs +328 -0
- package/server/services/subagent-viewer-rooms.mjs +119 -0
- package/server/services/tunnel-worker-path-guard.mjs +24 -41
- package/server/services/upgrade-socket-guard.mjs +16 -0
- 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/relay-events.mjs +371 -0
- package/shared/remote-relay-contract.mjs +160 -6
- package/shared/share-path-privacy.mjs +30 -0
- package/shared/subagent-record-retention.mjs +31 -0
- package/shared/subagent-timeline.mjs +79 -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
|
@@ -234,6 +234,11 @@ export function createSessionHistoryRefreshService({
|
|
|
234
234
|
// replaces those ids, so the rows must go with the messages they annotated
|
|
235
235
|
// (same lifecycle as subagent_runs). The fallback guards dbs opened before
|
|
236
236
|
// the table existed.
|
|
237
|
+
// The sub-agent timeline goes with its runs. Null on a database the 0009
|
|
238
|
+
// migration has not reached (no table, nothing to delete).
|
|
239
|
+
const deleteConversationSubagentTimeline = typeof stmts?.deleteConvSubagentTimeline?.run === 'function'
|
|
240
|
+
? stmts.deleteConvSubagentTimeline
|
|
241
|
+
: null;
|
|
237
242
|
const deleteConversationWorkflowRuns = typeof stmts?.deleteConvWorkflowRuns?.run === 'function'
|
|
238
243
|
? stmts.deleteConvWorkflowRuns
|
|
239
244
|
: db.prepare(`DELETE FROM workflow_runs WHERE conversation_id = ?`);
|
|
@@ -425,6 +430,7 @@ export function createSessionHistoryRefreshService({
|
|
|
425
430
|
deleteConversationThoughts.run(conversationId);
|
|
426
431
|
deleteConversationStreamEvents.run(conversationId);
|
|
427
432
|
deleteConversationSubagentRuns.run(conversationId);
|
|
433
|
+
deleteConversationSubagentTimeline?.run(conversationId);
|
|
428
434
|
deleteConversationWorkflowRuns.run(conversationId);
|
|
429
435
|
});
|
|
430
436
|
|
|
@@ -441,6 +447,7 @@ export function createSessionHistoryRefreshService({
|
|
|
441
447
|
deleteConversationThoughts.run(conversationId);
|
|
442
448
|
deleteConversationStreamEvents.run(conversationId);
|
|
443
449
|
deleteConversationSubagentRuns.run(conversationId);
|
|
450
|
+
deleteConversationSubagentTimeline?.run(conversationId);
|
|
444
451
|
deleteConversationWorkflowRuns.run(conversationId);
|
|
445
452
|
insertRebuiltMessages(conversationId, messages, attachmentCandidates);
|
|
446
453
|
const rebuiltMessages = ensureArray(messages);
|
|
@@ -14,6 +14,12 @@ import {
|
|
|
14
14
|
isOarMcpServerDisabled,
|
|
15
15
|
} from '../mcp/oar-mcp-server.mjs';
|
|
16
16
|
import { WORKER_LOG_FILE_ENV } from '../../shared/worker-runtime/worker-log-file.mjs';
|
|
17
|
+
import {
|
|
18
|
+
describeSpawnEnvironment,
|
|
19
|
+
envPathKey,
|
|
20
|
+
readEnvValue,
|
|
21
|
+
windowsCmdPath,
|
|
22
|
+
} from '../../shared/windows-system-tools.mjs';
|
|
17
23
|
|
|
18
24
|
export { isOarMcpServerDisabled };
|
|
19
25
|
|
|
@@ -43,6 +49,87 @@ function normalizeText(value) {
|
|
|
43
49
|
return text || null;
|
|
44
50
|
}
|
|
45
51
|
|
|
52
|
+
// ─── Which Node runs a worker ────────────────────────────────────────────────
|
|
53
|
+
//
|
|
54
|
+
// The relay's own Node by default: its absolute path works whatever PATH the
|
|
55
|
+
// relay was started with. A relay started by the Windows boot task found no
|
|
56
|
+
// `node` on that task's PATH, and every worker launch failed without a trace.
|
|
57
|
+
// COPILOT_WEB_RELAY_NODE still overrides it.
|
|
58
|
+
|
|
59
|
+
export function resolveWorkerNodeExecutable(env = {}, { execPath = process.execPath } = {}) {
|
|
60
|
+
return normalizeText(env?.COPILOT_WEB_RELAY_NODE) || execPath;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function pathImplFor(platform) {
|
|
64
|
+
return platform === 'win32' ? path.win32 : path.posix;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isPathLike(value) {
|
|
68
|
+
return /[\\/]/.test(String(value || ''));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The folder of the worker's Node when it is a path, else null. */
|
|
72
|
+
export function resolveWorkerNodeDirectory(env = {}, { platform = process.platform, execPath = process.execPath } = {}) {
|
|
73
|
+
const executable = resolveWorkerNodeExecutable(env, { execPath });
|
|
74
|
+
const pathImpl = pathImplFor(platform);
|
|
75
|
+
const candidate = isPathLike(executable) ? executable : execPath;
|
|
76
|
+
if (!isPathLike(candidate)) return null;
|
|
77
|
+
return pathImpl.dirname(candidate);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The environment with the worker's Node folder at the END of PATH when it is
|
|
82
|
+
* not on it yet. The CLIs a worker starts, and the commands its agent runs,
|
|
83
|
+
* may call a bare `node`; appending only matters when PATH has none, and never
|
|
84
|
+
* changes which one a PATH that has one picks.
|
|
85
|
+
*/
|
|
86
|
+
export function appendNodeDirectoryToPath(env = {}, nodeDirectory, { platform = process.platform } = {}) {
|
|
87
|
+
const dir = normalizeText(nodeDirectory);
|
|
88
|
+
if (!dir) return env;
|
|
89
|
+
const pathImpl = pathImplFor(platform);
|
|
90
|
+
const key = envPathKey(env, platform);
|
|
91
|
+
const current = String(env?.[key] || '');
|
|
92
|
+
const comparable = (entry) => {
|
|
93
|
+
const trimmed = String(entry || '').trim().replace(/[\\/]+$/, '');
|
|
94
|
+
return platform === 'win32' ? trimmed.toLowerCase() : trimmed;
|
|
95
|
+
};
|
|
96
|
+
const wanted = comparable(dir);
|
|
97
|
+
if (current.split(pathImpl.delimiter).some((entry) => comparable(entry) === wanted)) return env;
|
|
98
|
+
const separator = current && !current.endsWith(pathImpl.delimiter) ? pathImpl.delimiter : '';
|
|
99
|
+
return { ...env, [key]: `${current}${separator}${dir}` };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Where `command` resolves to with this environment's PATH (and PATHEXT on
|
|
104
|
+
* Windows), or null. A command with a folder part must be a file.
|
|
105
|
+
*/
|
|
106
|
+
export function locateExecutable(command, {
|
|
107
|
+
env = {},
|
|
108
|
+
platform = process.platform,
|
|
109
|
+
fsImpl = fs,
|
|
110
|
+
} = {}) {
|
|
111
|
+
const name = String(command || '').trim();
|
|
112
|
+
if (!name) return null;
|
|
113
|
+
const pathImpl = pathImplFor(platform);
|
|
114
|
+
const isFile = (candidate) => {
|
|
115
|
+
try { return fsImpl.statSync(candidate).isFile(); } catch { return false; }
|
|
116
|
+
};
|
|
117
|
+
const extensions = platform === 'win32' && !pathImpl.extname(name)
|
|
118
|
+
? (readEnvValue(env, 'PATHEXT') || '.COM;.EXE;.BAT;.CMD').split(';').map((ext) => ext.trim()).filter(Boolean)
|
|
119
|
+
: [''];
|
|
120
|
+
const names = extensions.map((ext) => `${name}${ext}`);
|
|
121
|
+
if (extensions[0] !== '') names.push(name);
|
|
122
|
+
if (isPathLike(name)) return names.find(isFile) || null;
|
|
123
|
+
const dirs = readEnvValue(env, 'PATH').split(pathImpl.delimiter).map((dir) => dir.trim()).filter(Boolean);
|
|
124
|
+
for (const dir of dirs) {
|
|
125
|
+
for (const candidate of names) {
|
|
126
|
+
const full = pathImpl.join(dir, candidate);
|
|
127
|
+
if (isFile(full)) return full;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
46
133
|
export function resolveOpenAIWireApi(model = '') {
|
|
47
134
|
const normalized = String(model || '').trim().toLowerCase().replace(/^openai\//, '');
|
|
48
135
|
if (
|
|
@@ -352,7 +439,7 @@ function resolveNodeWorkerDescriptor(env = {}) {
|
|
|
352
439
|
function buildPosixWorkerLaunchCommand(targetSessionId, env = {}, extraCliArgs = []) {
|
|
353
440
|
const nodeWorker = resolveNodeWorkerDescriptor(env);
|
|
354
441
|
if (nodeWorker) {
|
|
355
|
-
const nodeExecutable =
|
|
442
|
+
const nodeExecutable = resolveWorkerNodeExecutable(env);
|
|
356
443
|
return `${shellQuote(nodeExecutable)} ${shellQuote(nodeWorker.scriptPath)} --session-id ${shellQuote(targetSessionId)}`;
|
|
357
444
|
}
|
|
358
445
|
const cliExecutable = normalizeText(env?.COPILOT_WEB_RELAY_CLI_EXECUTABLE)
|
|
@@ -689,7 +776,16 @@ export function buildTmuxWorkerShellCommand(targetSessionId, env = {}, {
|
|
|
689
776
|
if (!value) continue;
|
|
690
777
|
exports.push(`${key}=${shellQuote(value)}`);
|
|
691
778
|
}
|
|
692
|
-
|
|
779
|
+
// `sh -lc` builds PATH from the login profile, not from the relay's; the
|
|
780
|
+
// relay's Node folder goes at its end when it is not on it (see
|
|
781
|
+
// appendNodeDirectoryToPath).
|
|
782
|
+
const statements = exports.map((entry) => `export ${entry};`);
|
|
783
|
+
const nodeDirectory = resolveWorkerNodeDirectory(launchEnv);
|
|
784
|
+
if (nodeDirectory) {
|
|
785
|
+
const quotedDir = shellQuote(nodeDirectory);
|
|
786
|
+
statements.push(`case ":$PATH:" in *:${quotedDir}:*) ;; *) PATH="$PATH:"${quotedDir};; esac; export PATH;`);
|
|
787
|
+
}
|
|
788
|
+
const prefix = statements.length ? `${statements.join(' ')} ` : '';
|
|
693
789
|
const secretPrefix = normalizedSecretEnvFilePath
|
|
694
790
|
? `. ${shellQuote(normalizedSecretEnvFilePath)} || exit $?; rm -f ${shellQuote(normalizedSecretEnvFilePath)}; rmdir ${shellQuote(path.dirname(normalizedSecretEnvFilePath))}; `
|
|
695
791
|
: '';
|
|
@@ -714,6 +810,43 @@ function sleep(ms) {
|
|
|
714
810
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
715
811
|
}
|
|
716
812
|
|
|
813
|
+
/** How a spawned child ended, filled in as it happens. */
|
|
814
|
+
function watchChildExit(child) {
|
|
815
|
+
const state = { tracked: typeof child?.on === 'function', exited: false, code: null, signal: null, error: null };
|
|
816
|
+
if (!state.tracked) return state;
|
|
817
|
+
try {
|
|
818
|
+
// A listener also keeps a failed spawn's 'error' from going unhandled.
|
|
819
|
+
child.on('error', (error) => { state.error = error; });
|
|
820
|
+
child.on('exit', (code, signal) => {
|
|
821
|
+
state.exited = true;
|
|
822
|
+
state.code = code;
|
|
823
|
+
state.signal = signal;
|
|
824
|
+
});
|
|
825
|
+
} catch {
|
|
826
|
+
state.tracked = false;
|
|
827
|
+
}
|
|
828
|
+
return state;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Why `cmd /c start <program>` did not start the worker, or null when nothing
|
|
833
|
+
* says it failed. Exported for the tests.
|
|
834
|
+
*/
|
|
835
|
+
export function describeWindowsStartFailure(state, { spawnCommand = 'cmd.exe', program = 'node' } = {}) {
|
|
836
|
+
if (!state?.tracked) return null;
|
|
837
|
+
if (state.error) {
|
|
838
|
+
return `worker-spawn-failed: could not run ${spawnCommand} (${state.error.code || state.error.message || 'error'})`;
|
|
839
|
+
}
|
|
840
|
+
if (!state.exited) {
|
|
841
|
+
return `worker-spawn-failed: Windows could not start ${program} (start did not return; it may be showing an error dialog)`;
|
|
842
|
+
}
|
|
843
|
+
if (state.code !== 0) {
|
|
844
|
+
const how = state.code === null || state.code === undefined ? `signal ${state.signal || 'unknown'}` : `exit code ${state.code}`;
|
|
845
|
+
return `worker-spawn-failed: Windows could not start ${program} (start ended with ${how})`;
|
|
846
|
+
}
|
|
847
|
+
return null;
|
|
848
|
+
}
|
|
849
|
+
|
|
717
850
|
function buildWorkerLaunchEnv({ processCwd, workspaceRoot, env = process.env } = {}) {
|
|
718
851
|
const launchProcessCwd = String(processCwd || '').trim();
|
|
719
852
|
const launchWorkspaceRoot = String(workspaceRoot || '').trim() || launchProcessCwd;
|
|
@@ -750,6 +883,9 @@ export async function launchSessionCli({
|
|
|
750
883
|
tmuxPollDelayMs = 200,
|
|
751
884
|
detachedPollAttempts = 10,
|
|
752
885
|
detachedPollDelayMs = 200,
|
|
886
|
+
// win32: how much longer a `start` still running after the polls gets to
|
|
887
|
+
// return before the launch counts as failed (a busy boot can be slow).
|
|
888
|
+
windowsStartGraceMs = 3_000,
|
|
753
889
|
allowProcessReuse = true,
|
|
754
890
|
createSecretEnvFileImpl = createWorkerSecretEnvFile,
|
|
755
891
|
// Extension engine: writes the session's OAR MCP config, returns its path
|
|
@@ -761,6 +897,9 @@ export async function launchSessionCli({
|
|
|
761
897
|
// Told when the process list could not be read (the error); the launch
|
|
762
898
|
// goes on without it.
|
|
763
899
|
onProcessListError = null,
|
|
900
|
+
// Where a launch that went wrong explains itself (one line per call).
|
|
901
|
+
log = null,
|
|
902
|
+
locateExecutableImpl = locateExecutable,
|
|
764
903
|
} = {}) {
|
|
765
904
|
const target = String(targetSessionId || '').trim();
|
|
766
905
|
if (!target) throw new Error('missing-target-session-id');
|
|
@@ -809,9 +948,13 @@ export async function launchSessionCli({
|
|
|
809
948
|
env,
|
|
810
949
|
});
|
|
811
950
|
const launchSessionEnv = {
|
|
812
|
-
...launchEnv,
|
|
951
|
+
...appendNodeDirectoryToPath(launchEnv, resolveWorkerNodeDirectory(launchEnv, { platform }), { platform }),
|
|
813
952
|
SESSION_ID: target,
|
|
814
953
|
};
|
|
954
|
+
const explain = (message) => {
|
|
955
|
+
try { log?.(`worker launcher: session=${target.slice(0, 8)} ${message}`); } catch {}
|
|
956
|
+
};
|
|
957
|
+
const spawnEnvironmentText = () => describeSpawnEnvironment(launchSessionEnv, platform);
|
|
815
958
|
// Set per launch below, never inherited: a relay started from inside a
|
|
816
959
|
// worker session would otherwise hand its workers that session's log.
|
|
817
960
|
delete launchSessionEnv[WORKER_LOG_FILE_ENV];
|
|
@@ -824,6 +967,17 @@ export async function launchSessionCli({
|
|
|
824
967
|
// session, so its prompt context need not teach the HTTP fallback.
|
|
825
968
|
if (extensionCliArgs.length) launchSessionEnv.OAR_MCP_SERVER_ATTACHED = '1';
|
|
826
969
|
|
|
970
|
+
// A Node that is not there fails loudly here instead of quietly later (on
|
|
971
|
+
// Windows, `start` shows its error in a dialog no one may be able to see).
|
|
972
|
+
if (resolveNodeWorkerDescriptor(launchSessionEnv)) {
|
|
973
|
+
const nodeExecutable = resolveWorkerNodeExecutable(launchSessionEnv);
|
|
974
|
+
if (!locateExecutableImpl(nodeExecutable, { env: launchSessionEnv, platform })) {
|
|
975
|
+
const overridden = Boolean(normalizeText(launchSessionEnv.COPILOT_WEB_RELAY_NODE));
|
|
976
|
+
explain(`cannot start: Node "${nodeExecutable}"${overridden ? ' (COPILOT_WEB_RELAY_NODE)' : ''} was not found; ${spawnEnvironmentText()}`);
|
|
977
|
+
throw new Error(`worker-node-not-found: ${nodeExecutable}${overridden ? ' (set by COPILOT_WEB_RELAY_NODE)' : ''}`);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
827
981
|
if (isTmuxAvailable({ platform, execFileSyncImpl })) {
|
|
828
982
|
const sessionName = normalizeTmuxSessionName(target);
|
|
829
983
|
const tmuxEnv = { ...launchSessionEnv };
|
|
@@ -897,14 +1051,20 @@ export async function launchSessionCli({
|
|
|
897
1051
|
}
|
|
898
1052
|
|
|
899
1053
|
const nodeWorker = resolveNodeWorkerDescriptor(launchSessionEnv);
|
|
900
|
-
const nodeWorkerExecutable =
|
|
1054
|
+
const nodeWorkerExecutable = resolveWorkerNodeExecutable(launchSessionEnv);
|
|
901
1055
|
const posixCliExecutable = normalizeText(launchSessionEnv.COPILOT_WEB_RELAY_CLI_EXECUTABLE)
|
|
902
1056
|
|| normalizeText(launchSessionEnv.COPILOT_CLI_EXECUTABLE)
|
|
903
1057
|
|| normalizeText(launchSessionEnv.COPILOT_CLI_PATH)
|
|
904
1058
|
|| 'copilot';
|
|
1059
|
+
// win32: Node quotes every argument that holds a space or a tab, so
|
|
1060
|
+
// `start "<title>" "C:\Program Files\nodejs\node.exe" ...` reaches cmd.exe
|
|
1061
|
+
// with the title and the path each in one piece; `/s` strips nothing here
|
|
1062
|
+
// because the line after `/c` starts with `start`, not a quote. The title
|
|
1063
|
+
// always holds a space, so `start` never mistakes the program for it.
|
|
905
1064
|
const spawnCommand = platform === 'win32'
|
|
906
|
-
? (
|
|
1065
|
+
? windowsCmdPath({ env: launchSessionEnv })
|
|
907
1066
|
: (nodeWorker ? nodeWorkerExecutable : posixCliExecutable);
|
|
1067
|
+
const startedProgram = nodeWorker ? nodeWorkerExecutable : (platform === 'win32' ? 'gh' : posixCliExecutable);
|
|
908
1068
|
const spawnArgs = platform === 'win32'
|
|
909
1069
|
? (nodeWorker
|
|
910
1070
|
? [
|
|
@@ -964,6 +1124,7 @@ export async function launchSessionCli({
|
|
|
964
1124
|
stdio: detachedStdio,
|
|
965
1125
|
windowsHide: platform !== 'win32',
|
|
966
1126
|
});
|
|
1127
|
+
const childState = watchChildExit(child);
|
|
967
1128
|
child.unref?.();
|
|
968
1129
|
if (Array.isArray(detachedStdio) && typeof detachedStdio[1] === 'number') {
|
|
969
1130
|
// The child inherited the descriptor; the parent's copy must not leak.
|
|
@@ -984,7 +1145,24 @@ export async function launchSessionCli({
|
|
|
984
1145
|
child,
|
|
985
1146
|
};
|
|
986
1147
|
}
|
|
1148
|
+
if (childState.error || (childState.exited && childState.code !== 0)) break;
|
|
1149
|
+
}
|
|
1150
|
+
const graceUntil = Date.now() + Math.max(0, Number(windowsStartGraceMs) || 0);
|
|
1151
|
+
while (childState.tracked && !childState.exited && !childState.error && Date.now() < graceUntil) {
|
|
1152
|
+
await sleep(50);
|
|
1153
|
+
}
|
|
1154
|
+
// `cmd /c start` returns at once when the program started. Still running,
|
|
1155
|
+
// or ended with an error, means it did not: name the program, and log the
|
|
1156
|
+
// command and the PATH it was looked up on.
|
|
1157
|
+
const failure = describeWindowsStartFailure(childState, { spawnCommand, program: startedProgram });
|
|
1158
|
+
if (failure) {
|
|
1159
|
+
if (childState.tracked && !childState.exited) {
|
|
1160
|
+
try { child.kill?.(); } catch {}
|
|
1161
|
+
}
|
|
1162
|
+
explain(`${failure}; command: ${spawnCommand} ${spawnArgs.join(' ')}; ${spawnEnvironmentText()}`);
|
|
1163
|
+
throw new Error(failure);
|
|
987
1164
|
}
|
|
1165
|
+
explain(`no worker process seen after ${attempts} check${attempts === 1 ? '' : 's'}; it may still report in. program: ${startedProgram}; ${spawnEnvironmentText()}`);
|
|
988
1166
|
return {
|
|
989
1167
|
pid: null,
|
|
990
1168
|
reused: false,
|
|
@@ -994,7 +1172,13 @@ export async function launchSessionCli({
|
|
|
994
1172
|
};
|
|
995
1173
|
}
|
|
996
1174
|
const pid = parsePositiveInt(child?.pid);
|
|
997
|
-
if (!pid)
|
|
1175
|
+
if (!pid) {
|
|
1176
|
+
// A failed spawn reports its error on the next tick.
|
|
1177
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
1178
|
+
const reason = childState.error?.code || childState.error?.message || '';
|
|
1179
|
+
explain(`could not start ${spawnCommand}${reason ? ` (${reason})` : ''}; ${spawnEnvironmentText()}`);
|
|
1180
|
+
throw new Error(`worker-spawn-unhealthy:missing-pid${reason ? ` (${spawnCommand}: ${reason})` : ''}`);
|
|
1181
|
+
}
|
|
998
1182
|
return {
|
|
999
1183
|
pid,
|
|
1000
1184
|
reused: false,
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { execFile, execFileSync } from 'child_process';
|
|
4
4
|
|
|
5
5
|
import { collectProcessDescendants, isChildProcessOf } from '../../shared/worker-runtime/process-tree.mjs';
|
|
6
|
+
import { windowsSystemToolPath } from '../../shared/windows-system-tools.mjs';
|
|
6
7
|
|
|
7
8
|
function normalizeSessionId(value) {
|
|
8
9
|
const text = String(value || '').trim();
|
|
@@ -319,8 +320,9 @@ export function createSessionWorkerProcessInspector({
|
|
|
319
320
|
|
|
320
321
|
function getWindowsProcessSnapshot() {
|
|
321
322
|
if (platform !== 'win32') return [];
|
|
322
|
-
const output = execFileSyncImpl('powershell.exe', ['-NoProfile', '-Command', WINDOWS_PROCESS_SNAPSHOT_SCRIPT], {
|
|
323
|
-
|
|
323
|
+
const output = execFileSyncImpl(windowsSystemToolPath('powershell.exe'), ['-NoProfile', '-Command', WINDOWS_PROCESS_SNAPSHOT_SCRIPT], {
|
|
324
|
+
// stderr is kept (not shown): a failed read reports it.
|
|
325
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
324
326
|
windowsHide: true,
|
|
325
327
|
maxBuffer: WINDOWS_PROCESS_SNAPSHOT_MAX_BUFFER,
|
|
326
328
|
});
|
|
@@ -374,11 +376,13 @@ export function createSessionWorkerProcessInspector({
|
|
|
374
376
|
if (platform !== 'win32') return Promise.resolve([]);
|
|
375
377
|
return new Promise((resolve, reject) => {
|
|
376
378
|
execFileImpl(
|
|
377
|
-
'powershell.exe',
|
|
379
|
+
windowsSystemToolPath('powershell.exe'),
|
|
378
380
|
['-NoProfile', '-Command', WINDOWS_PROCESS_SNAPSHOT_SCRIPT],
|
|
379
381
|
{ windowsHide: true, timeout: WINDOWS_PROCESS_SNAPSHOT_TIMEOUT_MS, maxBuffer: WINDOWS_PROCESS_SNAPSHOT_MAX_BUFFER },
|
|
380
|
-
(error, stdout) => {
|
|
382
|
+
(error, stdout, stderr) => {
|
|
381
383
|
if (error) {
|
|
384
|
+
// A failed read reports what PowerShell said.
|
|
385
|
+
if (stderr && error.stderr === undefined) error.stderr = stderr;
|
|
382
386
|
reject(error);
|
|
383
387
|
return;
|
|
384
388
|
}
|
|
@@ -541,7 +545,7 @@ export function createSessionWorkerProcessInspector({
|
|
|
541
545
|
function stopWindowsPids(pids) {
|
|
542
546
|
const ids = uniquePids(pids);
|
|
543
547
|
if (!ids.length) return [];
|
|
544
|
-
execFileSyncImpl('powershell.exe', ['-NoProfile', '-Command', buildStopWindowsPidsScript(ids)], {
|
|
548
|
+
execFileSyncImpl(windowsSystemToolPath('powershell.exe'), ['-NoProfile', '-Command', buildStopWindowsPidsScript(ids)], {
|
|
545
549
|
stdio: ['ignore', 'ignore', 'ignore'],
|
|
546
550
|
});
|
|
547
551
|
return ids;
|
|
@@ -553,7 +557,7 @@ export function createSessionWorkerProcessInspector({
|
|
|
553
557
|
if (!ids.length) return Promise.resolve([]);
|
|
554
558
|
return new Promise((resolve, reject) => {
|
|
555
559
|
execFileImpl(
|
|
556
|
-
'powershell.exe',
|
|
560
|
+
windowsSystemToolPath('powershell.exe'),
|
|
557
561
|
['-NoProfile', '-Command', buildStopWindowsPidsScript(ids)],
|
|
558
562
|
{ windowsHide: true, timeout: WINDOWS_STOP_TIMEOUT_MS, maxBuffer: WINDOWS_PROCESS_SNAPSHOT_MAX_BUFFER },
|
|
559
563
|
(error) => {
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import { createHmac } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
// What counts as an open of a shared conversation.
|
|
6
|
+
//
|
|
7
|
+
// The shared page fetches GET /api/shared/:token every 900 ms while it is in
|
|
8
|
+
// the foreground, so a fetch is not an open. Two rules decide:
|
|
9
|
+
//
|
|
10
|
+
// - The page's first fetch after it loads carries `open=1`. That fetch is an
|
|
11
|
+
// open, unless the same viewer opened the same share in the last
|
|
12
|
+
// RELOAD_DEDUPE_MS (a double load, a reload storm).
|
|
13
|
+
// - Any other fetch is an open only when the viewer has not fetched the share
|
|
14
|
+
// for IDLE_MS: a page that slept in the background and came back, or a
|
|
15
|
+
// client that never sends the flag (a script reading the API). Nobody reads
|
|
16
|
+
// the share unlogged by leaving the flag out.
|
|
17
|
+
//
|
|
18
|
+
// A viewer is the share's lineage plus a keyed hash of address and user agent
|
|
19
|
+
// (`viewerKey`), so the rules and the "distinct viewers" count work whether or
|
|
20
|
+
// not the address is stored. When the address is unknown (no socket address),
|
|
21
|
+
// every fetch of that viewer looks alike, which only merges viewers.
|
|
22
|
+
//
|
|
23
|
+
// Last-seen times live in memory. After a relay restart the last logged open
|
|
24
|
+
// stands in for them, so a reader polling across the restart counts once more
|
|
25
|
+
// only when their last open is older than IDLE_MS.
|
|
26
|
+
|
|
27
|
+
export const SHARE_OPEN_IDLE_MS = 30 * 60_000;
|
|
28
|
+
export const SHARE_OPEN_RELOAD_DEDUPE_MS = 10_000;
|
|
29
|
+
const DEFAULT_MAX_TRACKED_VIEWERS = 4_096;
|
|
30
|
+
const USER_AGENT_MAX = 512;
|
|
31
|
+
|
|
32
|
+
function cleanText(value, max) {
|
|
33
|
+
const text = String(value ?? '').replace(/[\u0000-\u001f\u007f]+/g, ' ').trim();
|
|
34
|
+
return text ? text.slice(0, max) : '';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `::ffff:203.0.113.7` is the IPv4 address it wraps. */
|
|
38
|
+
export function normalizeViewerIp(value) {
|
|
39
|
+
const ip = String(value || '').trim();
|
|
40
|
+
if (!ip) return '';
|
|
41
|
+
const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(ip);
|
|
42
|
+
return (mapped ? mapped[1] : ip).slice(0, 128);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The viewer of a public share request: the address as Express derives it
|
|
47
|
+
* through the app's `trust proxy` setting (a forwarded header from an
|
|
48
|
+
* untrusted peer is ignored), the user agent, and the edge's country, read
|
|
49
|
+
* only when the request came through a trusted proxy.
|
|
50
|
+
*/
|
|
51
|
+
export function readShareRequestViewer(req) {
|
|
52
|
+
const ip = normalizeViewerIp(req?.ip || req?.socket?.remoteAddress || '');
|
|
53
|
+
const userAgent = cleanText(req?.headers?.['user-agent'], USER_AGENT_MAX);
|
|
54
|
+
let country = '';
|
|
55
|
+
const trust = typeof req?.app?.get === 'function' ? req.app.get('trust proxy fn') : null;
|
|
56
|
+
const peer = String(req?.socket?.remoteAddress || '').trim();
|
|
57
|
+
if (typeof trust === 'function' && peer && trust(peer, 0)) {
|
|
58
|
+
const header = String(req?.headers?.['cf-ipcountry'] || '').trim().toUpperCase();
|
|
59
|
+
if (/^[A-Z0-9]{2}$/.test(header)) country = header;
|
|
60
|
+
}
|
|
61
|
+
return { ip, userAgent, country };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function createShareAccessLog({
|
|
65
|
+
repository,
|
|
66
|
+
now = () => Date.now(),
|
|
67
|
+
idleMs = SHARE_OPEN_IDLE_MS,
|
|
68
|
+
reloadDedupeMs = SHARE_OPEN_RELOAD_DEDUPE_MS,
|
|
69
|
+
maxTrackedViewers = DEFAULT_MAX_TRACKED_VIEWERS,
|
|
70
|
+
} = {}) {
|
|
71
|
+
// `${lineageId}:${viewerKey}` -> { seenAt, openedAt } (epoch ms)
|
|
72
|
+
const viewers = new Map();
|
|
73
|
+
|
|
74
|
+
function viewerKeyFor(ip, userAgent) {
|
|
75
|
+
return createHmac('sha256', repository.readViewerKeySecret())
|
|
76
|
+
.update(`${ip || ''}\n${userAgent || ''}`)
|
|
77
|
+
.digest('hex')
|
|
78
|
+
.slice(0, 32);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function prune(at) {
|
|
82
|
+
for (const [key, entry] of viewers) {
|
|
83
|
+
if (at - entry.seenAt > idleMs) viewers.delete(key);
|
|
84
|
+
}
|
|
85
|
+
if (viewers.size <= maxTrackedViewers) return;
|
|
86
|
+
const oldest = [...viewers.entries()].sort((a, b) => a[1].seenAt - b[1].seenAt);
|
|
87
|
+
for (const [key] of oldest.slice(0, viewers.size - maxTrackedViewers)) viewers.delete(key);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Called for every successful read of a share. Logs an open when the rules
|
|
92
|
+
* above say so; `storeIp` false keeps the address out of the row.
|
|
93
|
+
*/
|
|
94
|
+
function noteFetch({ share, viewer, opened = false, storeIp = true }) {
|
|
95
|
+
const at = Number(now());
|
|
96
|
+
const viewerKey = viewerKeyFor(viewer?.ip, viewer?.userAgent);
|
|
97
|
+
const key = `${share.lineageId}:${viewerKey}`;
|
|
98
|
+
let entry = viewers.get(key);
|
|
99
|
+
if (!entry) {
|
|
100
|
+
const lastOpen = Date.parse(repository.lastOpenAt(share.lineageId, viewerKey) || '');
|
|
101
|
+
if (Number.isFinite(lastOpen)) entry = { seenAt: lastOpen, openedAt: lastOpen };
|
|
102
|
+
}
|
|
103
|
+
const isOpen = !entry
|
|
104
|
+
|| (opened ? at - entry.openedAt > reloadDedupeMs : at - entry.seenAt > idleMs);
|
|
105
|
+
let recorded = false;
|
|
106
|
+
if (isOpen) {
|
|
107
|
+
recorded = repository.recordOpen({
|
|
108
|
+
lineageId: share.lineageId,
|
|
109
|
+
conversationId: share.conversationId,
|
|
110
|
+
openedAt: new Date(at).toISOString(),
|
|
111
|
+
ip: storeIp ? viewer?.ip || null : null,
|
|
112
|
+
userAgent: viewer?.userAgent || null,
|
|
113
|
+
country: viewer?.country || null,
|
|
114
|
+
viewerKey,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
viewers.set(key, { seenAt: at, openedAt: isOpen ? at : entry.openedAt });
|
|
118
|
+
prune(at);
|
|
119
|
+
return { opened: isOpen, recorded, viewerKey };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { noteFetch, viewerKeyFor };
|
|
123
|
+
}
|