@oar-sh/oar 0.9.13 → 0.9.14
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/.github/extensions/web-relay/extension.mjs +9 -0
- package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +15 -1
- package/.github/extensions/web-relay/skills/question-bridge.mjs +4 -1
- package/CHANGELOG.md +302 -0
- package/README.md +12 -12
- package/bin/oar.js +155 -94
- package/package.json +5 -5
- package/server/README.md +296 -149
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
- package/server/claude-worker/claude-session-process.mjs +39 -13
- package/server/claude-worker/claude-turn-publisher.mjs +20 -1
- package/server/claude-worker/subagent-timeline-recorder.mjs +75 -8
- package/server/copilot-worker/copilot-ask-user-tool.mjs +1 -1
- package/server/copilot-worker/copilot-prompt-context.mjs +3 -1
- package/server/copilot-worker/copilot-question-bridge.mjs +11 -6
- package/server/copilot-worker/copilot-sdk-adapter.mjs +11 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +14 -1
- package/server/cursor-worker/cursor-ask-user-tool.mjs +1 -1
- package/server/cursor-worker/cursor-sdk-adapter.mjs +22 -7
- package/server/cursor-worker/cursor-session-worker.mjs +9 -2
- package/server/cursor-worker/cursor-turn-runner.mjs +18 -3
- package/server/grok-worker/grok-sdk-adapter.mjs +14 -0
- package/server/grok-worker/grok-turn-runner.mjs +13 -3
- package/server/public/app/api-client.js +3 -1
- package/server/public/app/ask-user-view.js +29 -4
- package/server/public/app/conversation-provider-indicator.mjs +18 -8
- package/server/public/app/question-answer-rule.mjs +15 -0
- package/server/public/app/question-multi-select.mjs +9 -2
- package/server/public/app/relay-event-view.mjs +5 -1
- package/server/public/index.html +9 -5
- package/server/relay-exit-codes.mjs +6 -0
- package/server/relay-self-restart.mjs +6 -1
- package/server/relay-tools.md +14 -11
- package/server/repositories/message-repository.mjs +29 -28
- package/server/repositories/subagent-timeline-statements.mjs +2 -0
- package/server/routes/ask-user-routes.mjs +41 -7
- package/server/routes/messages-routes.mjs +65 -10
- package/server/routes/preview-routes.mjs +73 -7
- package/server/routes/sessions-routes.mjs +4 -2
- package/server/server-runtime.mjs +156 -94
- package/server/services/cursor-session-root-service.mjs +70 -6
- package/server/services/data-dir-resolution.mjs +208 -19
- package/server/services/earlier-database-service.mjs +631 -0
- package/server/services/oar-cli-helpers.mjs +6 -1
- package/server/services/oar-state-layout.mjs +107 -0
- package/server/services/oar-state-migration-service.mjs +387 -96
- package/server/services/package-data-rescue-preinstall.mjs +22 -0
- package/server/services/package-data-rescue.mjs +137 -0
- package/server/services/preview-registry-service.mjs +3 -2
- package/server/services/push-dispatch-service.mjs +33 -6
- package/server/services/relay-event-service.mjs +183 -5
- package/server/services/relay-restart-queue.mjs +10 -3
- package/server/services/relay-singleton-guard.mjs +300 -36
- package/server/services/remote-relay-caller-context.mjs +7 -2
- package/server/services/remote-relay-dispatcher.mjs +30 -9
- package/server/services/session-activity-tracker.mjs +52 -15
- package/server/services/session-worker-launch-service.mjs +31 -1
- package/server/services/stale-turn-recovery.mjs +99 -0
- package/server/services/subagent-timeline-service.mjs +4 -1
- package/server/services/windows-autostart-service.mjs +329 -31
- package/server/services/windows-boot-autostart-service.mjs +188 -60
- package/shared/preview-instructions.mjs +17 -12
- package/shared/preview-tool-core.mjs +32 -14
- package/shared/question-answer-rule.mjs +63 -0
- package/shared/relay-events.mjs +54 -4
- package/shared/remote-relay-contract.mjs +20 -10
- package/shared/subagent-timeline.mjs +4 -0
- package/shared/windows-system-tools.mjs +25 -0
- package/shared/worker-runtime/config-loader.mjs +54 -8
- package/shared/worker-runtime/prompt-context.mjs +3 -1
- package/shared/worker-runtime/working-directory.mjs +65 -0
- package/server/uploads/image_input.md +0 -540
|
@@ -14,6 +14,7 @@ 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 { missingWorkingDirectoryError } from '../../shared/worker-runtime/working-directory.mjs';
|
|
17
18
|
import {
|
|
18
19
|
describeSpawnEnvironment,
|
|
19
20
|
envPathKey,
|
|
@@ -213,6 +214,9 @@ export function applyCursorProviderEnvironment(env = {}, {
|
|
|
213
214
|
enabled = false,
|
|
214
215
|
model = '',
|
|
215
216
|
apiKey = '',
|
|
217
|
+
// The relay's resolved, absolute data dir: the worker keeps its agent stores
|
|
218
|
+
// there, and must not resolve a relative one or the config's own again.
|
|
219
|
+
dataDir = '',
|
|
216
220
|
} = {}) {
|
|
217
221
|
const next = { ...env };
|
|
218
222
|
if (resolveWorkerKind(next) === 'cursor') delete next.COPILOT_WEB_RELAY_WORKER_KIND;
|
|
@@ -225,6 +229,8 @@ export function applyCursorProviderEnvironment(env = {}, {
|
|
|
225
229
|
next.CURSOR_API_KEY = normalizedApiKey;
|
|
226
230
|
const normalizedModel = normalizeText(model);
|
|
227
231
|
if (normalizedModel) next.CURSOR_RELAY_MODEL = normalizedModel;
|
|
232
|
+
const normalizedDataDir = normalizeText(dataDir);
|
|
233
|
+
if (normalizedDataDir) next.COPILOT_WEB_RELAY_DATA_DIR = path.resolve(normalizedDataDir);
|
|
228
234
|
return next;
|
|
229
235
|
}
|
|
230
236
|
|
|
@@ -717,6 +723,9 @@ export function buildTmuxWorkerShellCommand(targetSessionId, env = {}, {
|
|
|
717
723
|
'COPILOT_WEB_RELAY_CONFIG',
|
|
718
724
|
'COPILOT_WEB_RELAY_TOOLS',
|
|
719
725
|
'COPILOT_WEB_RELAY_LOG_DIR',
|
|
726
|
+
// The Cursor worker keeps its agent stores in the relay's data dir
|
|
727
|
+
// (applyCursorProviderEnvironment).
|
|
728
|
+
'COPILOT_WEB_RELAY_DATA_DIR',
|
|
720
729
|
'COPILOT_WEB_RELAY_CLI_EXECUTABLE',
|
|
721
730
|
'COPILOT_WEB_RELAY_EXTENSION_BOOTSTRAP_PATH',
|
|
722
731
|
'COPILOT_SDK_PATH',
|
|
@@ -847,6 +856,19 @@ export function describeWindowsStartFailure(state, { spawnCommand = 'cmd.exe', p
|
|
|
847
856
|
return null;
|
|
848
857
|
}
|
|
849
858
|
|
|
859
|
+
/**
|
|
860
|
+
* A spawn whose folder is gone fails with ENOENT (or ENOTDIR), which reads as
|
|
861
|
+
* a missing program (`node`, `cmd.exe`). Asked only after a spawn failed: the folder
|
|
862
|
+
* is named instead, or null when it is there. Exported for the tests.
|
|
863
|
+
* (A tmux launch never gets here: `new-session -c` falls back to the home
|
|
864
|
+
* folder, and the worker reports the folder on the turn.)
|
|
865
|
+
*/
|
|
866
|
+
export function describeLaunchFolderFailure(state, folder, { check = missingWorkingDirectoryError } = {}) {
|
|
867
|
+
if (state?.error?.code !== 'ENOENT' && state?.error?.code !== 'ENOTDIR') return null;
|
|
868
|
+
const missing = check(folder);
|
|
869
|
+
return missing ? `worker-spawn-failed: ${missing.message}` : null;
|
|
870
|
+
}
|
|
871
|
+
|
|
850
872
|
function buildWorkerLaunchEnv({ processCwd, workspaceRoot, env = process.env } = {}) {
|
|
851
873
|
const launchProcessCwd = String(processCwd || '').trim();
|
|
852
874
|
const launchWorkspaceRoot = String(workspaceRoot || '').trim() || launchProcessCwd;
|
|
@@ -900,6 +922,8 @@ export async function launchSessionCli({
|
|
|
900
922
|
// Where a launch that went wrong explains itself (one line per call).
|
|
901
923
|
log = null,
|
|
902
924
|
locateExecutableImpl = locateExecutable,
|
|
925
|
+
// Asked after a failed spawn whether its folder is gone (the error, or null).
|
|
926
|
+
checkWorkingDirectoryImpl = missingWorkingDirectoryError,
|
|
903
927
|
} = {}) {
|
|
904
928
|
const target = String(targetSessionId || '').trim();
|
|
905
929
|
if (!target) throw new Error('missing-target-session-id');
|
|
@@ -1154,7 +1178,8 @@ export async function launchSessionCli({
|
|
|
1154
1178
|
// `cmd /c start` returns at once when the program started. Still running,
|
|
1155
1179
|
// or ended with an error, means it did not: name the program, and log the
|
|
1156
1180
|
// command and the PATH it was looked up on.
|
|
1157
|
-
const failure =
|
|
1181
|
+
const failure = describeLaunchFolderFailure(childState, launchProcessCwd, { check: checkWorkingDirectoryImpl })
|
|
1182
|
+
|| describeWindowsStartFailure(childState, { spawnCommand, program: startedProgram });
|
|
1158
1183
|
if (failure) {
|
|
1159
1184
|
if (childState.tracked && !childState.exited) {
|
|
1160
1185
|
try { child.kill?.(); } catch {}
|
|
@@ -1176,6 +1201,11 @@ export async function launchSessionCli({
|
|
|
1176
1201
|
// A failed spawn reports its error on the next tick.
|
|
1177
1202
|
await new Promise((resolve) => setImmediate(resolve));
|
|
1178
1203
|
const reason = childState.error?.code || childState.error?.message || '';
|
|
1204
|
+
const folderFailure = describeLaunchFolderFailure(childState, launchProcessCwd, { check: checkWorkingDirectoryImpl });
|
|
1205
|
+
if (folderFailure) {
|
|
1206
|
+
explain(`could not start ${spawnCommand}: ${folderFailure}`);
|
|
1207
|
+
throw new Error(folderFailure);
|
|
1208
|
+
}
|
|
1179
1209
|
explain(`could not start ${spawnCommand}${reason ? ` (${reason})` : ''}; ${spawnEnvironmentText()}`);
|
|
1180
1210
|
throw new Error(`worker-spawn-unhealthy:missing-pid${reason ? ` (${spawnCommand}: ${reason})` : ''}`);
|
|
1181
1211
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The stale-turn sweep: a `processing` row whose worker went silent, or whose
|
|
4
|
+
// turn ran past the max turn duration, goes back to the queue. Which rows
|
|
5
|
+
// qualify is decided by the repository (listRecoverableProcessing); this
|
|
6
|
+
// module decides what happens to them: continuations are torn down, consumed
|
|
7
|
+
// rows fail terminally, rows out of retry budget fail, the rest are requeued
|
|
8
|
+
// and announced.
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `failRecoveredRowTerminally(row, reason)`, `failConsumedRowTerminally(row,
|
|
12
|
+
* reason)` and `noteRestartTurnOutcome(row, outcome)` are the runtime's own;
|
|
13
|
+
* `emit(event, payload)` is the socket broadcast.
|
|
14
|
+
*/
|
|
15
|
+
export function createStaleTurnRecovery({
|
|
16
|
+
db,
|
|
17
|
+
stmts,
|
|
18
|
+
emit,
|
|
19
|
+
maxRequeueRetries,
|
|
20
|
+
failRecoveredRowTerminally,
|
|
21
|
+
failConsumedRowTerminally,
|
|
22
|
+
noteRestartTurnOutcome,
|
|
23
|
+
warn = (line) => console.warn(line),
|
|
24
|
+
} = {}) {
|
|
25
|
+
if (!db) throw new Error('createStaleTurnRecovery requires db');
|
|
26
|
+
if (!stmts) throw new Error('createStaleTurnRecovery requires stmts');
|
|
27
|
+
if (typeof emit !== 'function') throw new Error('createStaleTurnRecovery requires emit');
|
|
28
|
+
|
|
29
|
+
// A Stop still pending for a requeued turn is settled: the attempt it was
|
|
30
|
+
// aimed at is gone.
|
|
31
|
+
const settleRecoveredAbortControls = db.prepare(`
|
|
32
|
+
UPDATE relay_control_requests
|
|
33
|
+
SET status = 'failed',
|
|
34
|
+
error = ?,
|
|
35
|
+
updated_at = ?,
|
|
36
|
+
completed_at = ?
|
|
37
|
+
WHERE queue_message_id = ?
|
|
38
|
+
AND type IN ('abort_turn', 'abort_subagent')
|
|
39
|
+
AND status IN ('pending', 'processing')
|
|
40
|
+
`);
|
|
41
|
+
|
|
42
|
+
function recoverProcessingOlderThan(cutoffIso, requeueAtIso, { ceilingBeforeIso = null } = {}) {
|
|
43
|
+
const params = { inactiveBefore: cutoffIso, ceilingBefore: ceilingBeforeIso || null };
|
|
44
|
+
// Stale background-continuation turns are torn down, never requeued —
|
|
45
|
+
// replaying one would deliver the CLI's own continuation as a user prompt.
|
|
46
|
+
const staleContinuations = stmts.listStaleProcessingContinuations?.all?.(params) || [];
|
|
47
|
+
for (const row of staleContinuations) {
|
|
48
|
+
stmts.dropStaleContinuation?.run?.(row.id);
|
|
49
|
+
emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'failed' });
|
|
50
|
+
noteRestartTurnOutcome(row, 'cut');
|
|
51
|
+
// A teardown is a turn ending with no reply; it must be visible in the
|
|
52
|
+
// console log (the only trace of a silent one was `ignored=already_failed`
|
|
53
|
+
// hours later, when the worker's reply arrived for the dead row).
|
|
54
|
+
const lastSeen = row.owner_last_claimed_at || row.processing_at || row.timestamp || null;
|
|
55
|
+
const reason = lastSeen && lastSeen < cutoffIso ? `inactive since ${lastSeen}` : `ceiling (started ${row.processing_at || row.timestamp})`;
|
|
56
|
+
warn(`STALE CONTINUATION ${String(row.id).slice(0, 8)} conv=${String(row.conversation_id).slice(0, 8)} dropped: ${reason}`);
|
|
57
|
+
}
|
|
58
|
+
const listedRows = stmts.listRecoverableProcessing.all(params);
|
|
59
|
+
for (const row of listedRows.filter((entry) => entry?.consumed_at)) {
|
|
60
|
+
failConsumedRowTerminally(row, 'stale-recovery');
|
|
61
|
+
noteRestartTurnOutcome(row, 'cut');
|
|
62
|
+
}
|
|
63
|
+
const allRows = listedRows.filter((entry) => !entry?.consumed_at);
|
|
64
|
+
if (!allRows.length) return [];
|
|
65
|
+
// Enforce the retry budget before requeueing: failing these rows first
|
|
66
|
+
// flips them out of 'processing' so the recovery UPDATE below skips them.
|
|
67
|
+
const overBudget = allRows.filter((row) => Number(row?.retry_count || 0) + 1 >= maxRequeueRetries);
|
|
68
|
+
for (const row of overBudget) {
|
|
69
|
+
failRecoveredRowTerminally(row, 'stale-recovery');
|
|
70
|
+
noteRestartTurnOutcome(row, 'failed');
|
|
71
|
+
}
|
|
72
|
+
const dueRows = allRows.filter((row) => Number(row?.retry_count || 0) + 1 < maxRequeueRetries);
|
|
73
|
+
if (!dueRows.length) return [];
|
|
74
|
+
|
|
75
|
+
// Requeue exactly the listed rows, and announce only the ones the
|
|
76
|
+
// UPDATE took (its re-check of the rule is a safeguard; nothing runs
|
|
77
|
+
// between the listing and here).
|
|
78
|
+
const rows = [];
|
|
79
|
+
const tx = db.transaction(() => {
|
|
80
|
+
const now = new Date().toISOString();
|
|
81
|
+
for (const row of dueRows) {
|
|
82
|
+
if (stmts.recoverProcessingBefore.run({ ...params, id: row.id, requeueAt: requeueAtIso }).changes === 0) continue;
|
|
83
|
+
settleRecoveredAbortControls.run('queue-recovered', now, now, row.id);
|
|
84
|
+
rows.push(row);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
tx();
|
|
88
|
+
if (!rows.length) return [];
|
|
89
|
+
|
|
90
|
+
for (const row of rows) {
|
|
91
|
+
emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'pending' });
|
|
92
|
+
noteRestartTurnOutcome(row, 'requeued');
|
|
93
|
+
}
|
|
94
|
+
emit('queue_updated', { recovered: rows.length });
|
|
95
|
+
return rows;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { recoverProcessingOlderThan };
|
|
99
|
+
}
|
|
@@ -140,7 +140,10 @@ export function createSubagentTimelineService({ db, stmts, now = () => new Date(
|
|
|
140
140
|
if (hasFields && headerSupported()) {
|
|
141
141
|
stmts.updateSubagentRunHeader.run({ id: runId, updatedAt: at, ...fields });
|
|
142
142
|
}
|
|
143
|
-
if (status) {
|
|
143
|
+
if (status === 'running' && typeof stmts.reopenSubagentRun?.run === 'function') {
|
|
144
|
+
// Running (again, for a resumed agent): the end of an earlier run goes.
|
|
145
|
+
stmts.reopenSubagentRun.run(at, runId);
|
|
146
|
+
} else if (status) {
|
|
144
147
|
const terminal = status === 'completed' || status === 'failed' || status === 'cancelled';
|
|
145
148
|
stmts.updateSubagentRunStatus?.run?.(status, at, terminal ? at : null, runId);
|
|
146
149
|
}
|
|
@@ -2,12 +2,18 @@ import fs from 'fs';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { randomUUID } from 'crypto';
|
|
4
4
|
import { resolveOarRoot } from './oar-state-migration-service.mjs';
|
|
5
|
+
import { resolveLayoutStateDirs, resolveRelayStateDirs } from './oar-state-layout.mjs';
|
|
5
6
|
|
|
6
7
|
export const WINDOWS_AUTOSTART_FILENAME = 'oar-web-relay.cmd';
|
|
7
8
|
// The pre-rebrand name. Never written anymore, but still recognized (an install
|
|
8
9
|
// that enabled autostart before the OAR rename has this file) and cleaned up on
|
|
9
10
|
// every state change so the two names can never both launch a relay.
|
|
10
11
|
export const LEGACY_WINDOWS_AUTOSTART_FILENAME = 'copilot-remote-web-relay.cmd';
|
|
12
|
+
// The launchers' wait, in all, for %TEMP% and the data drive (autostartWaitLines).
|
|
13
|
+
export const AUTOSTART_WAIT_SECONDS = 300;
|
|
14
|
+
// The sign-in entry's wait log, in the OAR state root: Windows opens every
|
|
15
|
+
// file in the Startup folder at sign-in.
|
|
16
|
+
export const WINDOWS_AUTOSTART_LOG_FILENAME = 'oar-web-relay.log';
|
|
11
17
|
|
|
12
18
|
export function escapeBatchValue(value) {
|
|
13
19
|
return String(value || '').replaceAll('%', '%%');
|
|
@@ -26,41 +32,280 @@ export function resolveWindowsStartupDirectory(env = process.env, pathImpl = pat
|
|
|
26
32
|
return pathImpl.join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup');
|
|
27
33
|
}
|
|
28
34
|
|
|
35
|
+
/** The `set` lines that pin the relay's data and log folders (shared with the boot launcher). */
|
|
36
|
+
export function stateDirLines({ dataDir = '', logDir = '', pathImpl = path } = {}) {
|
|
37
|
+
const lines = [];
|
|
38
|
+
if (String(dataDir || '').trim()) {
|
|
39
|
+
lines.push(`set "COPILOT_WEB_RELAY_DATA_DIR=${escapeBatchValue(pathImpl.resolve(String(dataDir).trim()))}"`);
|
|
40
|
+
}
|
|
41
|
+
if (String(logDir || '').trim()) {
|
|
42
|
+
lines.push(`set "COPILOT_WEB_RELAY_LOG_DIR=${escapeBatchValue(pathImpl.resolve(String(logDir).trim()))}"`);
|
|
43
|
+
}
|
|
44
|
+
return lines;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The data folder's drive root (`R:\`), '' for none or a UNC path (cmd's
|
|
49
|
+
* `if exist` does not tell a share root reliably). The root, not the folder:
|
|
50
|
+
* on a first run the relay creates the folder.
|
|
51
|
+
*/
|
|
52
|
+
export function dataDriveRoot(dataDir = '', pathImpl = path) {
|
|
53
|
+
const trimmed = String(dataDir || '').trim();
|
|
54
|
+
if (!trimmed) return '';
|
|
55
|
+
const { root } = pathImpl.parse(pathImpl.resolve(trimmed));
|
|
56
|
+
return /^[A-Za-z]:[\\/]$/.test(root) ? `${root.slice(0, 2).toUpperCase()}\\` : '';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Before Node starts: up to AUTOSTART_WAIT_SECONDS in all for %TEMP% and the
|
|
61
|
+
* data drive; when it runs out, one line naming what is missing goes to
|
|
62
|
+
* `logTarget` and Node starts anyway. A round is about a second: `ping -n 2`
|
|
63
|
+
* waits one between its echoes (by full path: an early boot's PATH may lack
|
|
64
|
+
* System32). TEMP is read once, into OAR_TEMP without quotes, only
|
|
65
|
+
* when set: `if not "%TEMP%"==""` breaks on `"D:\My Temp"`, an unset
|
|
66
|
+
* `%TEMP:"=%` expands to `"=`. `waitSeconds`: tests only.
|
|
67
|
+
*/
|
|
68
|
+
export function autostartWaitLines({ dataDir = '', logTarget, pathImpl = path, waitSeconds = AUTOSTART_WAIT_SECONDS } = {}) {
|
|
69
|
+
const drive = escapeBatchValue(dataDriveRoot(dataDir, pathImpl));
|
|
70
|
+
const missing = (temp, data) => [temp && 'TEMP folder "%OAR_TEMP%"', data && `data drive "${drive}"`].filter(Boolean).join(' and ');
|
|
71
|
+
const logLine = (text) => `echo [%DATE% %TIME%] Still missing after ${waitSeconds} s: ${text}; starting the relay anyway.>>${logTarget}`;
|
|
72
|
+
const lines = [
|
|
73
|
+
'set "OAR_TEMP="',
|
|
74
|
+
'if not defined TEMP goto oar_temp_set',
|
|
75
|
+
'set "OAR_TEMP=%TEMP:"=%"',
|
|
76
|
+
':oar_temp_set',
|
|
77
|
+
'set /a OAR_WAIT=0',
|
|
78
|
+
':oar_wait',
|
|
79
|
+
'set "OAR_WAIT_TEMP="',
|
|
80
|
+
'if defined OAR_TEMP if not exist "%OAR_TEMP%\\" set "OAR_WAIT_TEMP=1"',
|
|
81
|
+
];
|
|
82
|
+
if (drive) {
|
|
83
|
+
lines.push(
|
|
84
|
+
'set "OAR_WAIT_DATA="',
|
|
85
|
+
`if not exist "${drive}" set "OAR_WAIT_DATA=1"`,
|
|
86
|
+
'if not defined OAR_WAIT_TEMP if not defined OAR_WAIT_DATA goto oar_start',
|
|
87
|
+
);
|
|
88
|
+
} else {
|
|
89
|
+
lines.push('if not defined OAR_WAIT_TEMP goto oar_start');
|
|
90
|
+
}
|
|
91
|
+
lines.push(
|
|
92
|
+
`if %OAR_WAIT% geq ${waitSeconds} goto oar_wait_over`,
|
|
93
|
+
'"%SystemRoot%\\System32\\ping.exe" -n 2 127.0.0.1 >nul',
|
|
94
|
+
'set /a OAR_WAIT+=1',
|
|
95
|
+
'goto oar_wait',
|
|
96
|
+
':oar_wait_over',
|
|
97
|
+
);
|
|
98
|
+
// One line, whichever is missing: exactly one of these runs.
|
|
99
|
+
if (drive) {
|
|
100
|
+
lines.push(
|
|
101
|
+
`if defined OAR_WAIT_TEMP if defined OAR_WAIT_DATA ${logLine(missing(true, true))}`,
|
|
102
|
+
`if defined OAR_WAIT_TEMP if not defined OAR_WAIT_DATA ${logLine(missing(true, false))}`,
|
|
103
|
+
`if not defined OAR_WAIT_TEMP if defined OAR_WAIT_DATA ${logLine(missing(false, true))}`,
|
|
104
|
+
);
|
|
105
|
+
} else {
|
|
106
|
+
lines.push(logLine(missing(true, false)));
|
|
107
|
+
}
|
|
108
|
+
// The copy and the counters are not handed on to the relay.
|
|
109
|
+
lines.push(':oar_start', 'set "OAR_WAIT="', 'set "OAR_WAIT_TEMP="');
|
|
110
|
+
if (drive) lines.push('set "OAR_WAIT_DATA="');
|
|
111
|
+
lines.push('set "OAR_TEMP="');
|
|
112
|
+
return lines;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// How far past the end of the file it replaces the padding reaches, and the
|
|
116
|
+
// longest padding line (cmd.exe refuses lines over 8191 characters).
|
|
117
|
+
export const LAUNCHER_PADDING_MARGIN = 64;
|
|
118
|
+
// What the padding covers at least, whatever the file replaced: an entry
|
|
119
|
+
// written from scratch (autostart switched off and on) may still have the
|
|
120
|
+
// cmd of an earlier one running. Unpadded formats stay under 2 KB; a padded
|
|
121
|
+
// one's Node line ends it, so its cmd never reads on.
|
|
122
|
+
export const LAUNCHER_MIN_COVER_BYTES = 4096;
|
|
123
|
+
const LAUNCHER_PADDING_LINE_MAX = 8000;
|
|
124
|
+
// cmd.exe reads a batch file in the console's OEM code page; the file is
|
|
125
|
+
// UTF-8, so a path with non-ASCII characters (a user profile) would be
|
|
126
|
+
// garbled without this. By full path: an early boot's PATH may lack System32.
|
|
127
|
+
export const LAUNCHER_CODE_PAGE_LINE = '"%SystemRoot%\\System32\\chcp.com" 65001 >nul';
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Every launcher OAR writes has this shape, so it can be replaced while a
|
|
131
|
+
* cmd.exe still runs the one before it. cmd does not keep a batch file open:
|
|
132
|
+
* after each command it opens the file by name again and reads on from the
|
|
133
|
+
* byte offset it had reached, and the relay's cmd waits on the Node line for
|
|
134
|
+
* as long as the relay runs. Read on in a longer replacement, that offset
|
|
135
|
+
* would land on a line before the Node line and start the relay a second
|
|
136
|
+
* time.
|
|
137
|
+
*
|
|
138
|
+
* <header lines>
|
|
139
|
+
* goto oar_begin
|
|
140
|
+
* <spaces, up to past the end of the file this one replaces>
|
|
141
|
+
* exit /b
|
|
142
|
+
* :oar_begin
|
|
143
|
+
* ...
|
|
144
|
+
* "node.exe" "...server.js" & exit
|
|
145
|
+
*
|
|
146
|
+
* A fresh run jumps over the padding. A cmd still running the file replaced
|
|
147
|
+
* reads on at its offset, inside the padding, finds blanks and then
|
|
148
|
+
* `exit /b`. And since the Node line ends the script itself (cmd has read the
|
|
149
|
+
* whole line before Node starts), a cmd running this file never reads on in
|
|
150
|
+
* a later replacement at all. `coverBytes` is the size of the file being
|
|
151
|
+
* replaced (at least
|
|
152
|
+
* LAUNCHER_MIN_COVER_BYTES is covered).
|
|
153
|
+
*/
|
|
154
|
+
export function buildLauncherFile({ headerLines = ['@echo off'], bodyLines = [], coverBytes = 0 } = {}) {
|
|
155
|
+
const head = [...headerLines, 'goto oar_begin'];
|
|
156
|
+
const headBytes = Buffer.byteLength(head.map((line) => `${line}\r\n`).join(''), 'utf8');
|
|
157
|
+
let remaining = Math.max(
|
|
158
|
+
LAUNCHER_PADDING_MARGIN,
|
|
159
|
+
Math.max(Number(coverBytes) || 0, LAUNCHER_MIN_COVER_BYTES) + LAUNCHER_PADDING_MARGIN - headBytes,
|
|
160
|
+
);
|
|
161
|
+
const padding = [];
|
|
162
|
+
while (remaining > 0) {
|
|
163
|
+
const width = Math.min(remaining, LAUNCHER_PADDING_LINE_MAX);
|
|
164
|
+
padding.push(' '.repeat(width));
|
|
165
|
+
remaining -= width + 2;
|
|
166
|
+
}
|
|
167
|
+
return [...head, ...padding, 'exit /b', ':oar_begin', LAUNCHER_CODE_PAGE_LINE, ...bodyLines, ''].join('\r\n');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The line that starts the relay and ends the script (buildLauncherFile).
|
|
172
|
+
* `exit` hands on Node's exit code (`exit /b`, the earlier `ending`, gave
|
|
173
|
+
* `cmd /c` 0). It closes no one's console: Task Scheduler and the Startup
|
|
174
|
+
* folder each start a cmd of their own for a launcher.
|
|
175
|
+
*/
|
|
176
|
+
export function launcherNodeLine(nodePath, serverPath, { ending = 'exit' } = {}) {
|
|
177
|
+
return `"${escapeBatchValue(nodePath)}" "${escapeBatchValue(serverPath)}" & ${ending}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The Node a launcher runs `serverPath` with, read from its Node line (with or
|
|
182
|
+
* without the trailing `& exit` or `& exit /b`); null when it runs no such
|
|
183
|
+
* server.
|
|
184
|
+
*/
|
|
185
|
+
export function readLauncherNodePath(script, serverPath) {
|
|
186
|
+
const wanted = escapeBatchValue(serverPath).toLowerCase();
|
|
187
|
+
for (const line of String(script == null ? '' : script).split(/\r?\n/)) {
|
|
188
|
+
const match = line.trimEnd().match(/^"([^"]+)" "([^"]+)"(?: & exit(?: \/b)?)?$/);
|
|
189
|
+
if (match && match[2].toLowerCase() === wanted) return match[1].replaceAll('%%', '%');
|
|
190
|
+
}
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Same launcher, whatever the line endings, trailing blanks and the length
|
|
196
|
+
* of the padding. `ignoreCase` for telling whether a launcher is one an
|
|
197
|
+
* earlier version generated: Windows paths compare without case.
|
|
198
|
+
*/
|
|
199
|
+
export function launcherScriptsMatch(actual, expected, { ignoreCase = false } = {}) {
|
|
200
|
+
const normalize = (value) => {
|
|
201
|
+
const text = String(value == null ? '' : value)
|
|
202
|
+
.split(/\r?\n/)
|
|
203
|
+
.map((line) => line.replace(/\s+$/, ''))
|
|
204
|
+
.filter(Boolean)
|
|
205
|
+
.join('\n');
|
|
206
|
+
return ignoreCase ? text.toLowerCase() : text;
|
|
207
|
+
};
|
|
208
|
+
return normalize(actual) === normalize(expected);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function requireLauncherInputs({ packageRoot, nodePath }, what) {
|
|
212
|
+
if (!String(packageRoot || '').trim()) {
|
|
213
|
+
throw new Error(`${what} requires the copilot-remote package root.`);
|
|
214
|
+
}
|
|
215
|
+
if (!String(nodePath || '').trim()) {
|
|
216
|
+
throw new Error(`${what} requires the Node.js executable path.`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
29
220
|
export function buildWindowsAutostartScript({
|
|
30
221
|
packageRoot,
|
|
31
222
|
nodePath = process.execPath,
|
|
32
223
|
configPath = '',
|
|
224
|
+
// The data and log folders `oar start` gives the relay
|
|
225
|
+
// (resolveRelayStateDirs); without them the relay would keep its database
|
|
226
|
+
// inside the package directory, which every update replaces.
|
|
227
|
+
dataDir = '',
|
|
228
|
+
logDir = '',
|
|
33
229
|
// On Windows a process pins its cwd, and self-update must be able to
|
|
34
230
|
// replace the package directory — so the relay runs from the OAR state
|
|
35
231
|
// root, not from the code it executes.
|
|
36
232
|
workingDirectory = '',
|
|
233
|
+
// Size of the file this one replaces (see buildLauncherFile).
|
|
234
|
+
coverBytes = 0,
|
|
37
235
|
pathImpl = path,
|
|
38
236
|
} = {}) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
237
|
+
return buildLauncherFile({
|
|
238
|
+
bodyLines: signInBodyLines({
|
|
239
|
+
packageRoot, nodePath, configPath, dataDir, logDir, workingDirectory, pathImpl, wait: true,
|
|
240
|
+
}),
|
|
241
|
+
coverBytes,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// The entry's lines after the padding; `wait: false` is the earlier form.
|
|
246
|
+
function signInBodyLines({ packageRoot, nodePath, configPath, dataDir, logDir, workingDirectory, pathImpl, wait }) {
|
|
247
|
+
requireLauncherInputs({ packageRoot, nodePath }, 'Windows autostart');
|
|
248
|
+
const resolvedPackageRoot = pathImpl.resolve(String(packageRoot));
|
|
47
249
|
const serverPath = pathImpl.join(resolvedPackageRoot, 'server', 'server.js');
|
|
48
|
-
const lines = [
|
|
49
|
-
'@echo off',
|
|
50
|
-
'title OAR Web Relay',
|
|
51
|
-
];
|
|
250
|
+
const lines = ['title OAR Web Relay'];
|
|
52
251
|
if (String(configPath || '').trim()) {
|
|
53
252
|
lines.push(`set "COPILOT_WEB_RELAY_CONFIG=${escapeBatchValue(pathImpl.resolve(configPath))}"`);
|
|
54
253
|
}
|
|
254
|
+
lines.push(...stateDirLines({ dataDir, logDir, pathImpl }));
|
|
55
255
|
const cwdTarget = String(workingDirectory || '').trim()
|
|
56
256
|
? pathImpl.resolve(String(workingDirectory))
|
|
57
257
|
: resolvedPackageRoot;
|
|
258
|
+
if (wait) {
|
|
259
|
+
const logTarget = `"${escapeBatchValue(pathImpl.join(cwdTarget, WINDOWS_AUTOSTART_LOG_FILENAME))}"`;
|
|
260
|
+
lines.push(...autostartWaitLines({ dataDir, logTarget, pathImpl }));
|
|
261
|
+
}
|
|
58
262
|
lines.push(
|
|
59
263
|
`cd /d "${escapeBatchValue(cwdTarget)}"`,
|
|
60
|
-
|
|
61
|
-
'',
|
|
264
|
+
launcherNodeLine(pathImpl.resolve(String(nodePath)), serverPath, wait ? {} : { ending: 'exit /b' }),
|
|
62
265
|
);
|
|
63
|
-
return lines
|
|
266
|
+
return lines;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The sign-in entries earlier versions generated, newest first: the padded
|
|
271
|
+
* one before 0.9.14 shipped (no wait, `& exit /b`; needs `dataDir` and
|
|
272
|
+
* `logDir`), 0.9.2 to 0.9.13, then 0.9.0 and 0.9.1 (pre-rebrand name).
|
|
273
|
+
*/
|
|
274
|
+
export function buildEarlierWindowsAutostartScripts({
|
|
275
|
+
packageRoot,
|
|
276
|
+
nodePath,
|
|
277
|
+
configPath = '',
|
|
278
|
+
dataDir = '',
|
|
279
|
+
logDir = '',
|
|
280
|
+
workingDirectory = '',
|
|
281
|
+
pathImpl = path,
|
|
282
|
+
} = {}) {
|
|
283
|
+
requireLauncherInputs({ packageRoot, nodePath }, 'Windows autostart');
|
|
284
|
+
const resolvedPackageRoot = pathImpl.resolve(String(packageRoot));
|
|
285
|
+
const serverPath = pathImpl.join(resolvedPackageRoot, 'server', 'server.js');
|
|
286
|
+
const unpadded = (title, cwd) => [
|
|
287
|
+
'@echo off',
|
|
288
|
+
title,
|
|
289
|
+
...(String(configPath || '').trim()
|
|
290
|
+
? [`set "COPILOT_WEB_RELAY_CONFIG=${escapeBatchValue(pathImpl.resolve(configPath))}"`]
|
|
291
|
+
: []),
|
|
292
|
+
`cd /d "${escapeBatchValue(cwd)}"`,
|
|
293
|
+
`"${escapeBatchValue(pathImpl.resolve(String(nodePath)))}" "${escapeBatchValue(serverPath)}"`,
|
|
294
|
+
'',
|
|
295
|
+
].join('\r\n');
|
|
296
|
+
const scripts = [];
|
|
297
|
+
if (String(workingDirectory || '').trim()) {
|
|
298
|
+
scripts.push(
|
|
299
|
+
buildLauncherFile({
|
|
300
|
+
bodyLines: signInBodyLines({
|
|
301
|
+
packageRoot, nodePath, configPath, dataDir, logDir, workingDirectory, pathImpl, wait: false,
|
|
302
|
+
}),
|
|
303
|
+
}),
|
|
304
|
+
unpadded('title OAR Web Relay', pathImpl.resolve(String(workingDirectory))),
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
scripts.push(unpadded('title Copilot Remote Web Relay', resolvedPackageRoot));
|
|
308
|
+
return scripts;
|
|
64
309
|
}
|
|
65
310
|
|
|
66
311
|
export function createWindowsAutostartService({
|
|
@@ -108,6 +353,71 @@ export function createWindowsAutostartService({
|
|
|
108
353
|
return true;
|
|
109
354
|
}
|
|
110
355
|
|
|
356
|
+
const workingDirectory = () => resolveOarRoot(env, platform);
|
|
357
|
+
const serverPath = () => pathImpl.join(pathImpl.resolve(String(packageRoot || '')), 'server', 'server.js');
|
|
358
|
+
|
|
359
|
+
function buildScript({ stateDirs, coverBytes = 0, nodePathOverride = null } = {}) {
|
|
360
|
+
return buildWindowsAutostartScript({
|
|
361
|
+
packageRoot,
|
|
362
|
+
nodePath: nodePathOverride || nodePath,
|
|
363
|
+
configPath,
|
|
364
|
+
...(stateDirs || resolveRelayStateDirs({ packageRoot, env, platform, fsImpl, pathImpl })),
|
|
365
|
+
workingDirectory: workingDirectory(),
|
|
366
|
+
coverBytes,
|
|
367
|
+
pathImpl,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function writeEntry(targetPath, script) {
|
|
372
|
+
fsImpl.mkdirSync(pathImpl.dirname(targetPath), { recursive: true });
|
|
373
|
+
const temporaryPath = `${targetPath}.${randomUUID()}.tmp`;
|
|
374
|
+
try {
|
|
375
|
+
fsImpl.writeFileSync(temporaryPath, script, { encoding: 'utf8', flag: 'wx' });
|
|
376
|
+
fsImpl.renameSync(temporaryPath, targetPath);
|
|
377
|
+
} finally {
|
|
378
|
+
if (fsImpl.existsSync(temporaryPath)) fsImpl.unlinkSync(temporaryPath);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Brings an entry an earlier version wrote (see
|
|
384
|
+
* buildEarlierWindowsAutostartScripts) up to date, under the name it has;
|
|
385
|
+
* the relay calls this once it runs, so an update or `oar start` repairs the
|
|
386
|
+
* entry without the setting being switched off and on. Only an entry exactly
|
|
387
|
+
* as that version wrote it for this install and its config is rewritten,
|
|
388
|
+
* keeping the Node it runs; one edited by hand, or one of another install
|
|
389
|
+
* of OAR (the Startup entry has one name for all of them), is left alone. It
|
|
390
|
+
* pins only the install's own folders (resolveLayoutStateDirs), so nothing
|
|
391
|
+
* happens in a checkout or on a relay started with folders of its own. True
|
|
392
|
+
* when it rewrote an entry.
|
|
393
|
+
*/
|
|
394
|
+
// `onEarlierForm`: the earlier form found (`pinnedData` false: package database).
|
|
395
|
+
function refreshInstalledEntry({ onEarlierForm } = {}) {
|
|
396
|
+
if (!supported || String(env?.COPILOT_WEB_RELAY_DISABLE_CLI_SPAWN || '').trim()) return false;
|
|
397
|
+
const stateDirs = resolveLayoutStateDirs({ packageRoot, env, platform, fsImpl, pathImpl });
|
|
398
|
+
if (!stateDirs) return false;
|
|
399
|
+
let changed = false;
|
|
400
|
+
for (const targetPath of [entryPath(), legacyEntryPath()]) {
|
|
401
|
+
if (!fsImpl.existsSync(targetPath)) continue;
|
|
402
|
+
const current = String(fsImpl.readFileSync(targetPath, 'utf8'));
|
|
403
|
+
const entryNodePath = readLauncherNodePath(current, serverPath());
|
|
404
|
+
if (!entryNodePath) continue;
|
|
405
|
+
const earlier = buildEarlierWindowsAutostartScripts({
|
|
406
|
+
packageRoot, nodePath: entryNodePath, configPath, ...stateDirs, workingDirectory: workingDirectory(), pathImpl,
|
|
407
|
+
});
|
|
408
|
+
const matched = earlier.find((script) => launcherScriptsMatch(current, script, { ignoreCase: true }));
|
|
409
|
+
if (!matched) continue;
|
|
410
|
+
onEarlierForm?.({ path: targetPath, pinnedData: matched.includes('COPILOT_WEB_RELAY_DATA_DIR') });
|
|
411
|
+
writeEntry(targetPath, buildScript({
|
|
412
|
+
stateDirs,
|
|
413
|
+
nodePathOverride: entryNodePath,
|
|
414
|
+
coverBytes: Buffer.byteLength(current, 'utf8'),
|
|
415
|
+
}));
|
|
416
|
+
changed = true;
|
|
417
|
+
}
|
|
418
|
+
return changed;
|
|
419
|
+
}
|
|
420
|
+
|
|
111
421
|
function setEnabled(enabled) {
|
|
112
422
|
if (!supported) {
|
|
113
423
|
throw new Error('Windows autostart is only available on Windows.');
|
|
@@ -127,30 +437,18 @@ export function createWindowsAutostartService({
|
|
|
127
437
|
// legacy entry existed, the current-name write below reports the change.
|
|
128
438
|
removeLegacyEntry();
|
|
129
439
|
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
nodePath,
|
|
133
|
-
configPath,
|
|
134
|
-
workingDirectory: resolveOarRoot(env, platform),
|
|
135
|
-
pathImpl,
|
|
136
|
-
});
|
|
137
|
-
if (existed && fsImpl.readFileSync(targetPath, 'utf8') === script) {
|
|
440
|
+
const current = existed ? String(fsImpl.readFileSync(targetPath, 'utf8')) : '';
|
|
441
|
+
if (existed && launcherScriptsMatch(current, buildScript())) {
|
|
138
442
|
return { ...getState(), changed: false };
|
|
139
443
|
}
|
|
140
444
|
|
|
141
|
-
|
|
142
|
-
const temporaryPath = `${targetPath}.${randomUUID()}.tmp`;
|
|
143
|
-
try {
|
|
144
|
-
fsImpl.writeFileSync(temporaryPath, script, { encoding: 'utf8', flag: 'wx' });
|
|
145
|
-
fsImpl.renameSync(temporaryPath, targetPath);
|
|
146
|
-
} finally {
|
|
147
|
-
if (fsImpl.existsSync(temporaryPath)) fsImpl.unlinkSync(temporaryPath);
|
|
148
|
-
}
|
|
445
|
+
writeEntry(targetPath, buildScript({ coverBytes: Buffer.byteLength(current, 'utf8') }));
|
|
149
446
|
return { ...getState(), changed: true };
|
|
150
447
|
}
|
|
151
448
|
|
|
152
449
|
return {
|
|
153
450
|
getState,
|
|
154
451
|
setEnabled,
|
|
452
|
+
refreshInstalledEntry,
|
|
155
453
|
};
|
|
156
454
|
}
|