@oar-sh/oar 0.9.12 → 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 +508 -0
- package/README.md +12 -12
- package/bin/oar.js +155 -94
- package/package.json +5 -5
- package/server/README.md +479 -157
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
- package/server/claude-worker/claude-session-process.mjs +92 -15
- package/server/claude-worker/claude-turn-publisher.mjs +20 -1
- package/server/claude-worker/subagent-timeline-recorder.mjs +75 -8
- package/server/claude-worker/task-output-discovery.mjs +28 -2
- package/server/copilot-worker/copilot-ask-user-tool.mjs +1 -1
- package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
- 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 +24 -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/db-schema.mjs +10 -0
- package/server/grok-worker/acp-host-services.mjs +6 -3
- package/server/grok-worker/grok-background-tasks.mjs +7 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +14 -0
- package/server/grok-worker/grok-turn-runner.mjs +13 -3
- package/server/migrations/0018-background-task-rows.mjs +101 -0
- package/server/public/app/action-confirmations.js +12 -15
- package/server/public/app/api-client.js +60 -4
- package/server/public/app/ask-user-view.js +29 -4
- package/server/public/app/background-tasks-view.mjs +56 -7
- package/server/public/app/bootstrap.js +7 -0
- package/server/public/app/conversation-preferences.mjs +10 -4
- package/server/public/app/conversation-provider-indicator.mjs +18 -8
- package/server/public/app/conversation-view.js +33 -9
- package/server/public/app/host-suspend-ui.js +45 -44
- package/server/public/app/journal-view.js +9 -3
- package/server/public/app/push-settings.js +1 -0
- 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/app/relay-prompt-sanitizer.mjs +9 -2
- package/server/public/app/relay-restart-note-view.mjs +36 -0
- package/server/public/app/relay-restart-ui.js +568 -0
- package/server/public/app/settings-modal.js +52 -0
- package/server/public/app/socket-handlers.js +1 -1
- package/server/public/app/task-console-render.mjs +171 -7
- package/server/public/app/task-console.mjs +111 -11
- package/server/public/app/task-output-text.mjs +15 -6
- package/server/public/app/transcript-jump-bottom-view.mjs +5 -1
- package/server/public/app/transcript-jump-bottom.mjs +20 -0
- package/server/public/index.html +76 -5
- package/server/relay-exit-codes.mjs +6 -0
- package/server/relay-self-restart.mjs +6 -1
- package/server/relay-tools.md +17 -9
- package/server/repositories/message-repository.mjs +32 -28
- package/server/repositories/remote-relay-repository.mjs +4 -2
- package/server/repositories/subagent-timeline-statements.mjs +2 -0
- package/server/routes/ask-user-routes.mjs +41 -7
- package/server/routes/messages-routes.mjs +220 -37
- package/server/routes/preview-routes.mjs +73 -7
- package/server/routes/relay-board-routes.mjs +19 -2
- package/server/routes/relay-restart-routes.mjs +73 -0
- package/server/routes/sessions-routes.mjs +79 -5
- package/server/server-runtime.mjs +335 -218
- package/server/services/about-service.mjs +13 -12
- package/server/services/background-task-persistence.mjs +133 -0
- package/server/services/background-task-store.mjs +240 -13
- package/server/services/conversation-model-fallback.mjs +57 -0
- 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/host-suspend-command.mjs +2 -1
- 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-proxy-server.mjs +14 -1
- package/server/services/preview-registry-service.mjs +3 -2
- package/server/services/process-list-service.mjs +2 -1
- package/server/services/provider-model-selection.mjs +195 -0
- package/server/services/push-dispatch-service.mjs +42 -8
- package/server/services/relay-event-service.mjs +210 -14
- package/server/services/relay-prompt-sanitizer.mjs +7 -0
- package/server/services/relay-restart-picture.mjs +209 -0
- package/server/services/relay-restart-queue.mjs +387 -0
- package/server/services/relay-shutdown-gate.mjs +4 -1
- 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 +217 -28
- package/server/services/restart-resume-service.mjs +303 -0
- package/server/services/session-activity-tracker.mjs +52 -15
- package/server/services/session-worker-launch-service.mjs +31 -1
- package/server/services/session-worker-process-service.mjs +6 -4
- package/server/services/stale-turn-recovery.mjs +99 -0
- package/server/services/subagent-timeline-service.mjs +4 -1
- package/server/services/task-output-service.mjs +217 -18
- 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 +106 -3
- package/shared/remote-relay-contract.mjs +32 -15
- package/shared/subagent-timeline.mjs +4 -0
- package/shared/task-output-text.mjs +196 -83
- 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/worker-websocket-link.mjs +96 -20
- package/shared/worker-runtime/working-directory.mjs +65 -0
- package/server/uploads/image_input.md +0 -540
|
@@ -3,6 +3,7 @@ import path from 'path';
|
|
|
3
3
|
import { execFile, spawn } from 'child_process';
|
|
4
4
|
|
|
5
5
|
import { resolveOarRoot } from './oar-state-migration-service.mjs';
|
|
6
|
+
import { resolveLayoutStateDirs, resolveRelayStateDirs } from './oar-state-layout.mjs';
|
|
6
7
|
import {
|
|
7
8
|
describeExecError,
|
|
8
9
|
describeSpawnEnvironment,
|
|
@@ -11,8 +12,14 @@ import {
|
|
|
11
12
|
import {
|
|
12
13
|
LEGACY_WINDOWS_AUTOSTART_FILENAME,
|
|
13
14
|
WINDOWS_AUTOSTART_FILENAME,
|
|
15
|
+
autostartWaitLines,
|
|
16
|
+
buildLauncherFile,
|
|
14
17
|
escapeBatchValue,
|
|
18
|
+
launcherNodeLine,
|
|
19
|
+
launcherScriptsMatch,
|
|
20
|
+
readLauncherNodePath,
|
|
15
21
|
resolveWindowsStartupDirectory,
|
|
22
|
+
stateDirLines,
|
|
16
23
|
} from './windows-autostart-service.mjs';
|
|
17
24
|
|
|
18
25
|
/**
|
|
@@ -48,7 +55,8 @@ export const BOOT_ELEVATION_SCRIPT_FILENAME = 'oar-relay-elevate.ps1';
|
|
|
48
55
|
// Where a regenerated launcher keeps the one it replaced, when they differ.
|
|
49
56
|
export const BOOT_LAUNCHER_BACKUP_SUFFIX = '.bak';
|
|
50
57
|
const ELEVATION_TIMEOUT_MS = 120_000;
|
|
51
|
-
|
|
58
|
+
// Next to the launcher: a wait for TEMP and the data drive that ran out (see
|
|
59
|
+
// autostartWaitLines; AUTOSTART_WAIT_SECONDS is the limit).
|
|
52
60
|
export const BOOT_LAUNCHER_LOG_FILENAME = 'oar-web-relay-launcher.log';
|
|
53
61
|
// Each schtasks query; the settings page waits on two of them.
|
|
54
62
|
const QUERY_TIMEOUT_MS = 6_000;
|
|
@@ -69,6 +77,8 @@ function escapeXml(value) {
|
|
|
69
77
|
/**
|
|
70
78
|
* The launcher the task executes. Same shape as the sign-in .cmd minus the
|
|
71
79
|
* `title` line: a boot task runs in session 0 where no console is ever shown.
|
|
80
|
+
* Like it, the launcher pins the config and the data and log folders `oar
|
|
81
|
+
* start` uses, so both start the same relay.
|
|
72
82
|
*
|
|
73
83
|
* Node's folder goes at the end of PATH. The relay itself runs on the
|
|
74
84
|
* absolute path below, but a boot task's PATH can lack the user's Node (a
|
|
@@ -76,65 +86,142 @@ function escapeXml(value) {
|
|
|
76
86
|
* and the CLIs and commands the workers start may call a bare `node`. At the
|
|
77
87
|
* end it only matters when PATH has no Node of its own.
|
|
78
88
|
*
|
|
79
|
-
* Before Node starts
|
|
80
|
-
*
|
|
81
|
-
* only after the task has fired, and Node, the workers and their CLIs then
|
|
82
|
-
* fail creating their temp files. It costs nothing when the folder is there,
|
|
83
|
-
* is skipped when TEMP is not set, and a wait that runs out is logged to
|
|
84
|
-
* BOOT_LAUNCHER_LOG_FILENAME next to the launcher before Node starts anyway.
|
|
89
|
+
* Before Node starts it waits for %TEMP% and the data drive (autostartWaitLines),
|
|
90
|
+
* logging a wait that ran out to BOOT_LAUNCHER_LOG_FILENAME beside it.
|
|
85
91
|
*/
|
|
92
|
+
const BOOT_LAUNCHER_HEADER = [
|
|
93
|
+
'@echo off',
|
|
94
|
+
'rem Generated by OAR settings (system-startup autostart). Regenerated on',
|
|
95
|
+
'rem every settings change - do not edit by hand.',
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
function bootLauncherInputs({ packageRoot, nodePath, workingDirectory, pathImpl }) {
|
|
99
|
+
if (!normalizeText(packageRoot)) {
|
|
100
|
+
throw new Error('Windows boot autostart requires the package root.');
|
|
101
|
+
}
|
|
102
|
+
if (!normalizeText(nodePath)) {
|
|
103
|
+
throw new Error('Windows boot autostart requires the Node.js executable path.');
|
|
104
|
+
}
|
|
105
|
+
const resolvedPackageRoot = pathImpl.resolve(String(packageRoot));
|
|
106
|
+
return {
|
|
107
|
+
serverPath: pathImpl.join(resolvedPackageRoot, 'server', 'server.js'),
|
|
108
|
+
resolvedNodePath: pathImpl.resolve(nodePath),
|
|
109
|
+
cwdTarget: normalizeText(workingDirectory)
|
|
110
|
+
? pathImpl.resolve(String(workingDirectory))
|
|
111
|
+
: resolvedPackageRoot,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function configLines(configPath, pathImpl) {
|
|
116
|
+
return normalizeText(configPath)
|
|
117
|
+
? [`set "COPILOT_WEB_RELAY_CONFIG=${escapeBatchValue(pathImpl.resolve(configPath))}"`]
|
|
118
|
+
: [];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 0.9.11 to 0.9.13's wait (%TEMP% only, 120 s), verbatim, so a refresh knows it.
|
|
122
|
+
const EARLIER_BOOT_TEMP_WAIT_LINES = [
|
|
123
|
+
'if "%TEMP%"=="" goto oar_start',
|
|
124
|
+
'set /a OAR_TEMP_WAIT=0',
|
|
125
|
+
':oar_wait_temp',
|
|
126
|
+
'if exist "%TEMP%\\" goto oar_start',
|
|
127
|
+
'if %OAR_TEMP_WAIT% geq 120 goto oar_temp_missing',
|
|
128
|
+
'"%SystemRoot%\\System32\\ping.exe" -n 2 127.0.0.1 >nul',
|
|
129
|
+
'set /a OAR_TEMP_WAIT+=1',
|
|
130
|
+
'goto oar_wait_temp',
|
|
131
|
+
':oar_temp_missing',
|
|
132
|
+
`echo [%DATE% %TIME%] "%TEMP%" still missing after 120 s; starting the relay anyway.>>"%~dp0${BOOT_LAUNCHER_LOG_FILENAME}"`,
|
|
133
|
+
':oar_start',
|
|
134
|
+
'set "OAR_TEMP_WAIT="',
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
// The boot launcher's lines after the padding, with the wait and the Node
|
|
138
|
+
// line's ending given (see launcherNodeLine).
|
|
139
|
+
function bootBodyLines({ resolvedNodePath, serverPath, cwdTarget, configPath, dataDir, logDir, pathImpl, waitLines, nodeLineOptions = {} }) {
|
|
140
|
+
return [
|
|
141
|
+
`set "PATH=%PATH%;${escapeBatchValue(pathImpl.dirname(resolvedNodePath))}"`,
|
|
142
|
+
...configLines(configPath, pathImpl),
|
|
143
|
+
...stateDirLines({ dataDir, logDir, pathImpl }),
|
|
144
|
+
...waitLines,
|
|
145
|
+
`cd /d "${escapeBatchValue(cwdTarget)}"`,
|
|
146
|
+
launcherNodeLine(resolvedNodePath, serverPath, nodeLineOptions),
|
|
147
|
+
];
|
|
148
|
+
}
|
|
149
|
+
|
|
86
150
|
export function buildBootLauncherScript({
|
|
87
151
|
packageRoot,
|
|
88
152
|
nodePath = process.execPath,
|
|
89
153
|
configPath = '',
|
|
154
|
+
dataDir = '',
|
|
155
|
+
logDir = '',
|
|
90
156
|
// Where the relay process lives, NOT where its code lives: on Windows a
|
|
91
157
|
// process pins its cwd, and self-update must be able to replace the package
|
|
92
158
|
// directory. Callers pass the OAR state root; the package-root default only
|
|
93
159
|
// serves builders that predate self-update.
|
|
94
160
|
workingDirectory = '',
|
|
161
|
+
// Size of the file this one replaces (see buildLauncherFile).
|
|
162
|
+
coverBytes = 0,
|
|
95
163
|
pathImpl = path,
|
|
96
164
|
} = {}) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
165
|
+
const { serverPath, resolvedNodePath, cwdTarget } = bootLauncherInputs({ packageRoot, nodePath, workingDirectory, pathImpl });
|
|
166
|
+
return buildLauncherFile({
|
|
167
|
+
headerLines: BOOT_LAUNCHER_HEADER,
|
|
168
|
+
bodyLines: bootBodyLines({
|
|
169
|
+
resolvedNodePath,
|
|
170
|
+
serverPath,
|
|
171
|
+
cwdTarget,
|
|
172
|
+
configPath,
|
|
173
|
+
dataDir,
|
|
174
|
+
logDir,
|
|
175
|
+
pathImpl,
|
|
176
|
+
waitLines: autostartWaitLines({ dataDir, logTarget: `"%~dp0${BOOT_LAUNCHER_LOG_FILENAME}"`, pathImpl }),
|
|
177
|
+
}),
|
|
178
|
+
coverBytes,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The launchers earlier versions generated, newest first: the padded one
|
|
184
|
+
* before 0.9.14 shipped (120 s TEMP wait, `& exit /b`; needs `dataDir` and
|
|
185
|
+
* `logDir`), 0.9.11 to 0.9.13, then 0.9.2 to 0.9.10.
|
|
186
|
+
*/
|
|
187
|
+
export function buildEarlierBootLauncherScripts({
|
|
188
|
+
packageRoot,
|
|
189
|
+
nodePath,
|
|
190
|
+
configPath = '',
|
|
191
|
+
dataDir = '',
|
|
192
|
+
logDir = '',
|
|
193
|
+
workingDirectory = '',
|
|
194
|
+
pathImpl = path,
|
|
195
|
+
} = {}) {
|
|
196
|
+
const { serverPath, resolvedNodePath, cwdTarget } = bootLauncherInputs({ packageRoot, nodePath, workingDirectory, pathImpl });
|
|
197
|
+
const nodeLine = `"${escapeBatchValue(resolvedNodePath)}" "${escapeBatchValue(serverPath)}"`;
|
|
198
|
+
const cdLine = `cd /d "${escapeBatchValue(cwdTarget)}"`;
|
|
199
|
+
return [
|
|
200
|
+
buildLauncherFile({
|
|
201
|
+
headerLines: BOOT_LAUNCHER_HEADER,
|
|
202
|
+
bodyLines: bootBodyLines({
|
|
203
|
+
resolvedNodePath,
|
|
204
|
+
serverPath,
|
|
205
|
+
cwdTarget,
|
|
206
|
+
configPath,
|
|
207
|
+
dataDir,
|
|
208
|
+
logDir,
|
|
209
|
+
pathImpl,
|
|
210
|
+
waitLines: EARLIER_BOOT_TEMP_WAIT_LINES,
|
|
211
|
+
nodeLineOptions: { ending: 'exit /b' },
|
|
212
|
+
}),
|
|
213
|
+
}),
|
|
214
|
+
[
|
|
215
|
+
...BOOT_LAUNCHER_HEADER,
|
|
216
|
+
`set "PATH=%PATH%;${escapeBatchValue(pathImpl.dirname(resolvedNodePath))}"`,
|
|
217
|
+
...configLines(configPath, pathImpl),
|
|
218
|
+
...EARLIER_BOOT_TEMP_WAIT_LINES,
|
|
219
|
+
cdLine,
|
|
220
|
+
nodeLine,
|
|
221
|
+
'',
|
|
222
|
+
].join('\r\n'),
|
|
223
|
+
[...BOOT_LAUNCHER_HEADER, ...configLines(configPath, pathImpl), cdLine, nodeLine, ''].join('\r\n'),
|
|
111
224
|
];
|
|
112
|
-
if (normalizeText(configPath)) {
|
|
113
|
-
lines.push(`set "COPILOT_WEB_RELAY_CONFIG=${escapeBatchValue(pathImpl.resolve(configPath))}"`);
|
|
114
|
-
}
|
|
115
|
-
const cwdTarget = normalizeText(workingDirectory)
|
|
116
|
-
? pathImpl.resolve(String(workingDirectory))
|
|
117
|
-
: resolvedPackageRoot;
|
|
118
|
-
lines.push(
|
|
119
|
-
// About one second a round: ping waits a second between its two echoes.
|
|
120
|
-
// By full path, since the PATH of an early boot may not lead to System32.
|
|
121
|
-
'if "%TEMP%"=="" goto oar_start',
|
|
122
|
-
'set /a OAR_TEMP_WAIT=0',
|
|
123
|
-
':oar_wait_temp',
|
|
124
|
-
'if exist "%TEMP%\\" goto oar_start',
|
|
125
|
-
`if %OAR_TEMP_WAIT% geq ${BOOT_TEMP_WAIT_SECONDS} goto oar_temp_missing`,
|
|
126
|
-
'"%SystemRoot%\\System32\\ping.exe" -n 2 127.0.0.1 >nul',
|
|
127
|
-
'set /a OAR_TEMP_WAIT+=1',
|
|
128
|
-
'goto oar_wait_temp',
|
|
129
|
-
':oar_temp_missing',
|
|
130
|
-
`echo [%DATE% %TIME%] "%TEMP%" still missing after ${BOOT_TEMP_WAIT_SECONDS} s; starting the relay anyway.>>"%~dp0${BOOT_LAUNCHER_LOG_FILENAME}"`,
|
|
131
|
-
':oar_start',
|
|
132
|
-
'set "OAR_TEMP_WAIT="',
|
|
133
|
-
`cd /d "${escapeBatchValue(cwdTarget)}"`,
|
|
134
|
-
`"${escapeBatchValue(resolvedNodePath)}" "${escapeBatchValue(serverPath)}"`,
|
|
135
|
-
'',
|
|
136
|
-
);
|
|
137
|
-
return lines.join('\r\n');
|
|
138
225
|
}
|
|
139
226
|
|
|
140
227
|
function unescapeXml(value) {
|
|
@@ -273,15 +360,8 @@ export function describeTaskCustomisations(xmlText, { launcherPath, workingDirec
|
|
|
273
360
|
return differences;
|
|
274
361
|
}
|
|
275
362
|
|
|
276
|
-
|
|
277
|
-
export
|
|
278
|
-
const normalize = (value) => String(value == null ? '' : value)
|
|
279
|
-
.split(/\r?\n/)
|
|
280
|
-
.map((line) => line.replace(/\s+$/, ''))
|
|
281
|
-
.join('\n')
|
|
282
|
-
.trim();
|
|
283
|
-
return normalize(actual) === normalize(expected);
|
|
284
|
-
}
|
|
363
|
+
// Shared with the sign-in entry; still exported from here for its callers.
|
|
364
|
+
export { launcherScriptsMatch };
|
|
285
365
|
|
|
286
366
|
/**
|
|
287
367
|
* Ours or someone else's? A task counts as ours when its action runs our
|
|
@@ -380,6 +460,7 @@ export function createWindowsBootAutostartService({
|
|
|
380
460
|
const oarRoot = () => resolveOarRoot(env, platform);
|
|
381
461
|
const launcherPath = () => pathImpl.join(oarRoot(), BOOT_LAUNCHER_FILENAME);
|
|
382
462
|
const xmlPath = () => pathImpl.join(oarRoot(), BOOT_TASK_XML_FILENAME);
|
|
463
|
+
const serverPath = () => pathImpl.join(pathImpl.resolve(String(packageRoot || '')), 'server', 'server.js');
|
|
383
464
|
const elevationScriptPath = () => pathImpl.join(oarRoot(), BOOT_ELEVATION_SCRIPT_FILENAME);
|
|
384
465
|
|
|
385
466
|
function log(message) {
|
|
@@ -416,8 +497,49 @@ export function createWindowsBootAutostartService({
|
|
|
416
497
|
}
|
|
417
498
|
}
|
|
418
499
|
|
|
419
|
-
function generatedLauncher() {
|
|
420
|
-
return buildBootLauncherScript({
|
|
500
|
+
function generatedLauncher({ stateDirs, coverBytes = 0, nodePathOverride = null } = {}) {
|
|
501
|
+
return buildBootLauncherScript({
|
|
502
|
+
packageRoot,
|
|
503
|
+
nodePath: nodePathOverride || nodePath,
|
|
504
|
+
configPath,
|
|
505
|
+
...(stateDirs || resolveRelayStateDirs({ packageRoot, env, platform, fsImpl, pathImpl })),
|
|
506
|
+
workingDirectory: oarRoot(),
|
|
507
|
+
coverBytes,
|
|
508
|
+
pathImpl,
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Brings a launcher an earlier version wrote (see
|
|
514
|
+
* buildEarlierBootLauncherScripts) up to date; the relay calls this once it
|
|
515
|
+
* runs. The task runs the launcher file, so this needs no elevation. Only a
|
|
516
|
+
* launcher exactly as that version generated it for this install and its
|
|
517
|
+
* config is rewritten, keeping the Node it runs; an edited one stays (it
|
|
518
|
+
* reads as customised, and enabling again replaces it, keeping a copy). It
|
|
519
|
+
* pins only the install's own folders (resolveLayoutStateDirs). True when
|
|
520
|
+
* it rewrote the launcher.
|
|
521
|
+
*/
|
|
522
|
+
// `onEarlierForm`: the earlier form found (`pinnedData` false: package database).
|
|
523
|
+
function refreshInstalledLauncher({ onEarlierForm } = {}) {
|
|
524
|
+
if (!supported || spawnsDisabled()) return false;
|
|
525
|
+
const stateDirs = resolveLayoutStateDirs({ packageRoot, env, platform, fsImpl, pathImpl });
|
|
526
|
+
if (!stateDirs) return false;
|
|
527
|
+
const current = readLauncher();
|
|
528
|
+
if (current === null) return false;
|
|
529
|
+
const launcherNodePath = readLauncherNodePath(current, serverPath());
|
|
530
|
+
if (!launcherNodePath) return false;
|
|
531
|
+
const earlier = buildEarlierBootLauncherScripts({
|
|
532
|
+
packageRoot, nodePath: launcherNodePath, configPath, ...stateDirs, workingDirectory: oarRoot(), pathImpl,
|
|
533
|
+
});
|
|
534
|
+
const matched = earlier.find((script) => launcherScriptsMatch(current, script, { ignoreCase: true }));
|
|
535
|
+
if (!matched) return false;
|
|
536
|
+
onEarlierForm?.({ path: launcherPath(), pinnedData: matched.includes('COPILOT_WEB_RELAY_DATA_DIR') });
|
|
537
|
+
writeFileAtomic(launcherPath(), generatedLauncher({
|
|
538
|
+
stateDirs,
|
|
539
|
+
nodePathOverride: launcherNodePath,
|
|
540
|
+
coverBytes: Buffer.byteLength(current, 'utf8'),
|
|
541
|
+
}));
|
|
542
|
+
return true;
|
|
421
543
|
}
|
|
422
544
|
|
|
423
545
|
/** What differs from the generated task and launcher, for a task that runs our launcher. */
|
|
@@ -430,7 +552,12 @@ export function createWindowsBootAutostartService({
|
|
|
430
552
|
if (launcher === null) differences.push('launcher-missing');
|
|
431
553
|
else {
|
|
432
554
|
try {
|
|
433
|
-
|
|
555
|
+
// Against the Node the launcher runs, as the repair keeps it: a moved
|
|
556
|
+
// Node is no customisation.
|
|
557
|
+
const ownNodePath = readLauncherNodePath(launcher, serverPath());
|
|
558
|
+
if (!launcherScriptsMatch(launcher, generatedLauncher({ nodePathOverride: ownNodePath }))) {
|
|
559
|
+
differences.push('launcher');
|
|
560
|
+
}
|
|
434
561
|
} catch {}
|
|
435
562
|
}
|
|
436
563
|
return differences;
|
|
@@ -608,7 +735,7 @@ export function createWindowsBootAutostartService({
|
|
|
608
735
|
return { ...(await getState()), accepted: false };
|
|
609
736
|
}
|
|
610
737
|
}
|
|
611
|
-
writeFileAtomic(launcherPath(),
|
|
738
|
+
writeFileAtomic(launcherPath(), generatedLauncher({ coverBytes: previous === null ? 0 : Buffer.byteLength(previous, 'utf8') }));
|
|
612
739
|
const who = await execFileImpl(tool('whoami'), ['/user', '/fo', 'csv']);
|
|
613
740
|
const sid = parseSidFromWhoami(who.stdout);
|
|
614
741
|
if (!sid) {
|
|
@@ -667,5 +794,6 @@ export function createWindowsBootAutostartService({
|
|
|
667
794
|
getState,
|
|
668
795
|
requestEnable,
|
|
669
796
|
requestDisable,
|
|
797
|
+
refreshInstalledLauncher,
|
|
670
798
|
};
|
|
671
799
|
}
|
|
@@ -15,40 +15,45 @@ const DEFAULT_TTL_MS = 6 * 60 * 60 * 1000;
|
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Resolves the instruction block for this relay, or '' when the preview lane
|
|
18
|
-
* is off. Preview settings only change on relay restart, so the
|
|
19
|
-
* cached: this must not become a per-turn HTTP call.
|
|
18
|
+
* is off. Preview settings only change on relay restart, so the lane facts are
|
|
19
|
+
* cached: this must not become a per-turn HTTP call. The block itself is
|
|
20
|
+
* rendered per call for `{ conversationId }`, the conversation it is sent to,
|
|
21
|
+
* so the API calls it teaches are scoped to that conversation.
|
|
20
22
|
*/
|
|
21
23
|
export function createPreviewInstructionsProvider({
|
|
22
24
|
api,
|
|
23
25
|
ttlMs = DEFAULT_TTL_MS,
|
|
24
26
|
now = () => Date.now(),
|
|
25
27
|
} = {}) {
|
|
26
|
-
|
|
28
|
+
// null: the lane is off; else its public base URL.
|
|
29
|
+
let cachedLane = null;
|
|
27
30
|
let cachedUntil = 0;
|
|
28
31
|
let inFlight = null;
|
|
29
32
|
|
|
30
33
|
async function load() {
|
|
31
34
|
const response = await api('GET', '/api/previews');
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
: '';
|
|
35
|
-
cachedText = text;
|
|
35
|
+
const lane = response?.enabled === true ? { publicBaseUrl: response?.publicBaseUrl || '' } : null;
|
|
36
|
+
cachedLane = lane;
|
|
36
37
|
cachedUntil = now() + ttlMs;
|
|
37
|
-
return
|
|
38
|
+
return lane;
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
|
|
41
|
+
function render(lane, conversationId) {
|
|
42
|
+
return lane ? renderPreviewInstructionBlock({ publicBaseUrl: lane.publicBaseUrl, conversationId }) : '';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return async function getPreviewInstructions({ conversationId = '' } = {}) {
|
|
41
46
|
if (typeof api !== 'function') return '';
|
|
42
|
-
if (cachedUntil > now()) return
|
|
47
|
+
if (cachedUntil > now()) return render(cachedLane, conversationId);
|
|
43
48
|
if (!inFlight) {
|
|
44
49
|
// A failed lookup is deliberately not cached, and yields no block: an
|
|
45
50
|
// unreachable relay must neither lose the capability for the rest of the
|
|
46
51
|
// process nor advertise one that would 503.
|
|
47
52
|
inFlight = load()
|
|
48
|
-
.catch(() =>
|
|
53
|
+
.catch(() => null)
|
|
49
54
|
.finally(() => { inFlight = null; });
|
|
50
55
|
}
|
|
51
|
-
return inFlight;
|
|
56
|
+
return render(await inFlight, conversationId);
|
|
52
57
|
};
|
|
53
58
|
}
|
|
54
59
|
|
|
@@ -15,9 +15,9 @@ export const PREVIEW_TOOL_NAME = 'preview';
|
|
|
15
15
|
|
|
16
16
|
export const PREVIEW_TOOL_DESCRIPTION =
|
|
17
17
|
'Publish a local web server or a static directory on a public preview URL so '
|
|
18
|
-
+ 'the user can open it from any device, or list/close
|
|
19
|
-
+ 'this whenever the user wants to see, try, or share something
|
|
20
|
-
+ '— never ask them to open localhost or forward a port. '
|
|
18
|
+
+ 'the user can open it from any device, or list/close this conversation\'s '
|
|
19
|
+
+ 'previews. Use this whenever the user wants to see, try, or share something '
|
|
20
|
+
+ 'with a web UI — never ask them to open localhost or forward a port. '
|
|
21
21
|
+ 'For a dev server: call this FIRST with {action:"create", port} to get the '
|
|
22
22
|
+ 'basePath, then start the server configured to serve under that basePath '
|
|
23
23
|
+ '(Vite --base, Next basePath, Express mount prefix); the proxy forwards '
|
|
@@ -37,7 +37,7 @@ export const PREVIEW_TOOL_INPUT_SCHEMA = {
|
|
|
37
37
|
action: {
|
|
38
38
|
type: 'string',
|
|
39
39
|
enum: ['create', 'list', 'close'],
|
|
40
|
-
description: 'create: publish a port or directory. list: live previews. close: unpublish one.',
|
|
40
|
+
description: 'create: publish a port or directory. list: this conversation\'s live previews. close: unpublish one of them.',
|
|
41
41
|
},
|
|
42
42
|
port: {
|
|
43
43
|
type: 'integer',
|
|
@@ -114,10 +114,19 @@ export async function executePreviewTool(input, { api, conversationId = '' } = {
|
|
|
114
114
|
if (typeof api !== 'function') return { ok: false, error: 'Preview API unavailable in this worker' };
|
|
115
115
|
const parsed = validatePreviewToolInput(input);
|
|
116
116
|
if (!parsed.ok) return parsed;
|
|
117
|
+
// list and close reach only this conversation's previews: a preview's token
|
|
118
|
+
// is its only credential. The relay enforces the same scope for every
|
|
119
|
+
// request that names a conversation (routes/preview-routes.mjs). A create
|
|
120
|
+
// without one would land in no conversation's scope, so it is refused too.
|
|
121
|
+
const conversation = toText(conversationId);
|
|
122
|
+
const scope = `conversationId=${encodeURIComponent(conversation)}`;
|
|
123
|
+
if (!conversation) {
|
|
124
|
+
return { ok: false, error: 'This tool call is not bound to a conversation; it cannot publish, list or close previews' };
|
|
125
|
+
}
|
|
117
126
|
|
|
118
127
|
try {
|
|
119
128
|
if (parsed.action === 'list') {
|
|
120
|
-
const response = await api('GET',
|
|
129
|
+
const response = await api('GET', `/api/previews?${scope}`);
|
|
121
130
|
return {
|
|
122
131
|
ok: true,
|
|
123
132
|
enabled: response?.enabled === true,
|
|
@@ -138,7 +147,7 @@ export async function executePreviewTool(input, { api, conversationId = '' } = {
|
|
|
138
147
|
if (parsed.action === 'close') {
|
|
139
148
|
let token = parsed.token;
|
|
140
149
|
if (!token) {
|
|
141
|
-
const response = await api('GET', `/api/previews
|
|
150
|
+
const response = await api('GET', `/api/previews?${scope}`);
|
|
142
151
|
const live = response?.previews || [];
|
|
143
152
|
if (live.length === 0) return { ok: false, error: 'No live previews in this conversation' };
|
|
144
153
|
if (live.length > 1) {
|
|
@@ -150,12 +159,14 @@ export async function executePreviewTool(input, { api, conversationId = '' } = {
|
|
|
150
159
|
}
|
|
151
160
|
token = live[0].token;
|
|
152
161
|
}
|
|
153
|
-
|
|
162
|
+
// Another conversation's token is refused by the relay (403); its
|
|
163
|
+
// message comes back as the answer.
|
|
164
|
+
const response = await api('DELETE', `/api/previews/${token}?${scope}`);
|
|
154
165
|
return { ok: true, closed: { token, label: response?.preview?.label || null } };
|
|
155
166
|
}
|
|
156
167
|
|
|
157
168
|
const body = {
|
|
158
|
-
conversationId,
|
|
169
|
+
conversationId: conversation,
|
|
159
170
|
label: parsed.label || undefined,
|
|
160
171
|
...(parsed.port ? { port: parsed.port } : { dir: parsed.dir }),
|
|
161
172
|
};
|
|
@@ -176,26 +187,33 @@ export async function executePreviewTool(input, { api, conversationId = '' } = {
|
|
|
176
187
|
: hint,
|
|
177
188
|
};
|
|
178
189
|
} catch (error) {
|
|
179
|
-
|
|
190
|
+
// A scope refusal (403) in its own words, without the HTTP wrapper.
|
|
191
|
+
const refusal = Number(error?.status) === 403 ? toText(error?.detail) : '';
|
|
192
|
+
return { ok: false, error: refusal || String(error?.message || error) };
|
|
180
193
|
}
|
|
181
194
|
}
|
|
182
195
|
|
|
183
196
|
/**
|
|
184
197
|
* The instruction block for providers without a custom-tool surface. Generated
|
|
185
198
|
* from the same description/schema strings the tool uses; keep any wording
|
|
186
|
-
* change up in the constants, never here.
|
|
199
|
+
* change up in the constants, never here. `conversationId` is the conversation
|
|
200
|
+
* the block is sent to: written into every call, so the model's natural call
|
|
201
|
+
* is the scoped one (without it, a plain GET would list the whole relay).
|
|
187
202
|
*/
|
|
188
|
-
export function renderPreviewInstructionBlock({ publicBaseUrl = '' } = {}) {
|
|
203
|
+
export function renderPreviewInstructionBlock({ publicBaseUrl = '', conversationId = '' } = {}) {
|
|
189
204
|
const base = toText(publicBaseUrl) || 'https://<preview-host>';
|
|
205
|
+
const id = toText(conversationId);
|
|
206
|
+
const conv = id ? JSON.stringify(id).slice(1, -1) : '<conv>';
|
|
207
|
+
const convQuery = id ? encodeURIComponent(id) : '<conv>';
|
|
190
208
|
return `## Preview servers
|
|
191
209
|
|
|
192
210
|
${PREVIEW_TOOL_DESCRIPTION}
|
|
193
211
|
|
|
194
212
|
There is no \`${PREVIEW_TOOL_NAME}\` tool on this provider — use the authenticated localhost API (same auth as /api/relay/shutdown):
|
|
195
213
|
|
|
196
|
-
- Publish a running dev server: \`POST /api/previews\` with \`{ "conversationId": "
|
|
197
|
-
- Publish files without a server: \`POST /api/previews\` with \`{ "conversationId": "
|
|
198
|
-
- List: \`GET /api/previews
|
|
214
|
+
- Publish a running dev server: \`POST /api/previews\` with \`{ "conversationId": "${conv}", "port": 5173, "label": "web app" }\` → \`{ "url": "${base}/test_<token>/", "basePath": "/test_<token>/" }\`. Register FIRST, then start the server under the returned basePath.
|
|
215
|
+
- Publish files without a server: \`POST /api/previews\` with \`{ "conversationId": "${conv}", "dir": "./dist", "label": "built site" }\` — the relay serves the directory itself. \`dir\` must live inside the conversation's workspace root.
|
|
216
|
+
- List this conversation's previews: \`GET /api/previews?conversationId=${convQuery}\`. Close one: \`DELETE /api/previews/:token?conversationId=${convQuery}\` — closes the link only, never the dev server behind it. Always pass the conversation: you may list and close only this conversation's previews, another conversation's is refused (403), and the relay-wide list is the user's (Settings → Previews).
|
|
199
217
|
|
|
200
218
|
A 503 with \`details\` means the preview lane is disabled or misconfigured — surface those details to the user. A 400 names exactly what was refused (relay-owned port, non-loopback host, dir outside the workspace root).`;
|
|
201
219
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// What an answer to a question card means, in one place. The relay's answer
|
|
2
|
+
// route (a card that takes no typed text accepts only its choices), the card
|
|
3
|
+
// view (no reply box on such a card; server/public/app/question-answer-rule.mjs
|
|
4
|
+
// is its browser copy) and every reader of an approve / deny
|
|
5
|
+
// card (OAR's own remote-relay approvals, Claude Cloud's and Copilot's
|
|
6
|
+
// permission cards) apply the same rule: an answer is a choice when it equals
|
|
7
|
+
// the choice's label trimmed and in any case.
|
|
8
|
+
|
|
9
|
+
function answerKey(value) {
|
|
10
|
+
return String(value ?? '').trim().toLowerCase();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The card's own label for `answer`, or null when it is none of `choices`. */
|
|
14
|
+
export function matchQuestionChoice(answer, choices) {
|
|
15
|
+
const key = answerKey(answer);
|
|
16
|
+
if (!key || !Array.isArray(choices)) return null;
|
|
17
|
+
for (const choice of choices) {
|
|
18
|
+
const label = String(choice ?? '').trim();
|
|
19
|
+
if (label && answerKey(label) === key) return label;
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* An approve / deny card's answer: 'approve' for its approve choice, 'deny'
|
|
26
|
+
* for its deny choice, null for anything else. A caller decides what null
|
|
27
|
+
* means for its card; it is never the user's plain Deny.
|
|
28
|
+
*/
|
|
29
|
+
export function readApprovalAnswer(answer, { approve, deny }) {
|
|
30
|
+
const key = answerKey(answer);
|
|
31
|
+
if (!key) return null;
|
|
32
|
+
if (key === answerKey(approve)) return 'approve';
|
|
33
|
+
if (key === answerKey(deny)) return 'deny';
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Whether a card takes typed text. A card with choices takes it only when it
|
|
39
|
+
* says so (`allowFreeform: true`); a missing flag counts as false there, as
|
|
40
|
+
* the create route and the relay's question rows read it. Without choices a
|
|
41
|
+
* card always does: it could not be answered otherwise.
|
|
42
|
+
*/
|
|
43
|
+
export function questionAcceptsFreeText(question) {
|
|
44
|
+
const choices = Array.isArray(question?.choices) ? question.choices : [];
|
|
45
|
+
return question?.allowFreeform === true || choices.length === 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The answer a card that takes no typed text accepts, as the card's labels:
|
|
50
|
+
* one choice, or on a multi-select card several, ", "-joined the way the card
|
|
51
|
+
* sends them. null when the answer is not made of the card's choices.
|
|
52
|
+
*/
|
|
53
|
+
export function closedQuestionAnswer({ choices, multiSelect = false } = {}, answer) {
|
|
54
|
+
const single = matchQuestionChoice(answer, choices);
|
|
55
|
+
if (single || !multiSelect) return single;
|
|
56
|
+
const labels = [];
|
|
57
|
+
for (const part of String(answer ?? '').split(',')) {
|
|
58
|
+
const label = matchQuestionChoice(part, choices);
|
|
59
|
+
if (!label) return null;
|
|
60
|
+
if (!labels.includes(label)) labels.push(label);
|
|
61
|
+
}
|
|
62
|
+
return labels.length ? labels.join(', ') : null;
|
|
63
|
+
}
|