@oar-sh/oar 0.9.10 → 0.9.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +314 -1
- package/README.md +156 -972
- package/package.json +13 -13
- package/server/README.md +95 -22
- package/server/claude-worker/claude-session-process.mjs +89 -11
- package/server/claude-worker/claude-session-worker.mjs +9 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +76 -11
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
- package/server/db-schema.mjs +59 -2
- package/server/grok-worker/acp-client.mjs +2 -1
- package/server/grok-worker/acp-host-services.mjs +4 -3
- package/server/migrations/0013-previews.mjs +130 -0
- package/server/migrations/0014-conversation-groups.mjs +89 -0
- package/server/migrations/0015-conversation-group-position.mjs +76 -0
- package/server/migrations/0016-share-access-log.mjs +110 -0
- package/server/public/app/about-ui.js +73 -0
- package/server/public/app/about-view.mjs +234 -0
- package/server/public/app/action-confirmations.js +31 -14
- package/server/public/app/agent-exchange-fold-view.mjs +40 -101
- package/server/public/app/api-client.js +251 -16
- package/server/public/app/ask-user-view.js +17 -15
- package/server/public/app/bootstrap.js +158 -32
- package/server/public/app/chat-actions-menu-state.mjs +20 -10
- package/server/public/app/context-menu.mjs +90 -6
- package/server/public/app/conversation-group-drag.mjs +191 -0
- package/server/public/app/conversation-group-order.mjs +265 -0
- package/server/public/app/conversation-group-state.mjs +100 -0
- package/server/public/app/conversation-list-drag.mjs +449 -0
- package/server/public/app/conversation-turn-state.mjs +81 -0
- package/server/public/app/conversation-view.js +136 -93
- package/server/public/app/cwd-picker.js +1 -1
- package/server/public/app/hidden-messages-view.mjs +312 -0
- package/server/public/app/host-suspend-ui.js +7 -1
- package/server/public/app/journal-view.js +787 -22
- package/server/public/app/preview-cards.mjs +117 -31
- package/server/public/app/preview-command.mjs +10 -1
- package/server/public/app/relay-board-view.js +19 -6
- package/server/public/app/relay-restart-error.mjs +16 -0
- package/server/public/app/settings-modal.js +40 -8
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/share-format.mjs +49 -0
- package/server/public/app/share-preview-marks.mjs +167 -0
- package/server/public/app/share-state.js +109 -0
- package/server/public/app/shared-conversations-settings.js +480 -0
- package/server/public/app/socket-handlers.js +80 -14
- package/server/public/app/store.js +96 -30
- package/server/public/app/subagent-row.mjs +3 -3
- package/server/public/app/subagent-viewer.mjs +30 -6
- package/server/public/app/thinking-follow.js +0 -4
- package/server/public/app/transcript-jump-bottom-view.mjs +194 -20
- package/server/public/app/transcript-jump-bottom.mjs +132 -21
- package/server/public/app/transcript-viewport-anchor.mjs +140 -0
- package/server/public/index.html +386 -15
- package/server/relay-tools.md +2 -1
- package/server/relay.mjs +2 -1
- package/server/repositories/conversation-group-repository.mjs +444 -0
- package/server/repositories/message-repository.mjs +1 -0
- package/server/repositories/session-repository.mjs +12 -37
- package/server/repositories/share-repository.mjs +322 -0
- package/server/repositories/share-visibility-statements.mjs +10 -0
- package/server/routes/about-routes.mjs +15 -0
- package/server/routes/cache-routes.mjs +4 -0
- package/server/routes/conversation-group-routes.mjs +171 -0
- package/server/routes/messages-routes.mjs +184 -65
- package/server/routes/preview-routes.mjs +9 -1
- package/server/routes/sessions-routes.mjs +771 -193
- package/server/server-runtime.mjs +316 -100
- package/server/services/about-service.mjs +461 -0
- package/server/services/background-task-store.mjs +128 -0
- package/server/services/background-work-view.mjs +228 -0
- package/server/services/cache-rebuild-service.mjs +30 -0
- package/server/services/delete-archive-service.mjs +4 -1
- package/server/services/detached-worker-watch-service.mjs +150 -0
- package/server/services/hidden-message-list.mjs +21 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/message-pin-list.mjs +1 -1
- package/server/services/preview-health-probe.mjs +8 -1
- package/server/services/preview-proxy-server.mjs +8 -4
- package/server/services/preview-registry-service.mjs +676 -26
- package/server/services/preview-static-handler.mjs +40 -7
- package/server/services/preview-store.mjs +274 -0
- package/server/services/rate-limit-service.mjs +12 -1
- package/server/services/relay-auth.mjs +183 -0
- package/server/services/relay-cli-launcher-service.mjs +3 -1
- package/server/services/relay-event-service.mjs +17 -0
- package/server/services/remote-relay-dispatcher.mjs +57 -5
- package/server/services/request-boundary.mjs +183 -0
- package/server/services/session-activity-tracker.mjs +227 -19
- package/server/services/session-worker-launch-service.mjs +190 -6
- package/server/services/session-worker-process-service.mjs +10 -6
- package/server/services/share-access-log-service.mjs +123 -0
- package/server/services/share-path-redaction.mjs +424 -0
- package/server/services/status-event-service.mjs +7 -3
- package/server/services/tunnel-worker-path-guard.mjs +24 -41
- package/server/services/windows-autostart-service.mjs +6 -1
- package/server/services/windows-boot-autostart-service.mjs +207 -23
- package/shared/preview-tool-core.mjs +15 -5
- package/shared/remote-relay-contract.mjs +3 -1
- package/shared/share-path-privacy.mjs +30 -0
- package/shared/windows-system-tools.mjs +106 -0
- package/shared/worker-runtime/background-task-digest.mjs +30 -0
- package/shared/worker-runtime/heartbeat.mjs +24 -0
- package/shared/worker-runtime/kept-posts.mjs +83 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
|
@@ -3,27 +3,7 @@
|
|
|
3
3
|
import { URL } from 'url';
|
|
4
4
|
|
|
5
5
|
import { buildAcceptedWorkerPaths } from './session-worker-websocket-service.mjs';
|
|
6
|
-
|
|
7
|
-
// Cloudflare's edge stamps `cf-ray` on every proxied request and a remote client
|
|
8
|
-
// cannot strip it, so its presence means the request came in over the tunnel
|
|
9
|
-
// rather than from a local session worker on 127.0.0.1.
|
|
10
|
-
const DEFAULT_TUNNEL_MARKER_HEADERS = ['cf-ray'];
|
|
11
|
-
|
|
12
|
-
function normalizeHeaderName(value) {
|
|
13
|
-
return String(value || '').trim().toLowerCase();
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function normalizeMarkerHeaders(extraHeaders) {
|
|
17
|
-
const names = [...DEFAULT_TUNNEL_MARKER_HEADERS];
|
|
18
|
-
const extras = Array.isArray(extraHeaders)
|
|
19
|
-
? extraHeaders
|
|
20
|
-
: String(extraHeaders || '').split(',');
|
|
21
|
-
for (const entry of extras) {
|
|
22
|
-
const name = normalizeHeaderName(entry);
|
|
23
|
-
if (name && !names.includes(name)) names.push(name);
|
|
24
|
-
}
|
|
25
|
-
return names;
|
|
26
|
-
}
|
|
6
|
+
import { createLocalRequestCheck, resolveTunnelMarkerHeaders } from './request-boundary.mjs';
|
|
27
7
|
|
|
28
8
|
function requestPathname(req) {
|
|
29
9
|
const rawUrl = String(req?.url || '/');
|
|
@@ -35,18 +15,24 @@ function requestPathname(req) {
|
|
|
35
15
|
}
|
|
36
16
|
|
|
37
17
|
/**
|
|
38
|
-
* Blocks session-worker WebSocket paths
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
18
|
+
* Blocks session-worker WebSocket paths for every request that is not truly
|
|
19
|
+
* local (request-boundary.mjs): a peer that is not loopback, an edge marker
|
|
20
|
+
* header, or a forwarding header. Session workers are spawned by the relay on
|
|
21
|
+
* its own machine and connect over 127.0.0.1 without any of these. The guard
|
|
22
|
+
* keys off *the path being a worker path*, never off "unauthenticated request
|
|
23
|
+
* bearing cf-ray" — shared conversations legitimately arrive over Cloudflare
|
|
24
|
+
* with no credentials and must keep working.
|
|
42
25
|
*/
|
|
43
26
|
export function createTunnelWorkerPathGuard({
|
|
44
27
|
pathPrefix = '',
|
|
45
28
|
extraMarkerHeaders = [],
|
|
29
|
+
localRequestCheck = null,
|
|
46
30
|
logger = console,
|
|
47
31
|
} = {}) {
|
|
48
32
|
const workerPaths = new Set(buildAcceptedWorkerPaths(pathPrefix));
|
|
49
|
-
const
|
|
33
|
+
const localCheck = localRequestCheck
|
|
34
|
+
|| createLocalRequestCheck({ markerHeaders: resolveTunnelMarkerHeaders(extraMarkerHeaders) });
|
|
35
|
+
const markerHeaders = localCheck.markerHeaders;
|
|
50
36
|
|
|
51
37
|
function isWorkerPath(pathname) {
|
|
52
38
|
if (workerPaths.has(pathname)) return true;
|
|
@@ -57,21 +43,18 @@ export function createTunnelWorkerPathGuard({
|
|
|
57
43
|
return false;
|
|
58
44
|
}
|
|
59
45
|
|
|
60
|
-
function
|
|
61
|
-
if (!
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (typeof value === 'string' ? value.trim() : value) return name;
|
|
65
|
-
}
|
|
66
|
-
return null;
|
|
46
|
+
function describeRefusal(refusal) {
|
|
47
|
+
if (!refusal) return null;
|
|
48
|
+
if (refusal.reason === 'peer') return 'a non-loopback peer';
|
|
49
|
+
return refusal.header;
|
|
67
50
|
}
|
|
68
51
|
|
|
69
|
-
function evaluate(req) {
|
|
52
|
+
function evaluate(req, socket = null) {
|
|
70
53
|
const pathname = requestPathname(req);
|
|
71
54
|
if (!isWorkerPath(pathname)) return { blocked: false, pathname, marker: null };
|
|
72
|
-
const
|
|
73
|
-
if (!
|
|
74
|
-
return { blocked: true, pathname, marker };
|
|
55
|
+
const refusal = localCheck.explain({ headers: req?.headers, socket: req?.socket || socket });
|
|
56
|
+
if (!refusal) return { blocked: false, pathname, marker: null };
|
|
57
|
+
return { blocked: true, pathname, marker: describeRefusal(refusal), reason: refusal.reason };
|
|
75
58
|
}
|
|
76
59
|
|
|
77
60
|
// Express middleware: 403 the request before any auth middleware runs.
|
|
@@ -81,15 +64,15 @@ export function createTunnelWorkerPathGuard({
|
|
|
81
64
|
next();
|
|
82
65
|
return;
|
|
83
66
|
}
|
|
84
|
-
logger?.warn?.(`[tunnel-guard] Blocked session-worker path ${result.pathname}
|
|
85
|
-
res.status(403).json({ error: 'Session worker endpoints are
|
|
67
|
+
logger?.warn?.(`[tunnel-guard] Blocked session-worker path ${result.pathname}: not local (${result.marker})`);
|
|
68
|
+
res.status(403).json({ error: 'Session worker endpoints are reachable from this machine only' });
|
|
86
69
|
}
|
|
87
70
|
|
|
88
71
|
// Raw upgrade guard: destroy the socket before the WebSocket handshake.
|
|
89
72
|
function handleUpgrade(req, socket) {
|
|
90
|
-
const result = evaluate(req);
|
|
73
|
+
const result = evaluate(req, socket);
|
|
91
74
|
if (!result.blocked) return false;
|
|
92
|
-
logger?.warn?.(`[tunnel-guard] Blocked session-worker upgrade ${result.pathname}
|
|
75
|
+
logger?.warn?.(`[tunnel-guard] Blocked session-worker upgrade ${result.pathname}: not local (${result.marker})`);
|
|
93
76
|
try {
|
|
94
77
|
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
|
|
95
78
|
} catch {}
|
|
@@ -14,7 +14,12 @@ export function escapeBatchValue(value) {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
export function resolveWindowsStartupDirectory(env = process.env, pathImpl = path) {
|
|
17
|
-
|
|
17
|
+
// A relay started without a full profile (the boot task) may lack APPDATA;
|
|
18
|
+
// the roaming folder then is where resolveOarRoot looks too.
|
|
19
|
+
const appData = String(env?.APPDATA || '').trim()
|
|
20
|
+
|| (String(env?.USERPROFILE || '').trim()
|
|
21
|
+
? pathImpl.join(String(env.USERPROFILE).trim(), 'AppData', 'Roaming')
|
|
22
|
+
: '');
|
|
18
23
|
if (!appData) {
|
|
19
24
|
throw new Error('Windows autostart requires the current user APPDATA directory.');
|
|
20
25
|
}
|
|
@@ -3,6 +3,11 @@ 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 {
|
|
7
|
+
describeExecError,
|
|
8
|
+
describeSpawnEnvironment,
|
|
9
|
+
windowsSystemToolPath,
|
|
10
|
+
} from '../../shared/windows-system-tools.mjs';
|
|
6
11
|
import {
|
|
7
12
|
LEGACY_WINDOWS_AUTOSTART_FILENAME,
|
|
8
13
|
WINDOWS_AUTOSTART_FILENAME,
|
|
@@ -40,7 +45,13 @@ export const LEGACY_BOOT_TASK_NAME = 'copilot-remote-relay';
|
|
|
40
45
|
export const BOOT_LAUNCHER_FILENAME = 'oar-web-relay-launcher.cmd';
|
|
41
46
|
export const BOOT_TASK_XML_FILENAME = 'oar-relay-task.xml';
|
|
42
47
|
export const BOOT_ELEVATION_SCRIPT_FILENAME = 'oar-relay-elevate.ps1';
|
|
48
|
+
// Where a regenerated launcher keeps the one it replaced, when they differ.
|
|
49
|
+
export const BOOT_LAUNCHER_BACKUP_SUFFIX = '.bak';
|
|
43
50
|
const ELEVATION_TIMEOUT_MS = 120_000;
|
|
51
|
+
export const BOOT_TEMP_WAIT_SECONDS = 120;
|
|
52
|
+
export const BOOT_LAUNCHER_LOG_FILENAME = 'oar-web-relay-launcher.log';
|
|
53
|
+
// Each schtasks query; the settings page waits on two of them.
|
|
54
|
+
const QUERY_TIMEOUT_MS = 6_000;
|
|
44
55
|
|
|
45
56
|
function normalizeText(value) {
|
|
46
57
|
return String(value == null ? '' : value).trim();
|
|
@@ -58,6 +69,19 @@ function escapeXml(value) {
|
|
|
58
69
|
/**
|
|
59
70
|
* The launcher the task executes. Same shape as the sign-in .cmd minus the
|
|
60
71
|
* `title` line: a boot task runs in session 0 where no console is ever shown.
|
|
72
|
+
*
|
|
73
|
+
* Node's folder goes at the end of PATH. The relay itself runs on the
|
|
74
|
+
* absolute path below, but a boot task's PATH can lack the user's Node (a
|
|
75
|
+
* version manager's folder, an entry that refers to an unexpanded variable),
|
|
76
|
+
* and the CLIs and commands the workers start may call a bare `node`. At the
|
|
77
|
+
* end it only matters when PATH has no Node of its own.
|
|
78
|
+
*
|
|
79
|
+
* Before Node starts, the launcher waits up to BOOT_TEMP_WAIT_SECONDS for the
|
|
80
|
+
* %TEMP% folder: at boot it can live on a drive that a startup script creates
|
|
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.
|
|
61
85
|
*/
|
|
62
86
|
export function buildBootLauncherScript({
|
|
63
87
|
packageRoot,
|
|
@@ -78,10 +102,12 @@ export function buildBootLauncherScript({
|
|
|
78
102
|
}
|
|
79
103
|
const resolvedPackageRoot = pathImpl.resolve(String(packageRoot));
|
|
80
104
|
const serverPath = pathImpl.join(resolvedPackageRoot, 'server', 'server.js');
|
|
105
|
+
const resolvedNodePath = pathImpl.resolve(nodePath);
|
|
81
106
|
const lines = [
|
|
82
107
|
'@echo off',
|
|
83
108
|
'rem Generated by OAR settings (system-startup autostart). Regenerated on',
|
|
84
109
|
'rem every settings change - do not edit by hand.',
|
|
110
|
+
`set "PATH=%PATH%;${escapeBatchValue(pathImpl.dirname(resolvedNodePath))}"`,
|
|
85
111
|
];
|
|
86
112
|
if (normalizeText(configPath)) {
|
|
87
113
|
lines.push(`set "COPILOT_WEB_RELAY_CONFIG=${escapeBatchValue(pathImpl.resolve(configPath))}"`);
|
|
@@ -90,13 +116,36 @@ export function buildBootLauncherScript({
|
|
|
90
116
|
? pathImpl.resolve(String(workingDirectory))
|
|
91
117
|
: resolvedPackageRoot;
|
|
92
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="',
|
|
93
133
|
`cd /d "${escapeBatchValue(cwdTarget)}"`,
|
|
94
|
-
`"${escapeBatchValue(
|
|
134
|
+
`"${escapeBatchValue(resolvedNodePath)}" "${escapeBatchValue(serverPath)}"`,
|
|
95
135
|
'',
|
|
96
136
|
);
|
|
97
137
|
return lines.join('\r\n');
|
|
98
138
|
}
|
|
99
139
|
|
|
140
|
+
function unescapeXml(value) {
|
|
141
|
+
return String(value)
|
|
142
|
+
.replaceAll('"', '"')
|
|
143
|
+
.replaceAll(''', "'")
|
|
144
|
+
.replaceAll('<', '<')
|
|
145
|
+
.replaceAll('>', '>')
|
|
146
|
+
.replaceAll('&', '&');
|
|
147
|
+
}
|
|
148
|
+
|
|
100
149
|
/**
|
|
101
150
|
* Task Scheduler XML, settings ported verbatim from the proven manual task.
|
|
102
151
|
* The S4U principal is registered by SID, not account name: a by-name
|
|
@@ -176,17 +225,64 @@ export function parseTaskXml(xmlText) {
|
|
|
176
225
|
const text = String(xmlText == null ? '' : xmlText);
|
|
177
226
|
const grab = (tag) => {
|
|
178
227
|
const match = text.match(new RegExp(`<${tag}>([^<]*)</${tag}>`));
|
|
179
|
-
return match ? match[1].trim() : null;
|
|
228
|
+
return match ? unescapeXml(match[1]).trim() : null;
|
|
180
229
|
};
|
|
230
|
+
const bootTrigger = text.match(/<BootTrigger>([\s\S]*?)<\/BootTrigger>/);
|
|
231
|
+
const bootDelay = bootTrigger ? (bootTrigger[1].match(/<Delay>([^<]*)<\/Delay>/)?.[1]?.trim() || null) : null;
|
|
181
232
|
return {
|
|
182
233
|
command: grab('Command'),
|
|
183
234
|
argumentsLine: grab('Arguments'),
|
|
184
235
|
workingDirectory: grab('WorkingDirectory'),
|
|
185
236
|
logonType: grab('LogonType'),
|
|
186
237
|
userId: grab('UserId'),
|
|
238
|
+
hasBootTrigger: Boolean(bootTrigger),
|
|
239
|
+
bootDelay,
|
|
187
240
|
};
|
|
188
241
|
}
|
|
189
242
|
|
|
243
|
+
function sameText(a, b) {
|
|
244
|
+
return normalizeText(a).toLowerCase() === normalizeText(b).toLowerCase();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Windows paths: no case, either slash, no trailing separator.
|
|
248
|
+
function samePath(a, b) {
|
|
249
|
+
const normalize = (value) => normalizeText(value).replaceAll('/', '\\').replace(/\\+$/, '').toLowerCase();
|
|
250
|
+
return normalize(a) === normalize(b);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* How a task that runs our launcher differs from the one buildBootTaskXml
|
|
255
|
+
* registers, as short names (empty when it does not). Only what the generated
|
|
256
|
+
* XML sets is compared: Task Scheduler adds its own defaults on export, and
|
|
257
|
+
* the run level, for one, is not set by OAR.
|
|
258
|
+
*/
|
|
259
|
+
export function describeTaskCustomisations(xmlText, { launcherPath, workingDirectory } = {}) {
|
|
260
|
+
const parsed = parseTaskXml(xmlText);
|
|
261
|
+
const differences = [];
|
|
262
|
+
if (!sameText(parsed.command, 'C:\\Windows\\System32\\cmd.exe')
|
|
263
|
+
|| normalizeText(parsed.argumentsLine).replace(/\s+/g, ' ').toLowerCase()
|
|
264
|
+
!== `/d /c "${normalizeText(launcherPath)}"`.toLowerCase()) {
|
|
265
|
+
differences.push('action');
|
|
266
|
+
}
|
|
267
|
+
if (normalizeText(workingDirectory) && !samePath(parsed.workingDirectory, workingDirectory)) {
|
|
268
|
+
differences.push('working-directory');
|
|
269
|
+
}
|
|
270
|
+
if (!sameText(parsed.logonType, 'S4U')) differences.push('logon-type');
|
|
271
|
+
if (!parsed.hasBootTrigger) differences.push('trigger');
|
|
272
|
+
else if (!sameText(parsed.bootDelay, 'PT30S')) differences.push('boot-delay');
|
|
273
|
+
return differences;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Same launcher, whatever the line endings and trailing blanks. */
|
|
277
|
+
export function launcherScriptsMatch(actual, expected) {
|
|
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
|
+
}
|
|
285
|
+
|
|
190
286
|
/**
|
|
191
287
|
* Ours or someone else's? A task counts as ours when its action runs our
|
|
192
288
|
* launcher path; anything else under our name is `foreign` and the UI offers
|
|
@@ -206,23 +302,25 @@ export function classifyTaskXml(xmlText, { launcherPath } = {}) {
|
|
|
206
302
|
* and nothing else — in one script, so one UAC covers the whole transition
|
|
207
303
|
* out of any mixed state (legacy-named task, hand-made task, fresh install).
|
|
208
304
|
*/
|
|
209
|
-
export function buildElevationScript({ action, xmlPath } = {}) {
|
|
305
|
+
export function buildElevationScript({ action, xmlPath, schtasksPath = 'schtasks' } = {}) {
|
|
306
|
+
// PowerShell runs a quoted path only behind the call operator.
|
|
307
|
+
const schtasks = /[\\/]/.test(schtasksPath) ? `& "${schtasksPath}"` : schtasksPath;
|
|
210
308
|
if (action === 'enable') {
|
|
211
309
|
if (!normalizeText(xmlPath)) {
|
|
212
310
|
throw new Error('The enable sweep needs the task XML path.');
|
|
213
311
|
}
|
|
214
312
|
return [
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
313
|
+
`${schtasks} /delete /tn "${LEGACY_BOOT_TASK_NAME}" /f 2>$null`,
|
|
314
|
+
`${schtasks} /delete /tn "${BOOT_TASK_NAME}" /f 2>$null`,
|
|
315
|
+
`${schtasks} /create /tn "${BOOT_TASK_NAME}" /xml "${xmlPath}"`,
|
|
218
316
|
'exit $LASTEXITCODE',
|
|
219
317
|
'',
|
|
220
318
|
].join('\r\n');
|
|
221
319
|
}
|
|
222
320
|
if (action === 'disable') {
|
|
223
321
|
return [
|
|
224
|
-
|
|
225
|
-
|
|
322
|
+
`${schtasks} /delete /tn "${BOOT_TASK_NAME}" /f 2>$null`,
|
|
323
|
+
`${schtasks} /delete /tn "${LEGACY_BOOT_TASK_NAME}" /f 2>$null`,
|
|
226
324
|
'exit 0',
|
|
227
325
|
'',
|
|
228
326
|
].join('\r\n');
|
|
@@ -236,13 +334,19 @@ export function parseSidFromWhoami(stdout) {
|
|
|
236
334
|
return match ? match[1] : null;
|
|
237
335
|
}
|
|
238
336
|
|
|
337
|
+
// `startError` is set when the program did not run at all (not found, timed
|
|
338
|
+
// out): unlike an exit code, that says nothing about the task.
|
|
239
339
|
function defaultExecFileImpl(file, args) {
|
|
240
340
|
return new Promise((resolve) => {
|
|
241
|
-
execFile(file, args, { windowsHide: true }, (error, stdout, stderr) => {
|
|
341
|
+
execFile(file, args, { windowsHide: true, timeout: QUERY_TIMEOUT_MS }, (error, stdout, stderr) => {
|
|
342
|
+
const exitCode = typeof error?.code === 'number' ? error.code : null;
|
|
242
343
|
resolve({
|
|
243
|
-
code: error ? (
|
|
344
|
+
code: error ? (exitCode ?? 1) : 0,
|
|
244
345
|
stdout: String(stdout || ''),
|
|
245
346
|
stderr: String(stderr || ''),
|
|
347
|
+
startError: error && (exitCode === null || error.killed)
|
|
348
|
+
? describeExecError(error)
|
|
349
|
+
: null,
|
|
246
350
|
});
|
|
247
351
|
});
|
|
248
352
|
});
|
|
@@ -262,12 +366,16 @@ export function createWindowsBootAutostartService({
|
|
|
262
366
|
clearTimeoutImpl = clearTimeout,
|
|
263
367
|
now = () => Date.now(),
|
|
264
368
|
logger = console,
|
|
369
|
+
// Full path of a Windows program (schtasks, whoami, powershell).
|
|
370
|
+
systemToolPath = (name) => windowsSystemToolPath(name, { env }),
|
|
265
371
|
} = {}) {
|
|
266
372
|
const supported = platform === 'win32';
|
|
267
373
|
// One elevation in flight at a time; getState() reports it and the
|
|
268
374
|
// completion callback resolves it.
|
|
269
375
|
let pending = null; // { action, startedAt, timer, child }
|
|
270
376
|
let lastError = null;
|
|
377
|
+
// The launcher an enable replaced, kept beside it (see requestEnable).
|
|
378
|
+
let launcherBackupPath = null;
|
|
271
379
|
|
|
272
380
|
const oarRoot = () => resolveOarRoot(env, platform);
|
|
273
381
|
const launcherPath = () => pathImpl.join(oarRoot(), BOOT_LAUNCHER_FILENAME);
|
|
@@ -278,9 +386,54 @@ export function createWindowsBootAutostartService({
|
|
|
278
386
|
try { logger?.log?.(`[boot-autostart] ${message}`); } catch {}
|
|
279
387
|
}
|
|
280
388
|
|
|
389
|
+
const tool = (name) => systemToolPath(name);
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* The task's XML, null when there is no such task, or `{ readError }` when
|
|
393
|
+
* schtasks itself could not run: that says nothing about the task, so it
|
|
394
|
+
* must not read as "missing".
|
|
395
|
+
*/
|
|
281
396
|
async function queryTaskXml(taskName) {
|
|
282
|
-
|
|
283
|
-
|
|
397
|
+
let result;
|
|
398
|
+
try {
|
|
399
|
+
result = await execFileImpl(tool('schtasks'), ['/query', '/tn', taskName, '/xml']);
|
|
400
|
+
} catch (error) {
|
|
401
|
+
result = { code: 1, stdout: '', stderr: '', startError: describeExecError(error) };
|
|
402
|
+
}
|
|
403
|
+
if (result?.startError) {
|
|
404
|
+
log(`schtasks /query ${taskName} did not run: ${result.startError}; ${describeSpawnEnvironment(env, platform)}`);
|
|
405
|
+
// The details stay in the log (they can name folders on the host).
|
|
406
|
+
return { readError: 'schtasks could not be run; the relay log has the details.' };
|
|
407
|
+
}
|
|
408
|
+
return result?.code === 0 ? result.stdout : null;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function readLauncher() {
|
|
412
|
+
try {
|
|
413
|
+
return fsImpl.existsSync(launcherPath()) ? String(fsImpl.readFileSync(launcherPath(), 'utf8')) : null;
|
|
414
|
+
} catch {
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function generatedLauncher() {
|
|
420
|
+
return buildBootLauncherScript({ packageRoot, nodePath, configPath, workingDirectory: oarRoot(), pathImpl });
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** What differs from the generated task and launcher, for a task that runs our launcher. */
|
|
424
|
+
function customisationsOf(xmlText) {
|
|
425
|
+
const differences = describeTaskCustomisations(xmlText, {
|
|
426
|
+
launcherPath: launcherPath(),
|
|
427
|
+
workingDirectory: oarRoot(),
|
|
428
|
+
});
|
|
429
|
+
const launcher = readLauncher();
|
|
430
|
+
if (launcher === null) differences.push('launcher-missing');
|
|
431
|
+
else {
|
|
432
|
+
try {
|
|
433
|
+
if (!launcherScriptsMatch(launcher, generatedLauncher())) differences.push('launcher');
|
|
434
|
+
} catch {}
|
|
435
|
+
}
|
|
436
|
+
return differences;
|
|
284
437
|
}
|
|
285
438
|
|
|
286
439
|
function startupCmdPaths() {
|
|
@@ -338,16 +491,29 @@ export function createWindowsBootAutostartService({
|
|
|
338
491
|
}
|
|
339
492
|
const ownXml = await queryTaskXml(BOOT_TASK_NAME);
|
|
340
493
|
const legacyXml = await queryTaskXml(LEGACY_BOOT_TASK_NAME);
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
494
|
+
const readError = ownXml?.readError || null;
|
|
495
|
+
const legacyKnown = typeof legacyXml === 'string';
|
|
496
|
+
let taskStatus;
|
|
497
|
+
if (readError) taskStatus = 'unknown';
|
|
498
|
+
else if (ownXml === null) taskStatus = legacyKnown ? 'legacy' : 'missing';
|
|
499
|
+
else taskStatus = classifyTaskXml(ownXml, { launcherPath: launcherPath() });
|
|
500
|
+
// A task that runs our launcher but is not quite the one OAR registers (no
|
|
501
|
+
// boot delay, an edited launcher) still is boot mode; it is reported,
|
|
502
|
+
// never failed on.
|
|
503
|
+
const customisations = taskStatus === 'ready' ? customisationsOf(ownXml) : [];
|
|
504
|
+
let launcherPresent = false;
|
|
505
|
+
try { launcherPresent = fsImpl.existsSync(launcherPath()); } catch {}
|
|
344
506
|
return {
|
|
345
507
|
supported: true,
|
|
346
508
|
platform,
|
|
347
509
|
taskStatus,
|
|
348
510
|
taskName: BOOT_TASK_NAME,
|
|
349
|
-
legacyTaskPresent:
|
|
350
|
-
launcherPath:
|
|
511
|
+
legacyTaskPresent: legacyKnown,
|
|
512
|
+
launcherPath: launcherPresent ? launcherPath() : null,
|
|
513
|
+
customised: customisations.length > 0,
|
|
514
|
+
customisations,
|
|
515
|
+
launcherBackupPath,
|
|
516
|
+
readError,
|
|
351
517
|
pendingElevation: pending ? pending.action : null,
|
|
352
518
|
lastError,
|
|
353
519
|
manualCommand: manualCommandFor(pending?.action || 'enable'),
|
|
@@ -374,12 +540,13 @@ export function createWindowsBootAutostartService({
|
|
|
374
540
|
* script, so its exit is the "elevation finished (or was declined)" signal.
|
|
375
541
|
*/
|
|
376
542
|
function spawnElevated(action, onSettled) {
|
|
377
|
-
const
|
|
543
|
+
const powershell = tool('powershell.exe');
|
|
544
|
+
const child = spawnImpl(powershell, [
|
|
378
545
|
'-NoProfile',
|
|
379
546
|
'-NonInteractive',
|
|
380
547
|
'-Command',
|
|
381
548
|
[
|
|
382
|
-
|
|
549
|
+
`$p = Start-Process -FilePath '${powershell.replaceAll("'", "''")}' -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList`,
|
|
383
550
|
`'-NoProfile','-ExecutionPolicy','Bypass','-File','${elevationScriptPath()}'`,
|
|
384
551
|
'; exit $p.ExitCode',
|
|
385
552
|
].join(' '),
|
|
@@ -425,8 +592,24 @@ export function createWindowsBootAutostartService({
|
|
|
425
592
|
return { ...(await getState()), accepted: false };
|
|
426
593
|
}
|
|
427
594
|
lastError = null;
|
|
428
|
-
|
|
429
|
-
const
|
|
595
|
+
const launcher = generatedLauncher();
|
|
596
|
+
const previous = readLauncher();
|
|
597
|
+
if (previous !== null && !launcherScriptsMatch(previous, launcher)) {
|
|
598
|
+
// An edited (or older) launcher is replaced, but kept: one copy, the
|
|
599
|
+
// latest replaced one.
|
|
600
|
+
const backupPath = `${launcherPath()}${BOOT_LAUNCHER_BACKUP_SUFFIX}`;
|
|
601
|
+
try {
|
|
602
|
+
fsImpl.writeFileSync(backupPath, previous);
|
|
603
|
+
launcherBackupPath = backupPath;
|
|
604
|
+
log(`the launcher differed from the generated one; saved it as ${backupPath}`);
|
|
605
|
+
} catch (error) {
|
|
606
|
+
log(`could not save the current launcher as ${backupPath}: ${error?.message || error}`);
|
|
607
|
+
lastError = 'Could not keep a copy of the current launcher next to it, so it was not replaced; the relay log has the details.';
|
|
608
|
+
return { ...(await getState()), accepted: false };
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
writeFileAtomic(launcherPath(), launcher);
|
|
612
|
+
const who = await execFileImpl(tool('whoami'), ['/user', '/fo', 'csv']);
|
|
430
613
|
const sid = parseSidFromWhoami(who.stdout);
|
|
431
614
|
if (!sid) {
|
|
432
615
|
lastError = 'Could not determine the current user SID (whoami /user failed).';
|
|
@@ -438,7 +621,7 @@ export function createWindowsBootAutostartService({
|
|
|
438
621
|
packageRoot: pathImpl.resolve(String(packageRoot)),
|
|
439
622
|
workingDirectory: oarRoot(),
|
|
440
623
|
})));
|
|
441
|
-
writeFileAtomic(elevationScriptPath(), buildElevationScript({ action: 'enable', xmlPath: xmlPath() }));
|
|
624
|
+
writeFileAtomic(elevationScriptPath(), buildElevationScript({ action: 'enable', xmlPath: xmlPath(), schtasksPath: tool('schtasks') }));
|
|
442
625
|
spawnElevated('enable', (ok) => {
|
|
443
626
|
if (ok) {
|
|
444
627
|
// Mutual exclusion: boot mode owns startup now, so the sign-in
|
|
@@ -463,7 +646,8 @@ export function createWindowsBootAutostartService({
|
|
|
463
646
|
return { ...(await getState()), accepted: false };
|
|
464
647
|
}
|
|
465
648
|
lastError = null;
|
|
466
|
-
|
|
649
|
+
launcherBackupPath = null;
|
|
650
|
+
writeFileAtomic(elevationScriptPath(), buildElevationScript({ action: 'disable', schtasksPath: tool('schtasks') }));
|
|
467
651
|
spawnElevated('disable', (ok) => {
|
|
468
652
|
if (ok) {
|
|
469
653
|
try {
|
|
@@ -25,8 +25,10 @@ export const PREVIEW_TOOL_DESCRIPTION =
|
|
|
25
25
|
+ 'paths will not work. For plain files or a build output: pass {action:'
|
|
26
26
|
+ '"create", dir} instead and the relay serves the directory itself — no dev '
|
|
27
27
|
+ 'server needed. Always tell the user the returned URL and that the link is '
|
|
28
|
-
+ 'public: anyone who has it can reach the app without logging in.
|
|
29
|
-
+ '
|
|
28
|
+
+ 'public: anyone who has it can reach the app without logging in. Publishing '
|
|
29
|
+
+ 'the same port or directory again in this conversation returns the same '
|
|
30
|
+
+ 'link. Previews never expire and survive a relay restart (a port preview '
|
|
31
|
+
+ 'until the machine reboots); close them with {action:"close"} when the user '
|
|
30
32
|
+ 'is done.';
|
|
31
33
|
|
|
32
34
|
export const PREVIEW_TOOL_INPUT_SCHEMA = {
|
|
@@ -126,6 +128,9 @@ export async function executePreviewTool(input, { api, conversationId = '' } = {
|
|
|
126
128
|
target: entry.mode === 'static' ? entry.rootDir : `localhost:${entry.targetPort}`,
|
|
127
129
|
online: entry.online,
|
|
128
130
|
conversationId: entry.conversationId,
|
|
131
|
+
// Stored but not served: the relay could not check it after a
|
|
132
|
+
// restart and tries again. It can be closed.
|
|
133
|
+
...(entry.unverified === true ? { unverified: true } : {}),
|
|
129
134
|
})),
|
|
130
135
|
};
|
|
131
136
|
}
|
|
@@ -155,15 +160,20 @@ export async function executePreviewTool(input, { api, conversationId = '' } = {
|
|
|
155
160
|
...(parsed.port ? { port: parsed.port } : { dir: parsed.dir }),
|
|
156
161
|
};
|
|
157
162
|
const response = await api('POST', '/api/previews', body);
|
|
163
|
+
const existing = response?.existing === true;
|
|
164
|
+
const hint = parsed.port
|
|
165
|
+
? `Serve the app under ${response?.basePath} (the proxy strips the prefix and sends X-Forwarded-Prefix). Tell the user the URL and that the link is public.`
|
|
166
|
+
: 'The directory is served as-is; relative asset paths work, root-absolute ones do not. Tell the user the URL and that the link is public.';
|
|
158
167
|
return {
|
|
159
168
|
ok: true,
|
|
169
|
+
existing,
|
|
160
170
|
url: response?.url,
|
|
161
171
|
basePath: response?.basePath,
|
|
162
172
|
token: response?.preview?.token,
|
|
163
173
|
mode: response?.preview?.mode || (parsed.port ? 'port' : 'static'),
|
|
164
|
-
hint:
|
|
165
|
-
? `
|
|
166
|
-
:
|
|
174
|
+
hint: existing
|
|
175
|
+
? `This conversation already publishes this ${parsed.port ? 'port' : 'directory'}: same link as before, label unchanged. ${hint}`
|
|
176
|
+
: hint,
|
|
167
177
|
};
|
|
168
178
|
} catch (error) {
|
|
169
179
|
return { ok: false, error: String(error?.message || error) };
|
|
@@ -217,7 +217,9 @@ export const REMOTE_RELAY_TOOL_DESCRIPTION =
|
|
|
217
217
|
+ 'with cwd, provider "claude-cloud" with repo and an optional branch), then wait for and read '
|
|
218
218
|
+ 'each one. The first create_session here shows the user an approval card once per '
|
|
219
219
|
+ 'conversation. A session you created cannot create sessions itself, and every session '
|
|
220
|
-
+ 'runs on the relay owner\'s accounts, so start only what the task needs.
|
|
220
|
+
+ 'runs on the relay owner\'s accounts, so start only what the task needs. Sessions you start '
|
|
221
|
+
+ 'here are grouped under this session in the owner\'s sidebar; list_sessions and read_session '
|
|
222
|
+
+ 'show the group (read-only for agents, the owner arranges groups). A new session '
|
|
221
223
|
+ 'knows nothing of this conversation: write a self-contained prompt, and for '
|
|
222
224
|
+ '"claude-cloud" (a sandbox with a fresh clone of the repository) name the branch to '
|
|
223
225
|
+ 'work on and to push. '
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// What a shared conversation shows of the relay host's paths and file names.
|
|
2
|
+
// One relay-wide setting; the owner's own view is never filtered. Shared so the
|
|
3
|
+
// settings route, the share routes and the Settings control agree.
|
|
4
|
+
|
|
5
|
+
export const SHARE_PATH_PRIVACY_SETTING_KEY = 'share_path_privacy';
|
|
6
|
+
|
|
7
|
+
export const SHARE_PATH_PRIVACY_MODES = Object.freeze(['show', 'filename', 'hide']);
|
|
8
|
+
export const DEFAULT_SHARE_PATH_PRIVACY_MODE = 'filename';
|
|
9
|
+
|
|
10
|
+
export const SHARE_PATH_PRIVACY_LABELS = Object.freeze({
|
|
11
|
+
show: 'Show every path and file name',
|
|
12
|
+
filename: 'Hide the path, show the file name',
|
|
13
|
+
hide: 'Hide paths and file names',
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
function asMode(value) {
|
|
17
|
+
const mode = String(value ?? '').trim().toLowerCase();
|
|
18
|
+
return SHARE_PATH_PRIVACY_MODES.includes(mode) ? mode : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** An unset or unreadable setting means the default, never "show". */
|
|
22
|
+
export function readSharePathPrivacySetting(storedValue) {
|
|
23
|
+
return asMode(storedValue) || DEFAULT_SHARE_PATH_PRIVACY_MODE;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function parseSharePathPrivacyUpdate(value) {
|
|
27
|
+
const mode = asMode(value);
|
|
28
|
+
if (!mode) return { ok: false, error: `mode must be one of ${SHARE_PATH_PRIVACY_MODES.join(', ')}` };
|
|
29
|
+
return { ok: true, mode };
|
|
30
|
+
}
|