playthrough 0.4.2 → 0.4.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
@@ -4,6 +4,8 @@ The agent says it's done. You decide if it's good.
4
4
 
5
5
  Playthrough records your coding agent's change the way a person would use it, before and after, so you can watch it instead of setting it up by hand. Point at what's wrong, right on the recording, and the next round answers it there. Take over any moment and carry on in the real app. It runs on your machine, with any coding agent that has a shell.
6
6
 
7
+ To see a review before you install anything, play the one at [playthrough.sh](https://playthrough.sh).
8
+
7
9
  ## Try it
8
10
 
9
11
  Ask your agent for a change in your app, as you would anyway, and add:
package/package.json CHANGED
@@ -1,7 +1,22 @@
1
1
  {
2
2
  "name": "playthrough",
3
- "version": "0.4.2",
4
- "description": "Review an AI agent's work on a web app by watching recordings of it.",
3
+ "version": "0.4.3",
4
+ "description": "Review what your coding agent built in your web app by watching recordings of it.",
5
+ "keywords": [
6
+ "coding-agent",
7
+ "claude-code",
8
+ "cursor",
9
+ "codex",
10
+ "ai-agents",
11
+ "code-review",
12
+ "ui-review",
13
+ "playwright",
14
+ "recording",
15
+ "human-in-the-loop",
16
+ "developer-tools",
17
+ "web-app"
18
+ ],
19
+ "homepage": "https://playthrough.sh",
5
20
  "type": "module",
6
21
  "license": "Apache-2.0",
7
22
  "repository": {
@@ -6,7 +6,7 @@ import { createRequire } from "node:module";
6
6
  import { copyFileSync, cpSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7
7
  import { execFileSync } from "node:child_process";
8
8
  import { readFile, readdir, stat, writeFile } from "node:fs/promises";
9
- import { createHash } from "node:crypto";
9
+ import { createHash, randomBytes } from "node:crypto";
10
10
  import { constants, networkInterfaces, tmpdir, userInfo } from "node:os";
11
11
  import { dirname, extname, join, normalize, resolve, sep } from "node:path";
12
12
  import { chromium, request } from "@playwright/test";
@@ -557,14 +557,17 @@ async function takeOver$1(repo, id, flowName, stepName) {
557
557
  throw new Refused([`Take over which flow? playthrough take-over ${id} "<flow>" ["<step>"]\nIts flows: ${list(round.flows.map((flow) => quote(flow.name)))}.`]);
558
558
  }
559
559
  console.log(`Running ${quote(flowName)} to ${stepName ? `the end of ${quote(stepName)}` : "its end"}, on the app as it is now…`);
560
- const { port, moment, seconds } = await getReady(repo, id, flowName, { step: stepName });
560
+ const { port, moment, seconds } = await getReady(repo, id, flowName, {
561
+ step: stepName,
562
+ network: true
563
+ });
561
564
  const here = `http://localhost:${port}/take-over/${moment}`;
562
565
  const away = addresses().map((address) => `http://${address}:${port}/take-over/${moment}`);
563
566
  console.log(`
564
567
  Ready in ${seconds}s. Carry on from there in your browser: ${here}${away.length ? `\nOr on your phone, on the same network: ${list(away)}` : ""}
565
568
  Each address hands over the same copy, and the app is passed on through Playthrough while this runs. Ctrl+C ends it.`);
566
569
  }
567
- async function getReady(repo, id, flowName, { step: stepName, actions, did, ahead } = {}) {
570
+ async function getReady(repo, id, flowName, { step: stepName, actions, did, ahead, network } = {}) {
568
571
  const round = await recorded(repo, id);
569
572
  const flow = round.flows.find((flow) => flow.name === flowName);
570
573
  if (!flow) throw new Refused([`${id} has no flow called ${quote(flowName)}. Its flows: ${list(round.flows.map((flow) => quote(flow.name)))}.`]);
@@ -584,10 +587,9 @@ async function getReady(repo, id, flowName, { step: stepName, actions, did, ahea
584
587
  });
585
588
  passing ??= {
586
589
  moments: /* @__PURE__ */ new Map(),
587
- port: passOn(app, (moment) => passing.moments.get(moment)),
588
- numbered: 0
590
+ port: passOn(app, (moment) => passing.moments.get(moment), network)
589
591
  };
590
- const moment = String(++passing.numbered);
592
+ const moment = randomBytes(12).toString("base64url");
591
593
  passing.moments.set(moment, did?.length ? {
592
594
  ...handed,
593
595
  did
@@ -625,11 +627,13 @@ async function recorded(repo, id) {
625
627
  if (!round) throw new Refused([`${id} has nothing recorded yet, so there's nothing to take over. Record it first: playthrough record ${id}`]);
626
628
  return round;
627
629
  }
628
- function passOn(app, handed) {
630
+ function passOn(app, handed, network) {
629
631
  const target = new URL(app.url);
630
632
  const server = createServer((request, response) => {
633
+ if (!named(request)) return turnAway(response);
631
634
  const [first, moment] = new URL(request.url ?? "/", "http://playthrough").pathname.split("/").filter(Boolean);
632
- if (first === "take-over") return handOver(handed(moment ?? ""), response);
635
+ if (first === "take-over") return handOver(handed(moment ?? ""), response, !fromThisMachine(request));
636
+ if (!passed(request)) return turnAway(response);
633
637
  const page = /text\/html/.test(request.headers.accept ?? "");
634
638
  const headers = asIfDirect(request, target);
635
639
  if (page) delete headers["accept-encoding"];
@@ -640,7 +644,7 @@ function passOn(app, handed) {
640
644
  path: request.url,
641
645
  headers
642
646
  }, (answer) => {
643
- const told = fromTheApp(answer, target);
647
+ const told = fromTheApp(answer, target, request);
644
648
  if (!page || !/text\/html/.test(String(answer.headers["content-type"])) || answer.headers["content-encoding"]) {
645
649
  response.writeHead(answer.statusCode ?? 502, told);
646
650
  answer.pipe(response);
@@ -662,6 +666,7 @@ function passOn(app, handed) {
662
666
  request.pipe(forward);
663
667
  });
664
668
  server.on("upgrade", (request, socket, head) => {
669
+ if (!named(request) || !passed(request)) return void socket.destroy();
665
670
  const upstream = connect(Number(target.port || 80), target.hostname, () => {
666
671
  const headers = Object.entries(asIfDirect(request, target)).flatMap(([name, value]) => [value ?? []].flat().map((one) => `${name}: ${one}`));
667
672
  upstream.write(`${request.method} ${request.url} HTTP/1.1\r\n${headers.join("\r\n")}\r\n\r\n`);
@@ -671,7 +676,15 @@ function passOn(app, handed) {
671
676
  upstream.on("error", () => socket.destroy());
672
677
  socket.on("error", () => upstream.destroy());
673
678
  });
674
- return listen(server, PORT$1, "0.0.0.0");
679
+ return listen(server, PORT$1, network ? "0.0.0.0" : "127.0.0.1");
680
+ }
681
+ const named = (request) => thisMachine(request.headers.host) || addresses().includes(request.headers.host?.replace(/:\d+$/, "") ?? "");
682
+ const PASS = `playthrough-pass=${randomBytes(16).toString("base64url")}`;
683
+ const fromThisMachine = (request) => /^(127\.|::1$|::ffff:127\.)/.test(request.socket.remoteAddress ?? "");
684
+ const passed = (request) => fromThisMachine(request) || !!request.headers.cookie?.split(/;\s*/).includes(PASS);
685
+ function turnAway(response) {
686
+ response.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
687
+ response.end("Playthrough passes the app on to this machine, and to whoever opened the address of a moment taken over. Open that address first.");
675
688
  }
676
689
  function asIfDirect(request, target) {
677
690
  const headers = {
@@ -681,6 +694,9 @@ function asIfDirect(request, target) {
681
694
  const from = `http://${request.headers.host}`;
682
695
  if (headers.origin) headers.origin = target.origin;
683
696
  if (headers.referer?.startsWith(from)) headers.referer = target.origin + headers.referer.slice(from.length);
697
+ const cookie = headers.cookie?.split(/;\s*/).filter((one) => one !== PASS).join("; ");
698
+ if (cookie) headers.cookie = cookie;
699
+ else delete headers.cookie;
684
700
  return headers;
685
701
  }
686
702
  const recorder = join(dirname(createRequire(import.meta.url).resolve("@rrweb/record")), "record.umd.min.cjs");
@@ -1023,17 +1039,19 @@ const SAYS = `<script>(() => {
1023
1039
  };
1024
1040
  addEventListener("load", () => setTimeout(settle, 50));
1025
1041
  })();<\/script>`;
1026
- function fromTheApp(answer, target) {
1042
+ function fromTheApp(answer, target, request) {
1027
1043
  const headers = { ...answer.headers };
1028
1044
  delete headers["x-frame-options"];
1029
- headers["access-control-allow-origin"] = "*";
1045
+ const origin = request.headers.origin;
1046
+ if (origin && URL.canParse(origin) && thisMachine(new URL(origin).host)) headers["access-control-allow-origin"] = origin;
1047
+ headers.vary = [headers.vary, "Origin"].filter(Boolean).join(", ");
1030
1048
  const policy = headers["content-security-policy"];
1031
1049
  if (policy) headers["content-security-policy"] = [policy].flat().map((rule) => rule.replace(/frame-ancestors[^;]*;?/i, ""));
1032
1050
  if (headers.location?.startsWith(target.origin)) headers.location = headers.location.slice(target.origin.length) || "/";
1033
1051
  if (headers["set-cookie"]) headers["set-cookie"] = headers["set-cookie"].map((cookie) => cookie.replace(/;\s*Domain=[^;]*/i, ""));
1034
1052
  return headers;
1035
1053
  }
1036
- function handOver(handed, response) {
1054
+ function handOver(handed, response, elsewhere) {
1037
1055
  if (!handed) {
1038
1056
  response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
1039
1057
  response.end("There's no moment ready at this address. Take it over again from the viewer, or with playthrough take-over.");
@@ -1043,6 +1061,7 @@ function handOver(handed, response) {
1043
1061
  const again = handed.again ?? [];
1044
1062
  const { pathname, search, hash } = new URL(url);
1045
1063
  const setCookies = cookies.map(({ name, value, path, expires, httpOnly }) => `${name}=${value}; Path=${path}; SameSite=Lax${httpOnly ? "; HttpOnly" : ""}${expires > 0 ? `; Expires=${(/* @__PURE__ */ new Date(expires * 1e3)).toUTCString()}` : ""}`);
1064
+ if (elsewhere) setCookies.push(`${PASS}; Path=/; SameSite=Lax; HttpOnly`);
1046
1065
  const keep = JSON.stringify({
1047
1066
  local,
1048
1067
  session,
@@ -1112,6 +1131,8 @@ async function open(repo, id) {
1112
1131
  console.log(`Playthrough is open at http://localhost:${port}/${id ?? ""}`);
1113
1132
  }
1114
1133
  async function answer(repo, page, you, request, response) {
1134
+ const stranger = fromElsewhere(request);
1135
+ if (stranger) return json(response, 403, { problems: [stranger] });
1115
1136
  const reviews = repo.reviews;
1116
1137
  const path = decodeURIComponent(new URL(request.url ?? "/", "http://localhost").pathname);
1117
1138
  const [api, collection, id, ...rest] = path.split("/").filter(Boolean);
@@ -1244,6 +1265,12 @@ async function serve(page, path, response) {
1244
1265
  });
1245
1266
  response.end(await readFile(join(page, "index.html")));
1246
1267
  }
1268
+ function fromElsewhere(request) {
1269
+ const { host, origin } = request.headers;
1270
+ if (!thisMachine(host)) return `Playthrough answers only at localhost, not at “${host}”. Open http://localhost:${request.socket.localPort}/.`;
1271
+ if (origin !== void 0 && origin !== `http://${host}`) return `Only Playthrough's own page can ask this, not ${origin}.`;
1272
+ }
1273
+ const thisMachine = (host) => /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i.test(host ?? "");
1247
1274
  async function body(request) {
1248
1275
  const chunks = [];
1249
1276
  for await (const chunk of request) chunks.push(chunk);