@threadbase-sh/streamer 1.67.4 → 1.68.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/dist/index.js CHANGED
@@ -2569,6 +2569,9 @@ import { basename as basename2 } from "path";
2569
2569
  function permissionContentKey(gate) {
2570
2570
  return `${gate.prompt ?? ""}::${gate.detail ?? ""}::${gate.options.map((o) => `${o.index}.${o.label}`).join(",")}::${gate.cursor ?? ""}`;
2571
2571
  }
2572
+ function permissionGateKey(gate) {
2573
+ return permissionContentKey({ ...gate, cursor: void 0 });
2574
+ }
2572
2575
  var OSC_777_PERMISSION_RE = /\x1b\]777;notify;Claude Code;[^\x07\x1b]*needs your permission/;
2573
2576
  var OSC_777_WAITING_RE = /\x1b\]777;notify;Claude Code;[^\x07\x1b]*waiting for your input/;
2574
2577
  function hasPermissionOsc(rawData) {
@@ -6340,6 +6343,10 @@ var createSessionRoutes = (deps) => {
6340
6343
  await deps.handleSendAnswer(c.req.param("id"), c.env.incoming, c.env.outgoing);
6341
6344
  return alreadyHandled6();
6342
6345
  });
6346
+ app.post("/:id/permission/answer", async (c) => {
6347
+ await deps.handlePermissionAnswer(c.req.param("id"), c.env.incoming, c.env.outgoing);
6348
+ return alreadyHandled6();
6349
+ });
6343
6350
  app.post("/:id/files", async (c) => {
6344
6351
  await deps.handleUploadFile(c.req.param("id"), c.env.incoming, c.env.outgoing);
6345
6352
  return alreadyHandled6();
@@ -10506,9 +10513,98 @@ var SessionHandlers = class {
10506
10513
  ...gate.prompt ? { prompt: gate.prompt } : {},
10507
10514
  ...gate.detail ? { detail: gate.detail } : {},
10508
10515
  options: gate.options,
10509
- ...gate.cursor !== void 0 ? { cursor: gate.cursor } : {}
10516
+ ...gate.cursor !== void 0 ? { cursor: gate.cursor } : {},
10517
+ contentKey: permissionGateKey(gate)
10510
10518
  });
10511
10519
  }
10520
+ /**
10521
+ * Answer a permission gate — the validated counterpart of POST /:id/input.
10522
+ *
10523
+ * `/input` is a raw-bytes conduit (arrow-key nav uses it too) and stays that
10524
+ * way; this route is the semantic one, mirroring the /answer split. Two
10525
+ * things make it more than validation theatre:
10526
+ *
10527
+ * - The client sends `{ contentKey, optionIndex }` and NO keystrokes. The
10528
+ * keys are derived here from our own copy of the gate, so the client's
10529
+ * key-derivation can never drift from the server's.
10530
+ * - `contentKey` binds the answer to a specific gate. `isPermissionAnswer`
10531
+ * matches structurally, and approval gates repeat constantly ("2. Yes /
10532
+ * 3. No" for every tool call), so without this a delayed answer to gate A
10533
+ * could be written as gate B's answer — a user approving a bash command
10534
+ * they never saw, with a 200 and a normal permission_cancelled. Treat the
10535
+ * check as a security boundary.
10536
+ *
10537
+ * Every refusal happens BEFORE sendKeys. On success we deliberately broadcast
10538
+ * nothing: the PTY-side close (isPermissionAnswer in pty-manager) recognises
10539
+ * the bytes we just wrote and fires permission_cancelled itself.
10540
+ */
10541
+ async handlePermissionAnswer(sessionId, req, res) {
10542
+ const body = await readBody2(req);
10543
+ const contentKey = body?.contentKey;
10544
+ const optionIndex = body?.optionIndex;
10545
+ if (typeof contentKey !== "string" || !Number.isInteger(optionIndex) || optionIndex < 0) {
10546
+ json(res, 400, { ok: false, reason: "Expected { contentKey: string, optionIndex: number }" });
10547
+ return;
10548
+ }
10549
+ const gateClosed = () => {
10550
+ this.pendingPermission.delete(sessionId);
10551
+ this.pendingPermissionKey.delete(sessionId);
10552
+ this.wsHub.broadcast({ type: "permission_cancelled", sessionId });
10553
+ json(res, 409, { ok: false, reason: "gate_closed" });
10554
+ };
10555
+ const gate = this.pendingPermission.get(sessionId);
10556
+ if (!gate) {
10557
+ gateClosed();
10558
+ return;
10559
+ }
10560
+ if (permissionGateKey(gate) !== contentKey) {
10561
+ json(res, 409, { ok: false, reason: "gate_mismatch" });
10562
+ return;
10563
+ }
10564
+ const option = gate.options[optionIndex];
10565
+ if (!option) {
10566
+ json(res, 409, { ok: false, reason: "unknown_option" });
10567
+ return;
10568
+ }
10569
+ if (!await this.permissionGateStillOpen(sessionId, contentKey)) {
10570
+ gateClosed();
10571
+ return;
10572
+ }
10573
+ try {
10574
+ this.ptyManager.sendKeys(sessionId, option.answerKeys ?? permissionAnswerKeys(option.index));
10575
+ } catch (err) {
10576
+ const message = err instanceof Error ? err.message : "Failed to send answer";
10577
+ json(res, 400, { ok: false, reason: message });
10578
+ return;
10579
+ }
10580
+ json(res, 200, { ok: true });
10581
+ }
10582
+ /**
10583
+ * Is THIS gate still the one on screen?
10584
+ *
10585
+ * Deliberately stricter than questionMenuStillOpen's "is a menu up": the
10586
+ * staleness window this exists to cover (the ~300ms scrape throttle plus the
10587
+ * wait for the next PTY chunk) is exactly where pendingPermission still says
10588
+ * gate A while the screen has moved to gate B — and since approval gates
10589
+ * repeat their shape, "some gate is open" would wave that through.
10590
+ *
10591
+ * Reads 60 lines because that is the window the detector that produced the
10592
+ * pending gate uses (pty-manager's scrape); `detail` walks up to 6 lines
10593
+ * above the prompt, so a shorter window can truncate it and manufacture a
10594
+ * mismatch on a healthy gate.
10595
+ *
10596
+ * Best-effort, like questionMenuStillOpen: a session we hold no PTY for, or
10597
+ * one that raced away mid-read, is not ours to veto.
10598
+ */
10599
+ async permissionGateStillOpen(sessionId, contentKey) {
10600
+ if (!this.ptyManager.hasSession(sessionId)) return true;
10601
+ try {
10602
+ const onScreen = scrapePermissionGate(await this.ptyManager.getOutputLines(sessionId, 60));
10603
+ return onScreen !== null && permissionGateKey(onScreen) === contentKey;
10604
+ } catch {
10605
+ return true;
10606
+ }
10607
+ }
10512
10608
  async handleSendAnswer(sessionId, req, res) {
10513
10609
  const body = await readBody2(req);
10514
10610
  const pending = this.pendingQuestions.get(sessionId);
@@ -12955,6 +13051,7 @@ function createApiDeps(deps) {
12955
13051
  handleGetOutput: (id, res) => deps.sessionHandlers.handleGetOutput(id, res),
12956
13052
  handleSendInput: (id, req, res) => deps.sessionHandlers.handleSendInput(id, req, res),
12957
13053
  handleSendAnswer: (id, req, res) => deps.sessionHandlers.handleSendAnswer(id, req, res),
13054
+ handlePermissionAnswer: (id, req, res) => deps.sessionHandlers.handlePermissionAnswer(id, req, res),
12958
13055
  handleCancel: (id, res) => deps.sessionHandlers.handleCancel(id, res),
12959
13056
  handleStopSession: (id, res) => deps.sessionHandlers.handleStopSession(id, res),
12960
13057
  handleSetSessionName: (id, req, res) => deps.sessionHandlers.handleSetSessionName(id, req, res),
@@ -13038,7 +13135,8 @@ function createApiDeps(deps) {
13038
13135
  ...pendingGate.prompt ? { prompt: pendingGate.prompt } : {},
13039
13136
  ...pendingGate.detail ? { detail: pendingGate.detail } : {},
13040
13137
  options: pendingGate.options,
13041
- ...pendingGate.cursor !== void 0 ? { cursor: pendingGate.cursor } : {}
13138
+ ...pendingGate.cursor !== void 0 ? { cursor: pendingGate.cursor } : {},
13139
+ contentKey: permissionGateKey(pendingGate)
13042
13140
  })
13043
13141
  );
13044
13142
  }