getobsrv 0.3.1 → 0.4.0

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/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Obsrv
2
2
 
3
+ [![CI](https://github.com/vibesyemmy/obsrv/actions/workflows/ci.yml/badge.svg)](https://github.com/vibesyemmy/obsrv/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/getobsrv)](https://www.npmjs.com/package/getobsrv)
5
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+
3
7
  See your site the way 1x screens see it.
4
8
 
5
9
  Designers and developers work on HiDPI (2x–3x) monitors. A large share of users are on
@@ -74,7 +78,15 @@ The same CLI is also wrapped as an MCP server (stdio, stateless) so MCP
74
78
  clients get the tools natively: `obsrv_snap` (render a URL at a preset's true
75
79
  raster density — the PNG comes back as an inline image up to 1.5 MiB),
76
80
  `obsrv_diff` (the 1x-vs-2x metrics as structured output) and `obsrv_presets`
77
- (every preset and panel profile, no render). Build first, then register:
81
+ (every preset and panel profile, no render).
82
+
83
+ If the desktop app is open with the toolbar's **Agent control** toggle on,
84
+ `obsrv_snap` drives the *visible* window instead: you watch the URL load and
85
+ the preset flip, and the agent gets back a capture of the app exactly as you
86
+ see it (plus `obsrv_drive` to flip URL/preset/profile directly). With no app
87
+ running, everything falls back to the headless render automatically.
88
+
89
+ Build first, then register:
78
90
 
79
91
  ```bash
80
92
  claude mcp add --scope user obsrv -- npx -y getobsrv mcp
@@ -95,6 +107,16 @@ Architecture, decisions and the full spec live in
95
107
  the UI style rationale (why the chrome is strictly neutral) is in
96
108
  [docs/superpowers/specs/2026-08-23-obsrv-ui-style.md](docs/superpowers/specs/2026-08-23-obsrv-ui-style.md).
97
109
 
110
+ ## Install (desktop app)
111
+
112
+ Grab the DMG for your chip from [Releases](https://github.com/vibesyemmy/obsrv/releases),
113
+ drag Obsrv.app to Applications, then clear the quarantine flag once (the build is not
114
+ yet notarised, so macOS falsely reports it as "damaged"):
115
+
116
+ ```bash
117
+ xattr -cr /Applications/Obsrv.app
118
+ ```
119
+
98
120
  ## Distribution
99
121
 
100
122
  Obsrv will publish to npm as **`getobsrv`** (the installed commands remain `obsrv`
package/out/main/cli.js CHANGED
@@ -3,7 +3,7 @@ const electron = require("electron");
3
3
  const node_fs = require("node:fs");
4
4
  const node_os = require("node:os");
5
5
  const node_path = require("node:path");
6
- const targetSource = require("./targetSource-w_vWw7zd.js");
6
+ const targetSource = require("./targetSource-DkXWE0ha.js");
7
7
  function boxDownsample(src, factor) {
8
8
  if (!Number.isInteger(factor) || factor < 1) throw new RangeError("factor must be an integer >= 1");
9
9
  const width = Math.floor(src.width / factor);
package/out/main/index.js CHANGED
@@ -1,9 +1,11 @@
1
1
  "use strict";
2
2
  const electron = require("electron");
3
+ const node_fs = require("node:fs");
3
4
  const promises = require("node:fs/promises");
4
5
  const node_path = require("node:path");
5
- const targetSource = require("./targetSource-w_vWw7zd.js");
6
- const node_fs = require("node:fs");
6
+ const node_crypto = require("node:crypto");
7
+ const targetSource = require("./targetSource-DkXWE0ha.js");
8
+ const node_http = require("node:http");
7
9
  const node_url = require("node:url");
8
10
  const IPC = {
9
11
  navigate: "obsrv:navigate",
@@ -29,7 +31,10 @@ const IPC = {
29
31
  openImage: "obsrv:open-image",
30
32
  focusUrl: "obsrv:focus-url",
31
33
  openImagePath: "obsrv:open-image-path",
32
- readImageFile: "obsrv:read-image-file"
34
+ readImageFile: "obsrv:read-image-file",
35
+ uiState: "obsrv:ui-state",
36
+ agentApply: "obsrv:agent-apply",
37
+ agentActivity: "obsrv:agent-activity"
33
38
  };
34
39
  function attachFrameBus(target, win) {
35
40
  let ready = false;
@@ -63,6 +68,46 @@ function attachFrameBus(target, win) {
63
68
  }
64
69
  };
65
70
  }
71
+ const CONTROL_FILE_NAME = "control.json";
72
+ const CONTROL_TOKEN_BYTES = 32;
73
+ const CONTROL_COMMANDS = [
74
+ "status",
75
+ "navigate",
76
+ "setPreset",
77
+ "setProfile",
78
+ "setViewMode",
79
+ "captureVisible"
80
+ ];
81
+ function isControlCommand(v) {
82
+ return typeof v === "string" && CONTROL_COMMANDS.includes(v);
83
+ }
84
+ function tokenEqual(expected, provided) {
85
+ if (typeof provided !== "string") return false;
86
+ const a = node_crypto.createHash("sha256").update(expected).digest();
87
+ const b = node_crypto.createHash("sha256").update(provided).digest();
88
+ return node_crypto.timingSafeEqual(a, b);
89
+ }
90
+ const idList = (ids) => ids.join(", ");
91
+ function presetApplyError(id) {
92
+ if (typeof id !== "string") return "setPreset payload must be { id: string }";
93
+ if (id === "custom") {
94
+ return "the custom preset cannot be applied remotely — it is defined by the fields in the app; pick a preset id";
95
+ }
96
+ if (!targetSource.SCREEN_PRESETS.some((p) => p.id === id)) {
97
+ return `unknown preset "${id}" — valid ids: ${idList(targetSource.SCREEN_PRESETS.map((p) => p.id))}`;
98
+ }
99
+ return null;
100
+ }
101
+ function profileApplyError(id) {
102
+ if (typeof id !== "string") return "setProfile payload must be { id: string }";
103
+ if (!targetSource.PANEL_PROFILES.some((p) => p.id === id)) {
104
+ return `unknown profile "${id}" — valid ids: ${idList(targetSource.PANEL_PROFILES.map((p) => p.id))}`;
105
+ }
106
+ return null;
107
+ }
108
+ function viewModeApplyError(v) {
109
+ return v === "1:1" || v === "fit" ? null : `setViewMode payload must be { mode: '1:1' | 'fit' }`;
110
+ }
66
111
  const MAX_RECT = 16384;
67
112
  const isFiniteNumber = (v) => typeof v === "number" && Number.isFinite(v);
68
113
  const isRecord = (v) => typeof v === "object" && v !== null;
@@ -126,11 +171,23 @@ function parseSettings(raw) {
126
171
  const { hostDiagonalInches, hostNits } = raw;
127
172
  if (!isFiniteNumber(hostDiagonalInches) || hostDiagonalInches <= 0) return null;
128
173
  if (!isFiniteNumber(hostNits) || hostNits <= 0) return null;
129
- return { hostDiagonalInches, hostNits };
174
+ const agentControl = raw.agentControl ?? false;
175
+ if (typeof agentControl !== "boolean") return null;
176
+ return { hostDiagonalInches, hostNits, agentControl };
130
177
  }
131
178
  function parseMode(raw) {
132
179
  return raw === "url" || raw === "image" ? raw : null;
133
180
  }
181
+ const MAX_UI_ID = 64;
182
+ function parseUiState(raw) {
183
+ if (!isRecord(raw)) return null;
184
+ const { presetId, profileId, viewMode, mode } = raw;
185
+ if (typeof presetId !== "string" || presetId.length === 0 || presetId.length > MAX_UI_ID) return null;
186
+ if (typeof profileId !== "string" || profileId.length === 0 || profileId.length > MAX_UI_ID) return null;
187
+ if (viewMode !== "1:1" && viewMode !== "fit") return null;
188
+ if (mode !== "url" && mode !== "image") return null;
189
+ return { presetId, profileId, viewMode, mode };
190
+ }
134
191
  function parseScrollPos(raw) {
135
192
  if (!isRecord(raw)) return null;
136
193
  const { x, y } = raw;
@@ -143,7 +200,10 @@ function loadSettings(file) {
143
200
  const raw = JSON.parse(node_fs.readFileSync(file, "utf8"));
144
201
  return {
145
202
  hostDiagonalInches: isPositive(raw.hostDiagonalInches) ? raw.hostDiagonalInches : targetSource.DEFAULT_SETTINGS.hostDiagonalInches,
146
- hostNits: isPositive(raw.hostNits) ? raw.hostNits : targetSource.DEFAULT_SETTINGS.hostNits
203
+ hostNits: isPositive(raw.hostNits) ? raw.hostNits : targetSource.DEFAULT_SETTINGS.hostNits,
204
+ // Anything but a literal true (older files have no key at all) means off:
205
+ // a network-facing capability must never be enabled by a malformed file.
206
+ agentControl: raw.agentControl === true
147
207
  };
148
208
  } catch {
149
209
  return { ...targetSource.DEFAULT_SETTINGS };
@@ -151,9 +211,158 @@ function loadSettings(file) {
151
211
  }
152
212
  function saveSettings(file, s) {
153
213
  if (!isPositive(s.hostDiagonalInches) || !isPositive(s.hostNits)) throw new RangeError("settings values must be finite and > 0");
214
+ if (typeof s.agentControl !== "boolean") throw new RangeError("agentControl must be a boolean");
154
215
  node_fs.mkdirSync(node_path.dirname(file), { recursive: true });
155
216
  node_fs.writeFileSync(file, JSON.stringify(s, null, 2));
156
217
  }
218
+ const MAX_BODY_BYTES = 64 * 1024;
219
+ const APPLY_WAIT_MS = 2e3;
220
+ const APPLY_POLL_MS = 25;
221
+ const reply = (code, body) => ({ code, body });
222
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
223
+ class ControlServer {
224
+ constructor(file, deps) {
225
+ this.file = file;
226
+ this.deps = deps;
227
+ }
228
+ server = null;
229
+ token = "";
230
+ get running() {
231
+ return this.server !== null;
232
+ }
233
+ /** Starts the server on an ephemeral loopback port and writes the discovery file. */
234
+ async start() {
235
+ if (this.server) return;
236
+ this.token = node_crypto.randomBytes(CONTROL_TOKEN_BYTES).toString("hex");
237
+ const server = node_http.createServer((req, res) => void this.handle(req, res));
238
+ await new Promise((resolve, reject) => {
239
+ server.once("error", reject);
240
+ server.listen(0, "127.0.0.1", resolve);
241
+ });
242
+ this.server = server;
243
+ const { port } = server.address();
244
+ node_fs.rmSync(this.file, { force: true });
245
+ node_fs.writeFileSync(this.file, JSON.stringify({ port, token: this.token }), { mode: 384 });
246
+ }
247
+ /**
248
+ * Stops the server and removes the discovery file. Synchronous on purpose:
249
+ * the quit path must not race the process teardown, and the file — the
250
+ * part that outlives the process — goes first.
251
+ */
252
+ stop() {
253
+ const server = this.server;
254
+ this.server = null;
255
+ this.token = "";
256
+ node_fs.rmSync(this.file, { force: true });
257
+ if (server) {
258
+ server.closeAllConnections();
259
+ server.close();
260
+ }
261
+ }
262
+ async handle(req, res) {
263
+ let out;
264
+ try {
265
+ out = await this.route(req);
266
+ } catch (e) {
267
+ out = reply(500, { error: e instanceof Error ? e.message : "internal error" });
268
+ }
269
+ const payload = JSON.stringify(out.body);
270
+ res.writeHead(out.code, { "content-type": "application/json", "content-length": Buffer.byteLength(payload) });
271
+ res.end(payload);
272
+ }
273
+ async route(req) {
274
+ if (req.method !== "POST" || req.url !== "/") return reply(404, { error: "POST / only" });
275
+ if (req.headers.origin !== void 0) return reply(403, { error: "cross-origin requests are refused" });
276
+ const contentType = req.headers["content-type"] ?? "";
277
+ if (!/^application\/json\b/i.test(contentType)) {
278
+ return reply(415, { error: "content-type must be application/json" });
279
+ }
280
+ const raw = await this.readBody(req);
281
+ if (raw === null) return reply(413, { error: `body over ${MAX_BODY_BYTES} bytes` });
282
+ let parsed;
283
+ try {
284
+ parsed = JSON.parse(raw);
285
+ } catch {
286
+ return reply(400, { error: "body must be JSON" });
287
+ }
288
+ if (typeof parsed !== "object" || parsed === null) return reply(400, { error: "body must be a JSON object" });
289
+ const body = parsed;
290
+ if (!tokenEqual(this.token, body.token)) return reply(403, { error: "forbidden" });
291
+ const command = body.command;
292
+ if (!isControlCommand(command)) {
293
+ return reply(400, { error: `unknown command — allowed: ${CONTROL_COMMANDS.join(", ")}` });
294
+ }
295
+ this.deps.activity();
296
+ const payload = typeof body.payload === "object" && body.payload !== null ? body.payload : {};
297
+ switch (command) {
298
+ case "status":
299
+ return reply(200, { ok: true, ...this.deps.status() });
300
+ case "navigate": {
301
+ const url = payload.url;
302
+ if (typeof url !== "string" || url.trim() === "") return reply(400, { error: "navigate payload must be { url: string }" });
303
+ const bad = targetSource.urlSchemeError(url);
304
+ if (bad) return reply(400, { error: bad });
305
+ const applied = await this.deps.navigate(url.trim());
306
+ return reply(200, { ok: true, url: applied });
307
+ }
308
+ case "setPreset": {
309
+ const err = presetApplyError(payload.id);
310
+ if (err) return reply(400, { error: err });
311
+ return this.applyAndConfirm({ presetId: payload.id }, (s) => s.presetId === payload.id);
312
+ }
313
+ case "setProfile": {
314
+ const err = profileApplyError(payload.id);
315
+ if (err) return reply(400, { error: err });
316
+ return this.applyAndConfirm({ profileId: payload.id }, (s) => s.profileId === payload.id);
317
+ }
318
+ case "setViewMode": {
319
+ const err = viewModeApplyError(payload.mode);
320
+ if (err) return reply(400, { error: err });
321
+ const mode = payload.mode;
322
+ return this.applyAndConfirm({ viewMode: mode }, (s) => s.viewMode === mode);
323
+ }
324
+ case "captureVisible": {
325
+ const capture = await this.deps.captureVisible();
326
+ return reply(200, { ok: true, ...capture });
327
+ }
328
+ }
329
+ }
330
+ /**
331
+ * Forwards a patch to the renderer and waits (bounded) for the UI mirror to
332
+ * reflect it, so a 200 means "applied", not "sent". `applied: false` after
333
+ * the wait is not an error — the renderer may be busy — the caller can poll
334
+ * `status`.
335
+ */
336
+ async applyAndConfirm(patch, confirmed) {
337
+ this.deps.apply(patch);
338
+ const deadline = Date.now() + APPLY_WAIT_MS;
339
+ let applied = confirmed(this.deps.status());
340
+ while (!applied && Date.now() < deadline) {
341
+ await sleep(APPLY_POLL_MS);
342
+ applied = confirmed(this.deps.status());
343
+ }
344
+ return reply(200, { ok: true, applied, ...this.deps.status() });
345
+ }
346
+ /** The request body, or null when it exceeds the cap. */
347
+ readBody(req) {
348
+ return new Promise((resolve, rejectOnError) => {
349
+ const chunks = [];
350
+ let size = 0;
351
+ req.on("data", (chunk) => {
352
+ size += chunk.byteLength;
353
+ if (size > MAX_BODY_BYTES) {
354
+ req.removeAllListeners("data");
355
+ req.removeAllListeners("end");
356
+ resolve(null);
357
+ return;
358
+ }
359
+ chunks.push(chunk);
360
+ });
361
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
362
+ req.on("error", rejectOnError);
363
+ });
364
+ }
365
+ }
157
366
  const TOOLBAR_H = 44;
158
367
  const MAX_IMAGE_FILE_BYTES = 64 * 1024 * 1024;
159
368
  function hostInfo(win) {
@@ -176,8 +385,7 @@ function registerIpc(ctx) {
176
385
  const assertRenderer = (e) => {
177
386
  if (!fromRenderer(e)) throw new Error("ipc: unexpected sender");
178
387
  };
179
- electron.ipcMain.handle(IPC.navigate, async (e, url) => {
180
- assertRenderer(e);
388
+ const navigateBoth = async (url) => {
181
389
  let wanted = url;
182
390
  try {
183
391
  wanted = targetSource.normalizeUrl(url);
@@ -186,6 +394,10 @@ function registerIpc(ctx) {
186
394
  }
187
395
  const [applied] = await Promise.all([native.load(wanted), target.load(wanted)]);
188
396
  return applied;
397
+ };
398
+ electron.ipcMain.handle(IPC.navigate, (e, url) => {
399
+ assertRenderer(e);
400
+ return navigateBoth(url);
189
401
  });
190
402
  electron.ipcMain.on(IPC.reload, (e) => {
191
403
  if (!fromRenderer(e)) return;
@@ -271,8 +483,81 @@ function registerIpc(ctx) {
271
483
  const s = parseSettings(raw);
272
484
  if (!s) throw new Error("invalid settings");
273
485
  saveSettings(settingsFile, s);
486
+ const wasEnabled = settings.agentControl;
274
487
  settings = s;
488
+ if (s.agentControl !== wasEnabled) applyAgentControl(s.agentControl);
489
+ });
490
+ const uiState = { presetId: "1080p-24", profileId: "reference", viewMode: "1:1", mode: "url" };
491
+ const MAX_PENDING_APPLIES = 32;
492
+ let rendererReported = false;
493
+ let warnedPendingOverflow = false;
494
+ const pendingApplies = [];
495
+ electron.ipcMain.on(IPC.uiState, (e, raw) => {
496
+ if (!fromRenderer(e)) return;
497
+ const s = parseUiState(raw);
498
+ if (!s) return;
499
+ Object.assign(uiState, s);
500
+ if (!rendererReported) {
501
+ rendererReported = true;
502
+ for (const patch of pendingApplies.splice(0)) {
503
+ if (!win.isDestroyed()) win.webContents.send(IPC.agentApply, patch);
504
+ }
505
+ }
275
506
  });
507
+ const appVersion = (() => {
508
+ try {
509
+ const pkg = JSON.parse(node_fs.readFileSync(node_path.join(__dirname, "..", "..", "package.json"), "utf8"));
510
+ return pkg.version ?? electron.app.getVersion();
511
+ } catch {
512
+ return electron.app.getVersion();
513
+ }
514
+ })();
515
+ const control = new ControlServer(node_path.join(electron.app.getPath("userData"), CONTROL_FILE_NAME), {
516
+ status: () => {
517
+ let url = "";
518
+ try {
519
+ url = target.webContents.getURL();
520
+ } catch {
521
+ }
522
+ return { version: appVersion, url, ...uiState };
523
+ },
524
+ navigate: navigateBoth,
525
+ apply: (patch) => {
526
+ if (win.isDestroyed()) return;
527
+ if (!rendererReported) {
528
+ if (pendingApplies.length >= MAX_PENDING_APPLIES) {
529
+ if (!warnedPendingOverflow) {
530
+ warnedPendingOverflow = true;
531
+ console.warn("obsrv: agent-apply queue full before the renderer mounted; dropping oldest entries");
532
+ }
533
+ pendingApplies.shift();
534
+ }
535
+ pendingApplies.push(patch);
536
+ return;
537
+ }
538
+ win.webContents.send(IPC.agentApply, patch);
539
+ },
540
+ captureVisible: async () => {
541
+ const image = await win.webContents.capturePage();
542
+ const size = image.getSize();
543
+ return { data: image.toPNG().toString("base64"), width: size.width, height: size.height };
544
+ },
545
+ activity: () => {
546
+ if (!win.isDestroyed()) win.webContents.send(IPC.agentActivity);
547
+ }
548
+ });
549
+ const applyAgentControl = (enabled) => {
550
+ if (enabled) {
551
+ control.start().catch((e) => {
552
+ console.error("obsrv: agent-control server failed to start", e);
553
+ });
554
+ } else {
555
+ control.stop();
556
+ }
557
+ };
558
+ if (process.env.OBSRV_AGENT_CONTROL === "1") settings = { ...settings, agentControl: true };
559
+ if (settings.agentControl) applyAgentControl(true);
560
+ electron.app.on("will-quit", () => control.stop());
276
561
  electron.ipcMain.handle(IPC.readImageFile, async (e, raw) => {
277
562
  assertRenderer(e);
278
563
  if (typeof raw !== "string" || !targetSource.IMAGE_EXTENSIONS.test(raw)) throw new Error("Unsupported file type");
@@ -3,7 +3,7 @@ const electron = require("electron");
3
3
  const node_events = require("node:events");
4
4
  const node_path = require("node:path");
5
5
  const MAX_VIEWPORT = 4096;
6
- const DEFAULT_SETTINGS = { hostDiagonalInches: 27, hostNits: 500 };
6
+ const DEFAULT_SETTINGS = { hostDiagonalInches: 27, hostNits: 500, agentControl: false };
7
7
  const SCREEN_PRESETS = [
8
8
  // Laptops — ordered largest to smallest panel, then the denser 1080p outlier.
9
9
  { id: "laptop-768", label: '1366×768 15.6"', width: 1366, height: 768, diagonalInches: 15.6, deviceScaleFactor: 1, group: "laptop" },
@@ -70,6 +70,16 @@ function normalizeUrl(input) {
70
70
  if (SCHEME.test(s)) return s;
71
71
  return `https://${s}`;
72
72
  }
73
+ const ALLOWED_URL_SCHEMES = ["http:", "https:", "file:"];
74
+ function urlSchemeError(url) {
75
+ const trimmed = url.trim();
76
+ const match = /^([a-z][a-z0-9+.-]*):/i.exec(trimmed);
77
+ if (!match) return null;
78
+ const scheme = `${match[1].toLowerCase()}:`;
79
+ if (ALLOWED_URL_SCHEMES.includes(scheme)) return null;
80
+ if (/^[a-z0-9.-]+:\d+(\/|$)/i.test(trimmed)) return null;
81
+ return `unsupported URL scheme "${scheme}" — obsrv renders ${ALLOWED_URL_SCHEMES.map((s) => `${s}//`).join(", ")} URLs only (bare hosts like example.com also work; they normalise to http(s)).`;
82
+ }
73
83
  const ERR_ABORTED = -3;
74
84
  const DEFAULT_FPS = 30;
75
85
  const DEFAULT_VIEWPORT = { width: 1920, height: 1080 };
@@ -349,3 +359,4 @@ exports.findPreset = findPreset;
349
359
  exports.findProfile = findProfile;
350
360
  exports.maxCssViewport = maxCssViewport;
351
361
  exports.normalizeUrl = normalizeUrl;
362
+ exports.urlSchemeError = urlSchemeError;
@@ -0,0 +1,105 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ControlCallError = exports.CONTROL_FILE_ENV = void 0;
4
+ exports.controlFilePath = controlFilePath;
5
+ exports.controlCall = controlCall;
6
+ exports.discoverControl = discoverControl;
7
+ const promises_1 = require("node:fs/promises");
8
+ const node_http_1 = require("node:http");
9
+ const node_os_1 = require("node:os");
10
+ const control_1 = require("../shared/control");
11
+ /**
12
+ * MCP-side client for the app's agent-control server (spec §14 "Live
13
+ * drive"): discovery-file lookup, liveness check, and the one-shot POST the
14
+ * protocol speaks. Runs under plain node — the userData path is derived
15
+ * per-platform in shared/control.ts, never asked of Electron.
16
+ */
17
+ /**
18
+ * Overrides where the discovery file is looked for. The e2e harness sets it
19
+ * (its app runs with an isolated --user-data-dir, so its control.json is not
20
+ * at the standard path); it also keeps those tests hermetic against a real
21
+ * Obsrv the developer may have open.
22
+ */
23
+ exports.CONTROL_FILE_ENV = 'OBSRV_CONTROL_FILE';
24
+ function controlFilePath() {
25
+ return process.env[exports.CONTROL_FILE_ENV] ?? (0, control_1.defaultControlFilePath)(process.platform, process.env, (0, node_os_1.homedir)());
26
+ }
27
+ /** A non-2xx answer from the control server, carrying its error message. */
28
+ class ControlCallError extends Error {
29
+ statusCode;
30
+ constructor(message, statusCode) {
31
+ super(message);
32
+ this.statusCode = statusCode;
33
+ }
34
+ }
35
+ exports.ControlCallError = ControlCallError;
36
+ /**
37
+ * One control-protocol command. Rejects on transport failure, timeout, or a
38
+ * non-200 answer (with the server's error message when it sent one).
39
+ */
40
+ function controlCall(info, command, payload = {}, timeoutMs = 10_000) {
41
+ return new Promise((resolve, reject) => {
42
+ const body = JSON.stringify({ token: info.token, command, payload });
43
+ const req = (0, node_http_1.request)({
44
+ host: '127.0.0.1',
45
+ port: info.port,
46
+ method: 'POST',
47
+ path: '/',
48
+ headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) },
49
+ timeout: timeoutMs,
50
+ }, res => {
51
+ let text = '';
52
+ res.on('data', d => (text += String(d)));
53
+ res.on('end', () => {
54
+ let parsed = null;
55
+ try {
56
+ parsed = JSON.parse(text);
57
+ }
58
+ catch {
59
+ // Handled by the shape check below.
60
+ }
61
+ const rec = typeof parsed === 'object' && parsed !== null ? parsed : {};
62
+ if (res.statusCode === 200) {
63
+ resolve(rec);
64
+ }
65
+ else {
66
+ const detail = typeof rec['error'] === 'string' ? rec['error'] : `control server answered ${res.statusCode ?? '?'}`;
67
+ reject(new ControlCallError(`obsrv control ${command}: ${detail}`, res.statusCode));
68
+ }
69
+ });
70
+ });
71
+ req.on('timeout', () => req.destroy(new Error(`obsrv control ${command} timed out after ${timeoutMs} ms`)));
72
+ req.on('error', reject);
73
+ req.end(body);
74
+ });
75
+ }
76
+ /**
77
+ * Finds a running, control-enabled Obsrv app: discovery file present, sanely
78
+ * permissioned (no group/other access on POSIX — the app writes it 0600) and
79
+ * well-formed, and a tokened `status` answered within `timeoutMs`. Null on
80
+ * any failure — every path where the app cannot be *proven* live is "not
81
+ * reachable", so auto mode falls back to headless instead of erroring.
82
+ */
83
+ async function discoverControl(timeoutMs = 500) {
84
+ const file = controlFilePath();
85
+ let raw;
86
+ try {
87
+ const s = await (0, promises_1.stat)(file);
88
+ if (!(0, control_1.controlFileModeOk)(s.mode, process.platform))
89
+ return null;
90
+ raw = await (0, promises_1.readFile)(file, 'utf8');
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ const info = (0, control_1.parseControlFile)(raw);
96
+ if (!info)
97
+ return null;
98
+ try {
99
+ const status = (0, control_1.parseControlStatus)(await controlCall(info, 'status', {}, timeoutMs));
100
+ return status ? { info, status } : null;
101
+ }
102
+ catch {
103
+ return null;
104
+ }
105
+ }
package/out/mcp/lib.js CHANGED
@@ -1,12 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ALLOWED_URL_SCHEMES = exports.STDERR_TAIL_CHARS = exports.MAX_INLINE_IMAGE_BYTES = exports.UsageError = void 0;
3
+ exports.APP_NOT_REACHABLE = exports.urlSchemeError = exports.ALLOWED_URL_SCHEMES = exports.STDERR_TAIL_CHARS = exports.MAX_INLINE_IMAGE_BYTES = exports.UsageError = void 0;
4
4
  exports.buildSnapArgs = buildSnapArgs;
5
5
  exports.buildDiffArgs = buildDiffArgs;
6
6
  exports.shouldInlineImage = shouldInlineImage;
7
7
  exports.killBudgetMs = killBudgetMs;
8
8
  exports.stderrTail = stderrTail;
9
- exports.urlSchemeError = urlSchemeError;
9
+ exports.planSnapPath = planSnapPath;
10
10
  exports.extractTrailingJson = extractTrailingJson;
11
11
  exports.listCatalog = listCatalog;
12
12
  const presets_1 = require("../shared/presets");
@@ -97,30 +97,51 @@ function stderrTail(stderr, max = exports.STDERR_TAIL_CHARS) {
97
97
  const trimmed = stderr.trim();
98
98
  return trimmed.length <= max ? trimmed : `…${trimmed.slice(-max)}`;
99
99
  }
100
- /** Schemes a tool call may hand to the CLI. */
101
- exports.ALLOWED_URL_SCHEMES = ['http:', 'https:', 'file:'];
100
+ // The scheme allowlist lives in shared/url.ts so the agent-control server
101
+ // applies the identical check; re-exported so existing importers (and their
102
+ // unit tests) keep their path.
103
+ var url_1 = require("../shared/url");
104
+ Object.defineProperty(exports, "ALLOWED_URL_SCHEMES", { enumerable: true, get: function () { return url_1.ALLOWED_URL_SCHEMES; } });
105
+ Object.defineProperty(exports, "urlSchemeError", { enumerable: true, get: function () { return url_1.urlSchemeError; } });
106
+ exports.APP_NOT_REACHABLE = 'The Obsrv app is not reachable. Open the Obsrv desktop app and enable "Agent control" in the toolbar ' +
107
+ '(or pass mode: "headless" to render without it).';
102
108
  /**
103
- * Rejects URLs whose explicit scheme is outside the allowlist (javascript:,
104
- * data:, chrome:, …) with an actionable message, or returns null when the URL
105
- * may proceed. Scheme-relative (`//host`), bare-host (`example.com/page`) and
106
- * host:port (`localhost:5173`) forms pass — they normalise to http(s)
107
- * downstream.
109
+ * Decides whether an `obsrv_snap` call drives the visible app or renders
110
+ * headlessly (spec §14 "Live drive"), given whether a control-enabled app
111
+ * answered discovery. Documented calls, exercised in tests/unit/mcpLib.test.ts:
112
+ *
113
+ * - custom dims (width/height/dsf/diagonal) always render headlessly — the
114
+ * live path drives the app's preset table only — with a note, even under
115
+ * an explicit `mode: 'live'`.
116
+ * - `fullPage` always renders headlessly (the visible window cannot show a
117
+ * full page), with a note.
118
+ * - `waitMs` is honoured headlessly; on the live path it is ignored with a
119
+ * note (the live capture settles on the app's own committed navigation).
120
+ * - `mode: 'live'` with no reachable app is an error, never a silent
121
+ * headless fallback — the caller asked to watch.
108
122
  */
109
- function urlSchemeError(url) {
110
- const trimmed = url.trim();
111
- const match = /^([a-z][a-z0-9+.-]*):/i.exec(trimmed);
112
- if (!match)
113
- return null; // bare host or scheme-relative
114
- const scheme = `${match[1].toLowerCase()}:`;
115
- if (exports.ALLOWED_URL_SCHEMES.includes(scheme))
116
- return null;
117
- // `localhost:5173`-style host:port, not a scheme: the "scheme" is followed
118
- // by a bare port number.
119
- if (/^[a-z0-9.-]+:\d+(\/|$)/i.test(trimmed))
120
- return null;
121
- return (`unsupported URL scheme "${scheme}" — obsrv renders ` +
122
- `${exports.ALLOWED_URL_SCHEMES.map(s => `${s}//`).join(', ')} URLs only ` +
123
- `(bare hosts like example.com also work; they normalise to http(s)).`);
123
+ function planSnapPath(input, mode, liveReachable) {
124
+ if (mode === 'headless')
125
+ return { path: 'headless', notes: [] };
126
+ if (!liveReachable) {
127
+ if (mode === 'live')
128
+ return { error: exports.APP_NOT_REACHABLE };
129
+ return { path: 'headless', notes: [] };
130
+ }
131
+ const notes = [];
132
+ const custom = input.width !== undefined ||
133
+ input.height !== undefined ||
134
+ input.deviceScaleFactor !== undefined ||
135
+ input.diagonalInches !== undefined;
136
+ if (custom)
137
+ notes.push('custom dimensions are headless-only (live mode drives the preset table); rendered headlessly.');
138
+ if (input.fullPage)
139
+ notes.push('fullPage is headless-only; rendered headlessly instead of driving the app.');
140
+ if (notes.length > 0)
141
+ return { path: 'headless', notes };
142
+ if (input.waitMs !== undefined)
143
+ notes.push('waitMs is headless-only and was ignored in live mode.');
144
+ return { path: 'live', notes };
124
145
  }
125
146
  /**
126
147
  * Parses the CLI's machine output: the trailing JSON object on stdout.