@plannotator/pi-extension 0.28.9 → 0.28.10

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.
@@ -278,6 +278,21 @@ export interface PlannotatorConfig {
278
278
  * over this key. Read once when the Inbox starts. Default: false.
279
279
  */
280
280
  inboxPhones?: boolean;
281
+ /**
282
+ * Publish the Plannotator Inbox over the tailnet (`tailscale serve`, HTTPS,
283
+ * never funnel) at every start, for the Tailscale login that owns this
284
+ * machine. Written by the Inbox's Settings ("Over your tailnet");
285
+ * PLANNOTATOR_INBOX_TAILSCALE wins over this key, and `plannotator inbox
286
+ * --tailscale` turns it on for one run. Default: false.
287
+ */
288
+ inboxTailscale?: boolean;
289
+ /**
290
+ * Extra Tailscale logins (`Tailscale-User-Login`, e.g. "me@example.com")
291
+ * the tailnet-published Inbox lets in besides the machine's owner: for a
292
+ * shared tailnet where you sign in as another login on another device, or
293
+ * a tagged machine, which has no owner. Config only. Default: none.
294
+ */
295
+ inboxTailscaleAllow?: string[];
281
296
  /**
282
297
  * Inject a Plannotator Flavored Markdown reminder into every EnterPlanMode
283
298
  * call so the agent is aware it can enrich plans with code-file links,
@@ -1041,6 +1056,59 @@ export function resolveInboxPhones(
1041
1056
  return coerceConfigBoolean(config.inboxPhones, false);
1042
1057
  }
1043
1058
 
1059
+ /** The PLANNOTATOR_INBOX_TAILSCALE override, or undefined when it does not decide. */
1060
+ export function parseInboxTailscaleEnv(env: NodeJS.ProcessEnv = process.env): boolean | undefined {
1061
+ const v = env.PLANNOTATOR_INBOX_TAILSCALE?.trim().toLowerCase();
1062
+ if (v === "1" || v === "true" || v === "on") return true;
1063
+ if (v === "0" || v === "false" || v === "off" || v === "disabled") return false;
1064
+ return undefined;
1065
+ }
1066
+
1067
+ export type InboxTailscaleSource = "flag" | "env" | "config" | "default";
1068
+
1069
+ /**
1070
+ * Resolve whether the Plannotator Inbox publishes itself over the tailnet.
1071
+ *
1072
+ * Priority (highest wins):
1073
+ * PLANNOTATOR_INBOX_TAILSCALE → `plannotator inbox --tailscale` (this run,
1074
+ * also the control route) → config.inboxTailscale → default false
1075
+ *
1076
+ * The env var is a hard switch: `0` / `false` / `off` / `disabled` keeps it
1077
+ * off even against the flag, `1` / `true` / `on` keeps it on; an empty or
1078
+ * unrecognized value counts as unset. The flag only turns it on.
1079
+ */
1080
+ export function resolveInboxTailscale(
1081
+ config: PlannotatorConfig,
1082
+ env: NodeJS.ProcessEnv = process.env,
1083
+ flag = false,
1084
+ ): { on: boolean; source: InboxTailscaleSource } {
1085
+ const fromEnv = parseInboxTailscaleEnv(env);
1086
+ if (fromEnv !== undefined) return { on: fromEnv, source: "env" };
1087
+ if (flag) return { on: true, source: "flag" };
1088
+ const fromConfig = parseConfigBoolean(config.inboxTailscale);
1089
+ if (fromConfig !== undefined) return { on: fromConfig, source: "config" };
1090
+ return { on: false, source: "default" };
1091
+ }
1092
+
1093
+ /**
1094
+ * config.inboxTailscaleAllow, normalized: trimmed, lower-cased (logins are
1095
+ * compared case-insensitively), deduplicated. Entries that are not strings,
1096
+ * are empty, or carry whitespace or control characters are dropped: a login
1097
+ * is one token.
1098
+ */
1099
+ export function resolveInboxTailscaleAllow(config: PlannotatorConfig): string[] {
1100
+ const value = config.inboxTailscaleAllow;
1101
+ if (!Array.isArray(value)) return [];
1102
+ const out: string[] = [];
1103
+ for (const entry of value) {
1104
+ if (typeof entry !== "string") continue;
1105
+ const login = entry.trim().toLowerCase();
1106
+ if (login === "" || /[\s\u0000-\u001f\u007f]/.test(login) || out.includes(login)) continue;
1107
+ out.push(login);
1108
+ }
1109
+ return out;
1110
+ }
1111
+
1044
1112
  /**
1045
1113
  * After a POST /api/config saveConfig: whether an `agentTool` change did NOT
1046
1114
  * reach config.json. saveConfig logs a write failure and returns normally, so
@@ -220,6 +220,68 @@ export function serveStatusProxy(stdout: string, port: number): { state: "free"
220
220
  return { state: "mapped", proxy: "" };
221
221
  }
222
222
 
223
+ /**
224
+ * Every serve route in `tailscale serve status --json`, background and
225
+ * foreground: each web handler's proxy target (`Web["host:port"].Handlers`,
226
+ * every path) and each raw TCP forward (`TCP[port].TCPForward`). Undefined
227
+ * when the output is not recognizable.
228
+ */
229
+ export function serveStatusRoutes(stdout: string): { port: number; target: string }[] | undefined {
230
+ const trimmed = stdout.trim();
231
+ if (trimmed === "") return undefined;
232
+ let parsed: unknown;
233
+ try {
234
+ parsed = JSON.parse(trimmed);
235
+ } catch {
236
+ return undefined;
237
+ }
238
+ if (parsed === null) return [];
239
+ if (typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
240
+ const routes: { port: number; target: string }[] = [];
241
+ const foreground = (parsed as { Foreground?: unknown }).Foreground;
242
+ const configs = [parsed, ...(foreground && typeof foreground === "object" ? Object.values(foreground as Record<string, unknown>) : [])];
243
+ for (const config of configs) {
244
+ if (!config || typeof config !== "object") continue;
245
+ const tcp = (config as { TCP?: unknown }).TCP;
246
+ if (tcp && typeof tcp === "object") {
247
+ for (const [port, entry] of Object.entries(tcp as Record<string, unknown>)) {
248
+ const forward = (entry as { TCPForward?: unknown } | null)?.TCPForward;
249
+ if (typeof forward === "string" && /^\d+$/.test(port)) routes.push({ port: Number(port), target: `tcp://${forward}` });
250
+ }
251
+ }
252
+ const web = (config as { Web?: unknown }).Web;
253
+ if (web && typeof web === "object") {
254
+ for (const [hostPort, entry] of Object.entries(web as Record<string, unknown>)) {
255
+ const port = /:(\d+)$/.exec(hostPort)?.[1];
256
+ const handlers = (entry as { Handlers?: unknown } | null)?.Handlers;
257
+ if (!port || !handlers || typeof handlers !== "object") continue;
258
+ for (const handler of Object.values(handlers as Record<string, unknown>)) {
259
+ const proxy = (handler as { Proxy?: unknown } | null)?.Proxy;
260
+ if (typeof proxy === "string") routes.push({ port: Number(port), target: proxy });
261
+ }
262
+ }
263
+ }
264
+ }
265
+ return routes;
266
+ }
267
+
268
+ /**
269
+ * A serve target that lands on `port` on this machine's loopback:
270
+ * `http://127.0.0.1:<port>`, `http://localhost:<port>/x`, `https+insecure://…`,
271
+ * or a TCP forward `tcp://127.0.0.1:<port>`.
272
+ */
273
+ export function serveTargetIsLoopbackPort(target: string, port: number): boolean {
274
+ let url: URL;
275
+ try {
276
+ url = new URL(target.replace(/^https\+insecure:/, "https:"));
277
+ } catch {
278
+ return false;
279
+ }
280
+ const host = url.hostname.toLowerCase();
281
+ const loopback = host === "localhost" || host === "[::1]" || host === "::1" || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
282
+ return loopback && url.port !== "" && Number(url.port) === port;
283
+ }
284
+
223
285
  /**
224
286
  * First https URL in `tailscale serve --bg` output whose port matches the
225
287
  * port we asked to publish, sans trailing slash. Serve output is
@@ -241,6 +303,86 @@ export function extractServeHttpsUrl(output: string, expectedPort: number): stri
241
303
  return undefined;
242
304
  }
243
305
 
306
+ /** Who owns this machine on the tailnet, as `tailscale status --json` reports it. */
307
+ export interface TailscaleSelfIdentity {
308
+ /** The owning user's login (`User[Self.UserID].LoginName`); null for a tagged machine or when Tailscale does not say. */
309
+ login: string | null;
310
+ /** The machine carries ACL tags: it has no owning user, and serve sends no identity for tagged peers. */
311
+ tagged: boolean;
312
+ }
313
+
314
+ /**
315
+ * The login that owns this machine: `Self.UserID` looked up in the `User`
316
+ * map (keyed by the id as a string). A tagged machine (`Self.Tags`
317
+ * non-empty) is owned by its tags, not a person, so its login is null even
318
+ * though Tailscale lists a "tagged-devices" pseudo-user for it. Undefined
319
+ * when the output is not a status document.
320
+ */
321
+ export function parseTailscaleSelfIdentity(stdout: string): TailscaleSelfIdentity | undefined {
322
+ let parsed: unknown;
323
+ try {
324
+ parsed = JSON.parse(stdout);
325
+ } catch {
326
+ return undefined;
327
+ }
328
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
329
+ const self = (parsed as { Self?: unknown }).Self;
330
+ if (!self || typeof self !== "object" || Array.isArray(self)) return undefined;
331
+ const tags = (self as { Tags?: unknown }).Tags;
332
+ if (Array.isArray(tags) && tags.length > 0) return { login: null, tagged: true };
333
+ const userId = (self as { UserID?: unknown }).UserID;
334
+ const users = (parsed as { User?: unknown }).User;
335
+ if ((typeof userId !== "number" && typeof userId !== "string") || !users || typeof users !== "object") {
336
+ return { login: null, tagged: false };
337
+ }
338
+ const profile = (users as Record<string, unknown>)[String(userId)];
339
+ const login = (profile as { LoginName?: unknown } | null | undefined)?.LoginName;
340
+ return { login: typeof login === "string" && login.trim() !== "" ? login.trim() : null, tagged: false };
341
+ }
342
+
343
+ /**
344
+ * A `Tailscale-User-*` header value as serve writes it: ASCII as is, anything
345
+ * else RFC 2047 Q-encoded (`=?utf-8?q?J=C3=BCrgen?=`, Go's
346
+ * `mime.QEncoding.Encode`, as one or more encoded words separated by
347
+ * whitespace, which RFC 2047 drops between adjacent words). Returns the
348
+ * decoded text, or null when an encoded word is malformed or not UTF-8:
349
+ * never a guess, since the caller compares it to a login.
350
+ */
351
+ export function decodeTailscaleHeaderValue(value: string): string | null {
352
+ const trimmed = value.trim();
353
+ if (!trimmed.includes("=?")) return trimmed;
354
+ let out = "";
355
+ for (const word of trimmed.split(/\s+/)) {
356
+ const match = /^=\?utf-8\?q\?([^?]*)\?=$/i.exec(word);
357
+ if (!match) return null;
358
+ const text = match[1]!;
359
+ const bytes: number[] = [];
360
+ for (let i = 0; i < text.length; i++) {
361
+ const ch = text[i]!;
362
+ if (ch === "_") {
363
+ bytes.push(0x20);
364
+ } else if (ch === "=") {
365
+ const hex = text.slice(i + 1, i + 3);
366
+ if (!/^[0-9a-f]{2}$/i.test(hex)) return null;
367
+ bytes.push(parseInt(hex, 16));
368
+ i += 2;
369
+ } else {
370
+ const code = ch.charCodeAt(0);
371
+ if (code > 0x7e || code < 0x21) return null;
372
+ bytes.push(code);
373
+ }
374
+ }
375
+ try {
376
+ out += new TextDecoder("utf-8", { fatal: true }).decode(new Uint8Array(bytes));
377
+ } catch {
378
+ return null;
379
+ }
380
+ }
381
+ // Serve encodes only non-ASCII values; an all-ASCII encoded word is not
382
+ // what serve writes, so it is compared as it came, never as a login.
383
+ return /^[\x00-\x7f]*$/.test(out) ? trimmed : out;
384
+ }
385
+
244
386
  /**
245
387
  * urlHost "auto": resolve this machine's tailnet host once per process.
246
388
  * Detection is display-only like every urlHost value; callers gate on
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plannotator/pi-extension",
3
- "version": "0.28.9",
3
+ "version": "0.28.10",
4
4
  "type": "module",
5
5
  "description": "Plannotator Pi extension - interactive plan review with annotations, annotate agent messages, and review code/PRs",
6
6
  "author": "backnotprop",