shadok-ai 0.16.19 → 0.16.21

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
@@ -341,12 +341,21 @@ Enter to skip; you can add it later from the web UI).
341
341
  The Telegram panel (header icon) carries a folded *How to set it up, step by step*
342
342
  guide, open until a token is saved and a group is bound.
343
343
 
344
+ **Pairing.** The bot answers nobody until it is paired from the cockpit. In the
345
+ Telegram panel (admin only), click **Pair Telegram**: it shows an 8-digit code,
346
+ valid 10 minutes, single-use, cancelled after 5 wrong tries. Send `/setup <code>`
347
+ in the group to make it the board, or in a private chat with the bot to make its
348
+ DMs yours — or use the panel's one-click links, which pass the code for you.
349
+ Whoever enters the code becomes the owner of the bot's DMs. A group bound before
350
+ pairing existed stays bound; to move to another group, unbind it in the panel
351
+ and pair again.
352
+
344
353
  Add your bot to a group with **Topics** enabled, make it an admin with *Manage
345
354
  topics* (and *Delete messages* so `/secret` can scrub values), then in the group:
346
355
 
347
356
  | Command | |
348
357
  |---|---|
349
- | `/setup` | bind this group as the board (one group per instance) |
358
+ | `/setup <code>` | bind this group as the board (one group per instance), with the code from the panel's **Pair Telegram** |
350
359
  | `/spawn [profile] <name>` | new isolated agent in its own topic (also a web tab) |
351
360
  | `/stop` (alias `/esc`) | interrupt the current turn — does **not** end the session |
352
361
  | `/new` · `/end` | reset · kill the session |
package/dist/server.js CHANGED
@@ -39,6 +39,7 @@ import { getUsage } from "./usage.js";
39
39
  import { loadChannels, loadGroups, saveGroups, upsertChannel, removeChannel, mergeClientChannels, channelWriteAllowed, profilePatch, loadTgGroup, saveTgGroup, isHomeChannel, homeAdoptionTarget, resolveSessionTarget, resolveResumeId, resumeTarget, } from "./channels.js";
40
40
  import { deliverWithRetry } from "./reload.js";
41
41
  import { startTelegram, renameTelegramTopic, closeTelegramTopic, probeToken, isStalePreface, announceLoggedOut, resetLoggedOutNotice, attachmentPrompt, pasteFileName, MEDIA_DIR, } from "./telegram.js";
42
+ import { cancelPairingCode, issuePairingCode, pairingStatus } from "./tg-pairing.js";
42
43
  import { migrateTgBindings } from "./channels.js";
43
44
  import { instanceKey } from "./paths.js";
44
45
  import { peerFileFor, loadPeers, savePeers, peerFromToken, signPeerToken, newPeer, withSalts, redeemVerdict, redeemPeer, normPeerName, removePeer, ownedByPeer, peersOutFileFor, agentInvitePrompt, peerScope, } from "./peers.js";
@@ -1709,7 +1710,29 @@ app.put("/channels", (req, res) => {
1709
1710
  // drop a live or Telegram-bound session or strip server-owned fields.
1710
1711
  const live = new Set([...sessions.values()].filter((s) => !s.pilot.hasExited).map((s) => s.id));
1711
1712
  const before = new Map(loadChannels().map((c) => [c.sessionId, c.name]));
1712
- const merged = mergeClientChannels(Array.isArray(req.body) ? req.body : [], live);
1713
+ const body = (Array.isArray(req.body) ? req.body : []);
1714
+ // Mirroring is admin-managed (cf. the Telegram config gate and the `start`
1715
+ // handler): a non-admin may reorder / rename / regroup their agents, but must
1716
+ // not flip an agent's Telegram mirror through a channel write (the agent-menu
1717
+ // toggle persists via this endpoint). Pin each incoming `mirror` to its stored
1718
+ // value for a non-admin; a brand-new channel they create is left un-mirrored.
1719
+ // Admins — and agents, who authenticate as the bootstrap admin — pass through.
1720
+ if (currentAccount(req)?.role !== "admin") {
1721
+ const storedMirror = new Map(loadChannels().map((c) => [c.sessionId, c.mirror]));
1722
+ for (const c of body) {
1723
+ if (!c || typeof c.sessionId !== "string") {
1724
+ if (c)
1725
+ delete c.mirror;
1726
+ continue;
1727
+ }
1728
+ const prev = storedMirror.get(c.sessionId);
1729
+ if (prev === undefined)
1730
+ delete c.mirror;
1731
+ else
1732
+ c.mirror = prev;
1733
+ }
1734
+ }
1735
+ const merged = mergeClientChannels(body, live);
1713
1736
  // A web-side rename of a Telegram-bound channel → rename its topic too.
1714
1737
  for (const c of merged) {
1715
1738
  if (c.telegram?.threadId && c.name && c.name !== before.get(c.sessionId)) {
@@ -1845,7 +1868,10 @@ function accountAdmin(req, res,
1845
1868
  * CHANGE something, where the browser does send the header. A read stays
1846
1869
  * admin-only, which is what it was designed to be.
1847
1870
  */
1848
- browserOnly = true) {
1871
+ browserOnly = true,
1872
+ /** What the 403 says the caller is not allowed to do. The guard is reused
1873
+ * beyond accounts (e.g. the Telegram config), so the message names the act. */
1874
+ what = "manage accounts") {
1849
1875
  if (browserOnly && !requestFromBrowser(req)) {
1850
1876
  res.status(403).json({ error: "same-origin browser only" });
1851
1877
  return null;
@@ -1854,7 +1880,7 @@ browserOnly = true) {
1854
1880
  // is the bootstrap admin, and must not be able to reset anyone.
1855
1881
  const me = twoFactorOn() ? cookieAccount(req) : currentAccount(req);
1856
1882
  if (me?.role !== "admin") {
1857
- res.status(403).json({ error: "only an admin can manage accounts" });
1883
+ res.status(403).json({ error: `only an admin can ${what}` });
1858
1884
  return null;
1859
1885
  }
1860
1886
  return me;
@@ -2666,8 +2692,19 @@ async function telegramState() {
2666
2692
  // Telegram bot config — token/on-off/allowed-chats, applied hot (no restart).
2667
2693
  // Behind the GUI password gate like the rest. The token is write-only: never
2668
2694
  // returned here, only set/removed.
2669
- app.get("/telegram", async (_req, res) => res.json(await telegramState()));
2695
+ // The Telegram bot config is an instance-wide integration (one bot, one board,
2696
+ // owned by whoever runs the cockpit), so reading or changing it is ADMIN-ONLY —
2697
+ // a `member` has no business touching the bot token, the allowed chats or the
2698
+ // on/off switch. GET is browserOnly:false (a same-origin read sends no Origin);
2699
+ // PUT keeps the write-side CSRF guard.
2700
+ app.get("/telegram", async (req, res) => {
2701
+ if (!accountAdmin(req, res, false, "view or change the Telegram configuration"))
2702
+ return;
2703
+ res.json(await telegramState());
2704
+ });
2670
2705
  app.put("/telegram", async (req, res) => {
2706
+ if (!accountAdmin(req, res, true, "view or change the Telegram configuration"))
2707
+ return;
2671
2708
  const b = req.body ?? {};
2672
2709
  const patch = {};
2673
2710
  if (typeof b.token === "string" || b.token === null)
@@ -2692,6 +2729,37 @@ app.put("/telegram", async (req, res) => {
2692
2729
  }
2693
2730
  res.json(await telegramState());
2694
2731
  });
2732
+ // Pairing code for the bot (`src/tg-pairing.ts`): binding the board group or
2733
+ // owning the DMs needs a code only this admin-only panel shows. POST issues a
2734
+ // fresh one (revoking any previous code) and is the ONE response that carries
2735
+ // it; GET reports whether one is live, never the code itself. Browser-only
2736
+ // writes, like the rest of the Telegram config: an agent must not be able to
2737
+ // mint a code and pair a Telegram account of its choosing.
2738
+ app.get("/telegram/pairing", (req, res) => {
2739
+ if (!accountAdmin(req, res, false, "pair the Telegram bot"))
2740
+ return;
2741
+ res.json(pairingStatus());
2742
+ });
2743
+ app.post("/telegram/pairing", async (req, res) => {
2744
+ if (!accountAdmin(req, res, true, "pair the Telegram bot"))
2745
+ return;
2746
+ const p = issuePairingCode();
2747
+ const username = (await telegramState()).username;
2748
+ const bot = username ? String(username).replace(/^@/, "") : null;
2749
+ res.json({
2750
+ code: p.code,
2751
+ expiresAt: p.expiresAt,
2752
+ // Deep links: Telegram delivers `/start <code>` by itself, so one tap pairs.
2753
+ groupLink: bot ? `https://t.me/${bot}?startgroup=${p.code}` : null,
2754
+ dmLink: bot ? `https://t.me/${bot}?start=${p.code}` : null,
2755
+ });
2756
+ });
2757
+ app.delete("/telegram/pairing", (req, res) => {
2758
+ if (!accountAdmin(req, res, true, "pair the Telegram bot"))
2759
+ return;
2760
+ cancelPairingCode();
2761
+ res.json(pairingStatus());
2762
+ });
2695
2763
  const server = http.createServer(app);
2696
2764
  const wss = new WebSocketServer({
2697
2765
  server,
@@ -4765,6 +4833,15 @@ wss.on("connection", (ws, req) => {
4765
4833
  cwd: effectiveCwd,
4766
4834
  branch: worktree?.branch ?? null,
4767
4835
  });
4836
+ // Mirroring drops an agent into the (admin-owned) Telegram board, so
4837
+ // only an admin may enable it. A non-admin's `true` is DROPPED (not a
4838
+ // spawn failure — same spirit as a refused parent link); an explicit
4839
+ // `false`, or an absent field, is left untouched. Agents authenticate
4840
+ // as the bootstrap admin (session key), so pilotctl-driven mirroring
4841
+ // is unaffected.
4842
+ const effMirror = typeof msg.mirror === "boolean" ? msg.mirror && me?.role === "admin" : undefined;
4843
+ if (msg.mirror === true && me?.role !== "admin")
4844
+ console.log(`mirror: refused for non-admin ${me?.name ?? "anon"} on ${id.slice(0, 8)}`);
4768
4845
  upsertChannel({
4769
4846
  sessionId: id,
4770
4847
  cwd: effectiveCwd,
@@ -4803,8 +4880,8 @@ wss.on("connection", (ws, req) => {
4803
4880
  // The form's choice, recorded as soon as we're `ready` — that's
4804
4881
  // where the Telegram loop reads it. Absent (a client that ignores
4805
4882
  // the field) → decide nothing, and `isMirrored` falls back to the
4806
- // binding.
4807
- ...(typeof msg.mirror === "boolean" ? { mirror: msg.mirror } : {}),
4883
+ // binding. Admin-gated just above (`effMirror`).
4884
+ ...(effMirror !== undefined ? { mirror: effMirror } : {}),
4808
4885
  // Who launched this agent, sent by pilotctl from its own
4809
4886
  // SHADOK_SESSION_ID. ASSERT-only, like `branch` and `repo`: a
4810
4887
  // client that omits the key must not erase a link that already