stuntdouble 0.1.1 → 0.1.3

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  Stunt Double Local: run [Stunt Double](https://stuntdouble.io) checklists in a Chrome on your own machine, from your own coding agent session, with every run kept as files in your project and a local viewer to watch and review them.
4
4
 
5
- It is an MCP server and nothing more. Your agent session does all of the reasoning: it reads the actor's brief, drives the browser through the tools here and judges each check. This package drives the browser, checks that a passed verdict cites something on the page, scores the run with the same code as the Stunt Double cloud, and writes the files. It never calls a model, never starts an agent, and never reads an agent's credentials (a test fails the build if it tries), so local runs count against your own plan and nothing else.
5
+ It is an MCP server and nothing more. Your agent session does all of the reasoning: it reads the actor's brief, drives the browser through the tools here and judges each check. This package drives the browser, checks that a passed verdict cites something on the page, scores the run with the same code as the Stunt Double cloud, and writes the files. It never calls a model, never starts an agent, and never reads an agent's credentials (a test fails the build if it tries), so a local run uses whatever account your agent is signed in with, and nothing else.
6
6
 
7
7
  Most people use it through the Stunt Double plugin for Claude Code, which starts it for you: `/stuntdouble setup`, then `/stuntdouble run <checklist>`.
8
8
 
@@ -11,7 +11,7 @@ Most people use it through the Stunt Double plugin for Claude Code, which starts
11
11
  ```sh
12
12
  npx stuntdouble mcp # the MCP server over stdio; your agent starts this
13
13
  npx stuntdouble view [dir] # the viewer over a project's .stuntdouble/, no agent needed
14
- npx stuntdouble doctor # Node, Chrome, the viewer port, and how local runs are billed
14
+ npx stuntdouble doctor # Node, Chrome, the viewer port, the project, and how local runs are billed
15
15
  ```
16
16
 
17
17
  To add it to another MCP client, run `npx -y stuntdouble mcp` with `STUNTDOUBLE_PROJECT_DIR` set to the project folder (the current directory otherwise).
@@ -26,7 +26,8 @@ Needs Node 22.18 or later and Google Chrome (or Playwright's Chromium, from `npx
26
26
  | `start_run` | Validates the files (naming every field to fix), opens a fresh Chrome at the checklist's URL and returns the viewer link, the actor's brief and the checks. `resume` continues an interrupted run, or one still open in this session. |
27
27
  | `evaluate_check` | Records one verdict. A pass must cite text or a selector that is on the page now. Scrolls the evidence into view and saves `shots/<check>.png`. |
28
28
  | `finish_run` | Settles the checks left, scores the run and closes the browser. |
29
- | `open_ui` | The viewer's link. |
29
+ | `open_ui` | The viewer's link, and a line on each of the five latest runs: checklist, status, outcome, checks passed and when it started. |
30
+ | `doctor` | Read only. The same lines as `npx stuntdouble doctor`, one per check (`ok`, `FAIL`, `note` or `--`), so an agent can run it without a shell. While the viewer runs, it reports the viewer's port rather than probing 4747. |
30
31
 
31
32
  ## Files
32
33
 
@@ -46,9 +47,11 @@ Actors and checklists use the same fields, and the same limits, as the Stunt Dou
46
47
 
47
48
  Local results are self-judged: the actor records its own verdicts. Cloud runs add a second judge.
48
49
 
50
+ A checklist with no `weighted` checks has no score: it passes only when every pass/fail check passes, and its `pass_threshold`, which applies to weighted checks only, is not used. `start_run` and `finish_run` say so, and the viewer shows the tally with the same sentence.
51
+
49
52
  ## Safety
50
53
 
51
- - Every run gets a fresh Chrome profile, deleted afterwards. An actor with `persist_login` keeps its own profile in `~/.stuntdouble/profiles/<actor>`, never yours.
54
+ - Every run gets a fresh Chrome profile, deleted afterwards. An actor with `persist_login` keeps its own profile in `~/.stuntdouble/profiles/<project>/<actor>` (the project as a short hash of its path, so two projects with the same actor name never share cookies), never yours.
52
55
  - The browser reaches local and private addresses only for the app under test: the exact origins (scheme, host and port) of the checklist's URL and `local_base_url`. The rule holds however the browser gets there, by a link, a script, a popup, a frame or a redirect; a redirect's request is still sent, but the page is cleared before any tool reads it.
53
56
  - With `safety` at `third_party` (the default), on any other host the tools refuse to pay, subscribe, create an account or type a password or card number. Set it to `off` when your app signs in on another domain.
54
57
  - The viewer listens on `127.0.0.1` only (port 4747, or the next free one), answers only to its own Host header, needs the key from the printed link (kept as an HttpOnly, SameSite=Strict cookie), sends no CORS headers and serves files from `.stuntdouble/` only. Its one control is Stop.
@@ -1,5 +1,13 @@
1
+ // src/ui-server.ts
2
+ import { randomBytes, timingSafeEqual } from "crypto";
3
+ import { watch } from "fs";
4
+ import { readdir as readdir2, readFile as readFile2, realpath } from "fs/promises";
5
+ import { createServer } from "http";
6
+ import { basename, extname, join as join2, resolve, sep as sep2 } from "path";
7
+
1
8
  // src/files.ts
2
9
  import { randomUUID } from "crypto";
10
+ import { readFileSync } from "fs";
3
11
  import { appendFile, lstat, mkdir, readdir, readFile, rename, writeFile } from "fs/promises";
4
12
  import { dirname, join, sep } from "path";
5
13
  import { z } from "zod";
@@ -326,7 +334,7 @@ var ActorSchema = z.strictObject({
326
334
  content: z.string().min(1).max(ACTOR_KNOWLEDGE_CONTENT_MAX_LENGTH)
327
335
  })
328
336
  ).default([]),
329
- /** Keep this actor's cookies between runs, in ~/.stuntdouble/profiles/<actor>. */
337
+ /** Keep this actor's cookies between runs, in ~/.stuntdouble/profiles/<project>/<actor>. */
330
338
  persist_login: z.boolean().default(false),
331
339
  cloud
332
340
  });
@@ -397,7 +405,8 @@ var RunSchema = z.object({
397
405
  status: z.enum(RUN_STATUSES),
398
406
  pid: z.number().optional(),
399
407
  url: z.string(),
400
- started_at: z.string(),
408
+ // A run pulled back from the cloud may not have started.
409
+ started_at: z.string().nullable(),
401
410
  finished_at: z.string().nullable(),
402
411
  checklist: z.string(),
403
412
  actor: z.string(),
@@ -421,8 +430,14 @@ async function readJson(path, schema, label = path) {
421
430
  let raw;
422
431
  try {
423
432
  raw = await readFile(path, "utf8");
424
- } catch {
425
- throw new Error(`${label}: file not found`);
433
+ } catch (err) {
434
+ const code = err.code;
435
+ throw new Error(
436
+ `${label}: ${code === "ENOENT" ? "file not found" : `could not read (${code})`}`,
437
+ {
438
+ cause: err
439
+ }
440
+ );
426
441
  }
427
442
  let data;
428
443
  try {
@@ -465,11 +480,41 @@ function isAlive(pid) {
465
480
  try {
466
481
  process.kill(pid, 0);
467
482
  return true;
483
+ } catch {
484
+ return false;
485
+ }
486
+ }
487
+ var runningMarker = (root, id) => join(root, ".live", "running", id);
488
+ function isLive(root, run) {
489
+ let marker;
490
+ try {
491
+ marker = readFileSync(runningMarker(root, run.id), "utf8").trim();
492
+ } catch {
493
+ return false;
494
+ }
495
+ return marker === String(run.pid) && isAlive(run.pid);
496
+ }
497
+ var effectiveStatus = (root, run) => run.status === "running" && !isLive(root, run) ? "interrupted" : run.status;
498
+ function tallyRun(run) {
499
+ const status = new Map(run.results.map((r) => [r.check, r.status]));
500
+ const scored = run.checklist_snapshot.checks.filter(
501
+ (c) => c.type !== "informational" && status.get(c.key) !== "skipped"
502
+ );
503
+ return {
504
+ passed: scored.filter((c) => status.get(c.key) === "passed").length,
505
+ total: scored.length
506
+ };
507
+ }
508
+ async function readRun(root, id) {
509
+ if (!/^[\w.-]+$/.test(id) || id.startsWith(".")) return null;
510
+ const path = join(root, "runs", id, "run.json");
511
+ if (!await lstat(join(root, "runs", id)).catch(() => null)) return null;
512
+ try {
513
+ return { id, run: await readJson(path, RunSchema) };
468
514
  } catch (err) {
469
- return err.code === "EPERM";
515
+ return { id, error: err.message };
470
516
  }
471
517
  }
472
- var effectiveStatus = (run) => run.status === "running" && !isAlive(run.pid) ? "interrupted" : run.status;
473
518
  async function readRuns(root) {
474
519
  const ids = await readdir(join(root, "runs")).catch(() => []);
475
520
  const entries = await Promise.all(
@@ -484,6 +529,246 @@ async function readRuns(root) {
484
529
  return entries.sort((a, b) => a.id < b.id ? 1 : -1);
485
530
  }
486
531
 
532
+ // src/ui-server.ts
533
+ var FIRST_PORT = 4747;
534
+ var PORT_TRIES = 20;
535
+ var TYPES = {
536
+ ".png": "image/png",
537
+ ".json": "application/json",
538
+ ".jsonl": "text/plain; charset=utf-8"
539
+ };
540
+ var PAGE_HEADERS = {
541
+ "Content-Security-Policy": "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src 'self'; frame-ancestors 'none'; form-action 'none'"
542
+ };
543
+ function send(res, status, body, type = "text/plain; charset=utf-8", headers = {}) {
544
+ res.writeHead(status, {
545
+ "Content-Type": type,
546
+ "Cache-Control": "no-store",
547
+ "X-Content-Type-Options": "nosniff",
548
+ "Referrer-Policy": "no-referrer",
549
+ ...headers
550
+ });
551
+ res.end(body);
552
+ }
553
+ var json = (res, data) => send(res, 200, JSON.stringify(data), "application/json");
554
+ function sameSecret(given, token) {
555
+ const a = Buffer.from(given);
556
+ const b = Buffer.from(token);
557
+ return a.length === b.length && timingSafeEqual(a, b);
558
+ }
559
+ function cookie(req, name) {
560
+ for (const part of (req.headers.cookie ?? "").split(";")) {
561
+ const [key, ...value] = part.trim().split("=");
562
+ if (key === name) return value.join("=");
563
+ }
564
+ return void 0;
565
+ }
566
+ async function confined(root, rel) {
567
+ try {
568
+ const realRoot = await realpath(root);
569
+ const real = await realpath(resolve(root, rel));
570
+ return real.startsWith(realRoot + sep2) ? real : null;
571
+ } catch {
572
+ return null;
573
+ }
574
+ }
575
+ async function definitions(root, folder) {
576
+ const schema = folder === "checklists" ? ChecklistSchema : ActorSchema;
577
+ const names = await readdir2(join2(root, folder)).catch(() => []);
578
+ return Promise.all(
579
+ names.filter((n) => n.endsWith(".json")).sort().map(async (name) => {
580
+ const slug2 = basename(name, ".json");
581
+ try {
582
+ return { slug: slug2, data: await readJson(join2(root, folder, name), schema, name) };
583
+ } catch (err) {
584
+ return { slug: slug2, error: err.message };
585
+ }
586
+ })
587
+ );
588
+ }
589
+ async function actorNames(root) {
590
+ const actors = await definitions(root, "actors");
591
+ return new Map(actors.flatMap((a) => "data" in a && a.data ? [[a.slug, a.data.name]] : []));
592
+ }
593
+ function runSummary(root, run, names) {
594
+ const { checklist_snapshot, results: _results, ...rest } = run;
595
+ return {
596
+ ...rest,
597
+ status: effectiveStatus(root, run),
598
+ actor_name: names.get(run.actor) ?? null,
599
+ checklist_name: typeof checklist_snapshot.name === "string" ? checklist_snapshot.name : null,
600
+ tally: tallyRun(run)
601
+ };
602
+ }
603
+ async function readBody(req) {
604
+ let body = "";
605
+ for await (const chunk of req) {
606
+ body += chunk;
607
+ if (body.length > 1e4) throw new Error("Body too large");
608
+ }
609
+ return body;
610
+ }
611
+ async function startUi(options) {
612
+ const root = resolve(options.root);
613
+ const token = randomBytes(24).toString("base64url");
614
+ const clients = /* @__PURE__ */ new Set();
615
+ let port = 0;
616
+ async function handle(req, res) {
617
+ const host = req.headers.host ?? "";
618
+ if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) {
619
+ return send(res, 403, "Forbidden");
620
+ }
621
+ const url = new URL(req.url ?? "/", `http://${host}`);
622
+ if (url.pathname === "/__stuntdouble") return json(res, { stuntdouble: true });
623
+ if (url.pathname === "/favicon.ico") return send(res, 204, "");
624
+ const cookieName = `stuntdouble_${port}`;
625
+ const key = url.searchParams.get("t");
626
+ if (key !== null) {
627
+ if (!sameSecret(key, token)) return send(res, 403, "This link is out of date.");
628
+ url.searchParams.delete("t");
629
+ return send(res, 302, "", void 0, {
630
+ Location: `${url.pathname}${url.search}`,
631
+ "Set-Cookie": `${cookieName}=${token}; HttpOnly; SameSite=Strict; Path=/`
632
+ });
633
+ }
634
+ const signedIn = cookie(req, cookieName);
635
+ if (!signedIn || !sameSecret(signedIn, token)) {
636
+ return send(
637
+ res,
638
+ 403,
639
+ "Open the viewer from the link Stunt Double printed: it carries the key."
640
+ );
641
+ }
642
+ const path = url.pathname;
643
+ if (req.method === "POST" && path === "/api/stop") {
644
+ const origin = req.headers.origin;
645
+ if (origin && origin !== `http://${host}`) return send(res, 403, "Forbidden");
646
+ const { run } = JSON.parse(await readBody(req));
647
+ const entry = typeof run === "string" ? await readRun(root, run) : null;
648
+ if (!entry || !("run" in entry) || effectiveStatus(root, entry.run) !== "running") {
649
+ return send(res, 409, "That run is not going.");
650
+ }
651
+ await writeFileAtomic(join2(root, ".live", "stop"), entry.id);
652
+ return send(res, 204, "");
653
+ }
654
+ if (req.method !== "GET") return send(res, 405, "Method not allowed");
655
+ if (path === "/" || path === "/definitions" || /^\/runs\/[^/]+$/.test(path)) {
656
+ const page = await readFile2(new URL("./ui.html", import.meta.url));
657
+ return send(res, 200, page, "text/html; charset=utf-8", PAGE_HEADERS);
658
+ }
659
+ if (path === "/api/runs") {
660
+ const [runs, names] = await Promise.all([readRuns(root), actorNames(root)]);
661
+ return json(
662
+ res,
663
+ runs.map((e) => "run" in e ? runSummary(root, e.run, names) : e)
664
+ );
665
+ }
666
+ const runMatch = path.match(/^\/api\/runs\/([^/]+)$/);
667
+ if (runMatch) {
668
+ const id = decodeURIComponent(runMatch[1]);
669
+ const entry = await readRun(root, id);
670
+ if (!entry) return send(res, 404, "No such run");
671
+ const activity = await readFile2(join2(root, "runs", entry.id, "activity.jsonl"), "utf8").then(
672
+ (text) => text.split("\n").filter(Boolean).map((line) => JSON.parse(line))
673
+ ).catch(() => []);
674
+ const stop = await readFile2(join2(root, ".live", "stop"), "utf8").catch(() => "");
675
+ const names = "run" in entry ? await actorNames(root) : /* @__PURE__ */ new Map();
676
+ return json(res, {
677
+ ...entry,
678
+ ..."run" in entry ? {
679
+ run: {
680
+ ...entry.run,
681
+ status: effectiveStatus(root, entry.run),
682
+ actor_name: names.get(entry.run.actor) ?? null
683
+ }
684
+ } : {},
685
+ activity,
686
+ stop_requested: stop.trim() === entry.id
687
+ });
688
+ }
689
+ if (path === "/api/definitions") {
690
+ return json(res, {
691
+ checklists: await definitions(root, "checklists"),
692
+ actors: await definitions(root, "actors")
693
+ });
694
+ }
695
+ if (path === "/api/events") {
696
+ res.writeHead(200, {
697
+ "Content-Type": "text/event-stream",
698
+ "Cache-Control": "no-store",
699
+ "X-Content-Type-Options": "nosniff"
700
+ });
701
+ res.write(": connected\n\n");
702
+ clients.add(res);
703
+ req.on("close", () => clients.delete(res));
704
+ return;
705
+ }
706
+ if (path.startsWith("/files/")) {
707
+ const rel = decodeURIComponent(path.slice("/files/".length));
708
+ const file = await confined(root, rel);
709
+ if (!file) return send(res, 403, "Outside .stuntdouble/");
710
+ const type = TYPES[extname(file)];
711
+ if (!type) return send(res, 404, "Not found");
712
+ const cache = rel.includes("/shots/") ? "private, max-age=86400" : "no-store";
713
+ return send(res, 200, await readFile2(file), type, { "Cache-Control": cache });
714
+ }
715
+ return send(res, 404, "Not found");
716
+ }
717
+ const server = createServer((req, res) => {
718
+ handle(req, res).catch(() => {
719
+ if (res.headersSent) res.end();
720
+ else send(res, 500, "Something went wrong reading .stuntdouble/");
721
+ });
722
+ });
723
+ const first = options.port ?? FIRST_PORT;
724
+ for (let candidate = first; ; candidate++) {
725
+ try {
726
+ await new Promise((ok, fail) => {
727
+ server.once("error", fail);
728
+ server.listen(candidate, "127.0.0.1", () => {
729
+ server.off("error", fail);
730
+ ok();
731
+ });
732
+ });
733
+ break;
734
+ } catch (err) {
735
+ const busy = err.code === "EADDRINUSE";
736
+ if (!busy || candidate >= first + PORT_TRIES) throw err;
737
+ }
738
+ }
739
+ port = server.address().port;
740
+ let watcher = null;
741
+ let pending = /* @__PURE__ */ new Set();
742
+ let timer;
743
+ try {
744
+ watcher = watch(root, { recursive: true }, (_event, name) => {
745
+ if (!name || name.endsWith(".tmp")) return;
746
+ pending.add(String(name).split(sep2).join("/"));
747
+ timer ??= setTimeout(() => {
748
+ const data = `data: ${JSON.stringify({ paths: [...pending] })}
749
+
750
+ `;
751
+ for (const client of clients) client.write(data);
752
+ pending = /* @__PURE__ */ new Set();
753
+ timer = void 0;
754
+ }, 150);
755
+ });
756
+ } catch {
757
+ }
758
+ return {
759
+ port,
760
+ token,
761
+ link: (path = "/") => `http://127.0.0.1:${port}${path}?t=${token}`,
762
+ close: async () => {
763
+ watcher?.close();
764
+ clearTimeout(timer);
765
+ for (const client of clients) client.end();
766
+ server.closeAllConnections();
767
+ await new Promise((ok) => server.close(() => ok()));
768
+ }
769
+ };
770
+ }
771
+
487
772
  export {
488
773
  resolveSkippedChecks,
489
774
  resolveHaltedChecks,
@@ -499,8 +784,12 @@ export {
499
784
  writeJsonAtomic,
500
785
  writeFileAtomic,
501
786
  appendJsonl,
502
- isAlive,
787
+ runningMarker,
788
+ isLive,
503
789
  effectiveStatus,
504
- readRuns
790
+ tallyRun,
791
+ readRuns,
792
+ FIRST_PORT,
793
+ startUi
505
794
  };
506
- //# sourceMappingURL=chunk-JAB46QV7.js.map
795
+ //# sourceMappingURL=chunk-7IC2G3Z3.js.map