@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.
Files changed (104) hide show
  1. package/CHANGELOG.md +314 -1
  2. package/README.md +156 -972
  3. package/package.json +13 -13
  4. package/server/README.md +95 -22
  5. package/server/claude-worker/claude-session-process.mjs +89 -11
  6. package/server/claude-worker/claude-session-worker.mjs +9 -1
  7. package/server/copilot-worker/copilot-sdk-session-process.mjs +76 -11
  8. package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
  9. package/server/db-schema.mjs +59 -2
  10. package/server/grok-worker/acp-client.mjs +2 -1
  11. package/server/grok-worker/acp-host-services.mjs +4 -3
  12. package/server/migrations/0013-previews.mjs +130 -0
  13. package/server/migrations/0014-conversation-groups.mjs +89 -0
  14. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  15. package/server/migrations/0016-share-access-log.mjs +110 -0
  16. package/server/public/app/about-ui.js +73 -0
  17. package/server/public/app/about-view.mjs +234 -0
  18. package/server/public/app/action-confirmations.js +31 -14
  19. package/server/public/app/agent-exchange-fold-view.mjs +40 -101
  20. package/server/public/app/api-client.js +251 -16
  21. package/server/public/app/ask-user-view.js +17 -15
  22. package/server/public/app/bootstrap.js +158 -32
  23. package/server/public/app/chat-actions-menu-state.mjs +20 -10
  24. package/server/public/app/context-menu.mjs +90 -6
  25. package/server/public/app/conversation-group-drag.mjs +191 -0
  26. package/server/public/app/conversation-group-order.mjs +265 -0
  27. package/server/public/app/conversation-group-state.mjs +100 -0
  28. package/server/public/app/conversation-list-drag.mjs +449 -0
  29. package/server/public/app/conversation-turn-state.mjs +81 -0
  30. package/server/public/app/conversation-view.js +136 -93
  31. package/server/public/app/cwd-picker.js +1 -1
  32. package/server/public/app/hidden-messages-view.mjs +312 -0
  33. package/server/public/app/host-suspend-ui.js +7 -1
  34. package/server/public/app/journal-view.js +787 -22
  35. package/server/public/app/preview-cards.mjs +117 -31
  36. package/server/public/app/preview-command.mjs +10 -1
  37. package/server/public/app/relay-board-view.js +19 -6
  38. package/server/public/app/relay-restart-error.mjs +16 -0
  39. package/server/public/app/settings-modal.js +40 -8
  40. package/server/public/app/settings-tabs.js +1 -1
  41. package/server/public/app/share-format.mjs +49 -0
  42. package/server/public/app/share-preview-marks.mjs +167 -0
  43. package/server/public/app/share-state.js +109 -0
  44. package/server/public/app/shared-conversations-settings.js +480 -0
  45. package/server/public/app/socket-handlers.js +80 -14
  46. package/server/public/app/store.js +96 -30
  47. package/server/public/app/subagent-row.mjs +3 -3
  48. package/server/public/app/subagent-viewer.mjs +30 -6
  49. package/server/public/app/thinking-follow.js +0 -4
  50. package/server/public/app/transcript-jump-bottom-view.mjs +194 -20
  51. package/server/public/app/transcript-jump-bottom.mjs +132 -21
  52. package/server/public/app/transcript-viewport-anchor.mjs +140 -0
  53. package/server/public/index.html +386 -15
  54. package/server/relay-tools.md +2 -1
  55. package/server/relay.mjs +2 -1
  56. package/server/repositories/conversation-group-repository.mjs +444 -0
  57. package/server/repositories/message-repository.mjs +1 -0
  58. package/server/repositories/session-repository.mjs +12 -37
  59. package/server/repositories/share-repository.mjs +322 -0
  60. package/server/repositories/share-visibility-statements.mjs +10 -0
  61. package/server/routes/about-routes.mjs +15 -0
  62. package/server/routes/cache-routes.mjs +4 -0
  63. package/server/routes/conversation-group-routes.mjs +171 -0
  64. package/server/routes/messages-routes.mjs +184 -65
  65. package/server/routes/preview-routes.mjs +9 -1
  66. package/server/routes/sessions-routes.mjs +771 -193
  67. package/server/server-runtime.mjs +316 -100
  68. package/server/services/about-service.mjs +461 -0
  69. package/server/services/background-task-store.mjs +128 -0
  70. package/server/services/background-work-view.mjs +228 -0
  71. package/server/services/cache-rebuild-service.mjs +30 -0
  72. package/server/services/delete-archive-service.mjs +4 -1
  73. package/server/services/detached-worker-watch-service.mjs +150 -0
  74. package/server/services/hidden-message-list.mjs +21 -0
  75. package/server/services/host-suspend-command.mjs +2 -1
  76. package/server/services/message-pin-list.mjs +1 -1
  77. package/server/services/preview-health-probe.mjs +8 -1
  78. package/server/services/preview-proxy-server.mjs +8 -4
  79. package/server/services/preview-registry-service.mjs +676 -26
  80. package/server/services/preview-static-handler.mjs +40 -7
  81. package/server/services/preview-store.mjs +274 -0
  82. package/server/services/rate-limit-service.mjs +12 -1
  83. package/server/services/relay-auth.mjs +183 -0
  84. package/server/services/relay-cli-launcher-service.mjs +3 -1
  85. package/server/services/relay-event-service.mjs +17 -0
  86. package/server/services/remote-relay-dispatcher.mjs +57 -5
  87. package/server/services/request-boundary.mjs +183 -0
  88. package/server/services/session-activity-tracker.mjs +227 -19
  89. package/server/services/session-worker-launch-service.mjs +190 -6
  90. package/server/services/session-worker-process-service.mjs +10 -6
  91. package/server/services/share-access-log-service.mjs +123 -0
  92. package/server/services/share-path-redaction.mjs +424 -0
  93. package/server/services/status-event-service.mjs +7 -3
  94. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  95. package/server/services/windows-autostart-service.mjs +6 -1
  96. package/server/services/windows-boot-autostart-service.mjs +207 -23
  97. package/shared/preview-tool-core.mjs +15 -5
  98. package/shared/remote-relay-contract.mjs +3 -1
  99. package/shared/share-path-privacy.mjs +30 -0
  100. package/shared/windows-system-tools.mjs +106 -0
  101. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  102. package/shared/worker-runtime/heartbeat.mjs +24 -0
  103. package/shared/worker-runtime/kept-posts.mjs +83 -0
  104. 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 when the request arrived through a
39
- * public tunnel. The guard keys off *the path being a worker path*, never off
40
- * "unauthenticated request bearing cf-ray" — shared conversations legitimately
41
- * arrive over Cloudflare with no credentials and must keep working.
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 markerHeaders = normalizeMarkerHeaders(extraMarkerHeaders);
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 matchedMarkerHeader(headers) {
61
- if (!headers) return null;
62
- for (const name of markerHeaders) {
63
- const value = headers[name];
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 marker = matchedMarkerHeader(req?.headers);
73
- if (!marker) return { blocked: false, pathname, marker: null };
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} carrying ${result.marker}`);
85
- res.status(403).json({ error: 'Session worker endpoints are not reachable through the tunnel' });
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} carrying ${result.marker}`);
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
- const appData = String(env?.APPDATA || '').trim();
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(pathImpl.resolve(nodePath))}" "${escapeBatchValue(serverPath)}"`,
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('&lt;', '<')
145
+ .replaceAll('&gt;', '>')
146
+ .replaceAll('&amp;', '&');
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
- `schtasks /delete /tn "${LEGACY_BOOT_TASK_NAME}" /f 2>$null`,
216
- `schtasks /delete /tn "${BOOT_TASK_NAME}" /f 2>$null`,
217
- `schtasks /create /tn "${BOOT_TASK_NAME}" /xml "${xmlPath}"`,
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
- `schtasks /delete /tn "${BOOT_TASK_NAME}" /f 2>$null`,
225
- `schtasks /delete /tn "${LEGACY_BOOT_TASK_NAME}" /f 2>$null`,
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 ? (typeof error.code === 'number' ? error.code : 1) : 0,
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
- const result = await execFileImpl('schtasks', ['/query', '/tn', taskName, '/xml']);
283
- return result.code === 0 ? result.stdout : null;
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 taskStatus = ownXml === null
342
- ? (legacyXml !== null ? 'legacy' : 'missing')
343
- : classifyTaskXml(ownXml, { launcherPath: launcherPath() });
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: legacyXml !== null,
350
- launcherPath: fsImpl.existsSync(launcherPath()) ? launcherPath() : null,
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 child = spawnImpl('powershell.exe', [
543
+ const powershell = tool('powershell.exe');
544
+ const child = spawnImpl(powershell, [
378
545
  '-NoProfile',
379
546
  '-NonInteractive',
380
547
  '-Command',
381
548
  [
382
- '$p = Start-Process -FilePath powershell.exe -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList',
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
- writeFileAtomic(launcherPath(), buildBootLauncherScript({ packageRoot, nodePath, configPath, workingDirectory: oarRoot(), pathImpl }));
429
- const who = await execFileImpl('whoami', ['/user', '/fo', 'csv']);
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
- writeFileAtomic(elevationScriptPath(), buildElevationScript({ action: 'disable' }));
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. Previews '
29
- + 'never expire on their own; close them with {action:"close"} when the user '
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: 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.',
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. A new session '
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
+ }