@pasko70/pibo 1.3.0 → 1.3.2

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.
Files changed (30) hide show
  1. package/README.md +9 -0
  2. package/dist/apps/chat-ui/assets/{dist-CrBzZkUp.js → dist-7YaJd19a.js} +1 -1
  3. package/dist/apps/chat-ui/assets/{dist-DfcIkFSN.js → dist-8Noo5eCN.js} +1 -1
  4. package/dist/apps/chat-ui/assets/{dist-B4lw9z4F.js → dist-BIvPnn_C.js} +1 -1
  5. package/dist/apps/chat-ui/assets/{dist-DGLWQLX8.js → dist-BbNE72h8.js} +1 -1
  6. package/dist/apps/chat-ui/assets/{dist-CGINd7XU.js → dist-CVQU42Fn.js} +1 -1
  7. package/dist/apps/chat-ui/assets/{dist-CyyISotB.js → dist-ChSZNqKE.js} +1 -1
  8. package/dist/apps/chat-ui/assets/{dist-y9D6la48.js → dist-CiYeO8nN.js} +1 -1
  9. package/dist/apps/chat-ui/assets/{dist-DFbnFNpX.js → dist-CjSI6y5z.js} +1 -1
  10. package/dist/apps/chat-ui/assets/{dist-BcT_c1EJ.js → dist-D7TCkoFT.js} +1 -1
  11. package/dist/apps/chat-ui/assets/{dist-sKDHTwaT.js → dist-KXCMNKIL.js} +1 -1
  12. package/dist/apps/chat-ui/assets/{dist-BbTaHWON.js → dist-Ubstha8t.js} +1 -1
  13. package/dist/apps/chat-ui/assets/{index-CUkuvI3v.js → index-CmxtUVG1.js} +3 -3
  14. package/dist/apps/chat-ui/index.html +1 -1
  15. package/dist/apps/vscode-artifacts/latest.vsix +0 -0
  16. package/dist/apps/vscode-artifacts/pibo-vscode-1.3.0.vsix +0 -0
  17. package/dist/apps/vscode-artifacts/pibo-vscode-1.3.1.vsix +0 -0
  18. package/dist/apps/vscode-artifacts/pibo-vscode-1.3.2.vsix +0 -0
  19. package/dist/cli.js +15 -1
  20. package/dist/config/config.js +9 -0
  21. package/dist/core/context-build.js +36 -2
  22. package/dist/core/wsl.js +100 -0
  23. package/dist/gateway/web.js +51 -11
  24. package/dist/plugins/dev-auth.js +53 -1
  25. package/dist/setup/cli.js +60 -8
  26. package/dist/web/channel.js +53 -2
  27. package/dist/web/http.js +6 -3
  28. package/docs/ops/install-user-host.md +21 -1
  29. package/docs/ops/vscode-extension-release.md +24 -2
  30. package/package.json +2 -1
@@ -9,7 +9,7 @@
9
9
  <link rel="manifest" href="/apps/chat/manifest.webmanifest" />
10
10
  <link rel="apple-touch-icon" href="/apps/chat/assets/pwa-images/ios/180.png" />
11
11
  <title>Pibo Web Chat</title>
12
- <script type="module" crossorigin src="/apps/chat/assets/index-CUkuvI3v.js"></script>
12
+ <script type="module" crossorigin src="/apps/chat/assets/index-CmxtUVG1.js"></script>
13
13
  <link rel="modulepreload" crossorigin href="/apps/chat/assets/rolldown-runtime-S-ySWqyJ.js">
14
14
  <link rel="modulepreload" crossorigin href="/apps/chat/assets/dist-SVPsM5Oi.js">
15
15
  <link rel="stylesheet" crossorigin href="/apps/chat/assets/index-C25VYnyb.css">
package/dist/cli.js CHANGED
@@ -37,6 +37,10 @@ function parsePositiveInteger(value) {
37
37
  }
38
38
  return parsed;
39
39
  }
40
+ function isLoopbackBindForCli(host) {
41
+ const normalized = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
42
+ return normalized === "127.0.0.1" || normalized === "::1" || normalized === "localhost";
43
+ }
40
44
  export async function runPiboCli(argv = process.argv) {
41
45
  if (argv[2] === "--help" || argv[2] === "-h") {
42
46
  printRootDiscovery();
@@ -341,11 +345,21 @@ export async function runPiboCli(argv = process.argv) {
341
345
  program
342
346
  .command("gateway:web")
343
347
  .description("Start the authenticated web gateway")
348
+ .option("--auth <mode>", "Auth service mode: 'better-auth' (default) or 'local' (loopback-only, no Google OAuth)")
344
349
  .option("--web-host <host>", "Bind the HTTP web host, for example 0.0.0.0 for LAN access")
345
350
  .option("--web-port <port>", "Bind the HTTP web host port", parsePort)
346
351
  .action(async (options) => {
347
352
  const { runWebGatewayServer } = await import("./gateway/web.js");
353
+ const authMode = options.auth;
354
+ if (authMode !== undefined && authMode !== "better-auth" && authMode !== "local") {
355
+ throw new Error(`--auth must be 'better-auth' or 'local', got '${authMode}'`);
356
+ }
357
+ if (authMode === "local" && options.webHost !== undefined && !isLoopbackBindForCli(options.webHost)) {
358
+ throw new Error(`--auth=local requires a loopback bind (127.0.0.1, ::1, or localhost). Got --web-host='${options.webHost}'. ` +
359
+ "Either drop --web-host or pick --auth=better-auth for a public bind.");
360
+ }
348
361
  await runWebGatewayServer({
362
+ authMode: authMode,
349
363
  web: {
350
364
  host: options.webHost,
351
365
  port: options.webPort,
@@ -387,7 +401,7 @@ Commands:
387
401
  tui:routed Start the local routed Pibo TUI
388
402
  tui:sessions Start the reduced Web Chat-derived session UI
389
403
  gateway Inspect and restart host gateways through safe CLI commands
390
- gateway:web Start a web gateway runtime
404
+ gateway:web Start a web gateway runtime (use --auth=local for loopback-only local auth)
391
405
 
392
406
  Next:
393
407
  pibo <command> --help
@@ -43,6 +43,12 @@ export const PIBO_CONFIG_KEYS = [
43
43
  type: "string",
44
44
  description: "SQLite path for Better Auth data.",
45
45
  },
46
+ {
47
+ key: "auth.mode",
48
+ type: "string",
49
+ description: "Auth service mode. Use 'local' to skip Google OAuth on a loopback bind. Default 'better-auth'.",
50
+ values: ["better-auth", "local"],
51
+ },
46
52
  ];
47
53
  function getKeyDefinition(key) {
48
54
  const definition = PIBO_CONFIG_KEYS.find((candidate) => candidate.key === key);
@@ -78,6 +84,9 @@ function parseConfigValue(definition, value) {
78
84
  if (definition.key === "auth.secret" && value.length < 32) {
79
85
  throw new Error("auth.secret must be at least 32 characters");
80
86
  }
87
+ if (definition.values && !definition.values.includes(value)) {
88
+ throw new Error(`${definition.key} must be one of: ${definition.values.join(", ")}`);
89
+ }
81
90
  return value;
82
91
  }
83
92
  function assertObject(value) {
@@ -243,6 +243,29 @@ async function readSkillMarkdown(path) {
243
243
  return undefined;
244
244
  }
245
245
  }
246
+ // Skills are advertised to the model via a small XML summary in the system
247
+ // prompt (see formatSkillsForPrompt in @mariozechner/pi-coding-agent). Only the
248
+ // name, description, and location land in the prompt; the full SKILL.md body
249
+ // is loaded lazily by the model via the read tool. Mirror that exact
250
+ // per-skill entry shape here so the inspector's token estimate matches what
251
+ // the model actually sees, instead of the full markdown body.
252
+ function escapeXmlAttribute(value) {
253
+ return value
254
+ .replace(/&/g, "&amp;")
255
+ .replace(/</g, "&lt;")
256
+ .replace(/>/g, "&gt;")
257
+ .replace(/"/g, "&quot;")
258
+ .replace(/'/g, "&apos;");
259
+ }
260
+ function formatSkillEntryForPrompt(skill) {
261
+ return [
262
+ " <skill>",
263
+ ` <name>${escapeXmlAttribute(skill.name)}</name>`,
264
+ ` <description>${escapeXmlAttribute(skill.description)}</description>`,
265
+ ` <location>${escapeXmlAttribute(skill.filePath)}</location>`,
266
+ " </skill>",
267
+ ].join("\n");
268
+ }
246
269
  function diagnosticsNodes(diagnostics) {
247
270
  return diagnostics.map((diagnostic, index) => ({
248
271
  id: `diagnostics/${index}`,
@@ -516,20 +539,31 @@ export async function inspectPiboContextBuild(options = {}) {
516
539
  const skillChildren = [];
517
540
  for (const skill of skills) {
518
541
  const markdown = await readSkillMarkdown(skill.filePath);
542
+ const promptEntry = formatSkillEntryForPrompt(skill);
543
+ // The skill node reports only what is actually part of the model
544
+ // prompt: the formatted <skill>...</skill> entry. The full
545
+ // SKILL.md body is loaded lazily by the model via the read tool
546
+ // (or explicitly via /skill:name / $skill-name), so it must not
547
+ // inflate the model-context token estimate. The full file path
548
+ // and size are still surfaced via metadata for inspection.
519
549
  skillChildren.push({
520
550
  id: `skills/${skill.name}`,
521
551
  kind: "skill",
522
552
  title: skill.name,
523
553
  source: "plugin",
524
554
  path: skill.filePath,
525
- bytes: markdown ? byteLength(markdown) : undefined,
555
+ bytes: byteLength(promptEntry),
526
556
  badges: ["ACTIVE"],
527
557
  metadata: {
528
558
  description: skill.description,
529
559
  filePath: skill.filePath,
530
560
  disableModelInvocation: skill.disableModelInvocation,
561
+ fullFileBytes: markdown ? byteLength(markdown) : undefined,
562
+ fullFileLoadableBy: "read tool, /skill:name command, $skill-name inline expansion",
531
563
  },
532
- hydratedText: markdown ?? `Skill metadata is loaded, but ${skill.filePath} could not be read for inspection.`,
564
+ hydratedText: markdown === undefined
565
+ ? `Skill metadata is loaded, but ${skill.filePath} could not be read for inspection.`
566
+ : promptEntry,
533
567
  state: markdown ? "active" : "warning",
534
568
  });
535
569
  }
@@ -0,0 +1,100 @@
1
+ import { readFileSync, existsSync } from "node:fs";
2
+ /**
3
+ * Pure parser for the WSL release string.
4
+ *
5
+ * WSL2 kernels report strings like:
6
+ * "5.15.90.1-microsoft-standard-WSL2"
7
+ * "5.15.0-1054-azure-wsl2"
8
+ * WSL1 kernels report:
9
+ * "4.4.0-19041-Microsoft"
10
+ *
11
+ * Returns true when the string looks like a WSL kernel release.
12
+ */
13
+ export function parseWslRelease(release) {
14
+ if (!release)
15
+ return false;
16
+ return /\b(microsoft|wsl2?)\b/i.test(release);
17
+ }
18
+ /**
19
+ * Pure parser that extracts the WSL major version (1 or 2) from a release string.
20
+ * Returns undefined when the string is not WSL or the version cannot be determined.
21
+ */
22
+ export function parseWslVersion(release) {
23
+ if (!release)
24
+ return undefined;
25
+ if (!parseWslRelease(release))
26
+ return undefined;
27
+ // WSL2 explicitly mentions "WSL2" (with digit) in the osrelease.
28
+ // WSL1 mentions "Microsoft" without the digit.
29
+ if (/\bwsl2\b/i.test(release))
30
+ return 2;
31
+ // Some WSL2 kernels do not include the literal "WSL2" token; the kernel
32
+ // version alone is not a reliable signal because WSL1 also exists. We
33
+ // require the WSL2 marker or the "microsoft-standard-WSL2" suffix to claim
34
+ // WSL2; otherwise default to WSL1 when WSL is detected.
35
+ return 1;
36
+ }
37
+ /**
38
+ * Pure parser for /etc/os-release PRETTY_NAME. Returns undefined when the file
39
+ * is not the standard os-release format or cannot be parsed.
40
+ */
41
+ export function parseDistroFromOsRelease(contents) {
42
+ if (!contents)
43
+ return undefined;
44
+ const match = contents.match(/^PRETTY_NAME=(.+)$/m);
45
+ if (!match)
46
+ return undefined;
47
+ const raw = match[1]?.trim();
48
+ if (!raw)
49
+ return undefined;
50
+ const unquoted = raw.replace(/^["']|["']$/g, "");
51
+ return unquoted.length > 0 ? unquoted : undefined;
52
+ }
53
+ function readProcOsRelease() {
54
+ try {
55
+ return readFileSync("/proc/sys/kernel/osrelease", "utf8");
56
+ }
57
+ catch {
58
+ return undefined;
59
+ }
60
+ }
61
+ function readEtcOsRelease() {
62
+ try {
63
+ return readFileSync("/etc/os-release", "utf8");
64
+ }
65
+ catch {
66
+ return undefined;
67
+ }
68
+ }
69
+ function detectWindowsMount() {
70
+ return existsSync("/mnt/c") || existsSync("/mnt/wsl");
71
+ }
72
+ /**
73
+ * Detect whether the current process is running inside a WSL distribution.
74
+ * Returns true on WSL1 and WSL2.
75
+ */
76
+ export function isWsl() {
77
+ return parseWslRelease(readProcOsRelease());
78
+ }
79
+ /**
80
+ * Return structured WSL information for the current process. Safe to call on
81
+ * any platform; non-WSL hosts return { isWsl: false, version: undefined, ... }.
82
+ */
83
+ export function getWslInfo() {
84
+ const release = readProcOsRelease();
85
+ const isWslResult = parseWslRelease(release);
86
+ if (!isWslResult) {
87
+ return {
88
+ isWsl: false,
89
+ version: undefined,
90
+ distro: undefined,
91
+ hasWindowsMount: false,
92
+ };
93
+ }
94
+ return {
95
+ isWsl: true,
96
+ version: parseWslVersion(release),
97
+ distro: parseDistroFromOsRelease(readEtcOsRelease()),
98
+ hasWindowsMount: detectWindowsMount(),
99
+ };
100
+ }
@@ -26,22 +26,61 @@ function isDockerRuntime() {
26
26
  return false;
27
27
  }
28
28
  }
29
+ export function isLoopbackHost(value) {
30
+ if (!value)
31
+ return false;
32
+ const normalized = value.startsWith("[") && value.endsWith("]") ? value.slice(1, -1) : value;
33
+ return LOOPBACK_HOSTS.has(normalized);
34
+ }
35
+ function bindHost(options) {
36
+ return options.web?.host ?? DEFAULT_WEB_CHANNEL_HOST;
37
+ }
38
+ function requireLocalAuthBind(options) {
39
+ const host = bindHost(options);
40
+ if (isLoopbackHost(host))
41
+ return;
42
+ if (isDockerRuntime()) {
43
+ console.error(`[pibo] WARNING: local auth is active in a Docker worker with bind ${host}. ` +
44
+ "The Docker network is the security boundary; ensure the host port mapping is loopback-only.");
45
+ return;
46
+ }
47
+ throw new Error(`Local auth requires a loopback bind (127.0.0.1, ::1, or localhost). Got '${host}'. ` +
48
+ "Either drop --web-host or pick authMode=better-auth for a public bind.");
49
+ }
50
+ function localAuthStartupWarning(options) {
51
+ const host = bindHost(options);
52
+ console.error(`[pibo] LOCAL AUTH ENABLED — bound to ${host}. ` +
53
+ "This mode is unsafe if the port is reachable from the public internet. " +
54
+ "Use authMode=better-auth for production deployments.");
55
+ }
29
56
  export function resolveWebGatewayAuthMode(options = {}) {
30
- if (!options.devAuth) {
31
- if (process.env.PIBO_DEV_AUTH === "1") {
32
- throw new Error("PIBO_DEV_AUTH no longer activates dev auth for gateway:web; use the Docker worker entrypoint instead.");
33
- }
34
- return "better-auth";
57
+ const configMode = loadPiboConfig().auth?.mode;
58
+ const explicit = options.authMode;
59
+ const legacyDevAuth = options.devAuth === true;
60
+ if (process.env.PIBO_DEV_AUTH === "1") {
61
+ throw new Error("PIBO_DEV_AUTH is deprecated. Use `pibo gateway:web --auth=local --web-host=127.0.0.1` on the host, " +
62
+ "or rely on the Docker worker entrypoint inside a worker.");
35
63
  }
36
- if (!isDockerRuntime()) {
37
- throw new Error("Pibo dev auth can only be enabled inside a Docker worker runtime.");
64
+ if (explicit === "local" || legacyDevAuth || configMode === "local") {
65
+ requireLocalAuthBind(options);
66
+ localAuthStartupWarning(options);
67
+ return "dev-auth";
38
68
  }
39
- return "dev-auth";
69
+ if (explicit === "better-auth" || configMode === "better-auth") {
70
+ return "better-auth";
71
+ }
72
+ return "better-auth";
40
73
  }
41
74
  function authBaseURL(options) {
42
75
  return options.auth?.baseURL ?? loadPiboConfig().auth?.baseURL;
43
76
  }
44
- function defaultWebHost(baseURL) {
77
+ function defaultWebHost(baseURL, options = {}) {
78
+ // When local auth is selected, always default to loopback bind to keep the
79
+ // loopback-bind gate trivially satisfied. The user can still override
80
+ // --web-host, but `resolveWebGatewayAuthMode` will then enforce the gate.
81
+ const mode = options.authMode ?? loadPiboConfig().auth?.mode ?? "better-auth";
82
+ if (mode === "local" && options.web?.host === undefined)
83
+ return DEFAULT_WEB_CHANNEL_HOST;
45
84
  if (!baseURL)
46
85
  return DEFAULT_WEB_CHANNEL_HOST;
47
86
  try {
@@ -58,7 +97,7 @@ export function resolveWebGatewayServerOptions(options = {}) {
58
97
  ...options,
59
98
  web: {
60
99
  ...options.web,
61
- host: options.web?.host ?? defaultWebHost(baseURL),
100
+ host: options.web?.host ?? defaultWebHost(baseURL, options),
62
101
  },
63
102
  };
64
103
  }
@@ -96,7 +135,8 @@ export function createWebPiboPluginRegistry(options = {}) {
96
135
  });
97
136
  }
98
137
  function createChatAppURL(options, host, port) {
99
- if (options.devAuth) {
138
+ const useLocalAuth = options.authMode === "local" || options.devAuth === true || loadPiboConfig().auth?.mode === "local";
139
+ if (useLocalAuth) {
100
140
  return `http://${host}:${port}/apps/chat`;
101
141
  }
102
142
  const baseURL = options.auth?.baseURL ?? loadPiboConfig().auth?.baseURL;
@@ -1,5 +1,6 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { definePiboPlugin } from "./registry.js";
3
+ import { SOCKET_PEER_HEADER } from "../web/channel.js";
3
4
  const COOKIE_NAME = "pibo_dev_session";
4
5
  const COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 7 days
5
6
  const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
@@ -33,12 +34,50 @@ function isLoopbackHost(value) {
33
34
  return false;
34
35
  }
35
36
  }
37
+ function isLoopbackSocketAddress(value) {
38
+ if (!value)
39
+ return false;
40
+ const normalized = value.startsWith("::ffff:") ? value.slice("::ffff:".length) : value;
41
+ return normalized === "::1" || normalized === "127.0.0.1" || normalized.startsWith("127.");
42
+ }
43
+ /**
44
+ * Read the TCP socket peer address attached to the request by the web host
45
+ * channel via the `x-pibo-socket-peer` header. The header is internal and
46
+ * stripped from any outgoing response, so it can be trusted.
47
+ */
48
+ export function getSocketPeerForDevAuth(request) {
49
+ return firstHeaderValue(request.headers.get(SOCKET_PEER_HEADER));
50
+ }
51
+ /**
52
+ * Check whether the TCP socket peer attached by the channel is loopback.
53
+ * Returns `false` (fail-closed) when the header is missing, so a request
54
+ * that did not flow through the web host channel is never accepted.
55
+ */
56
+ export function isLoopbackSocketPeerForDevAuth(request) {
57
+ return isLoopbackSocketAddress(getSocketPeerForDevAuth(request));
58
+ }
36
59
  export function isLoopbackDevAuthRequest(request) {
37
60
  const host = firstHeaderValue(request.headers.get("host"));
38
61
  const forwardedHost = firstHeaderValue(request.headers.get("x-forwarded-host"));
39
62
  return isLoopbackHost(host) && (!forwardedHost || isLoopbackHost(forwardedHost));
40
63
  }
41
- function createDevAuthService() {
64
+ /**
65
+ * Headers-only variants of the loopback predicates so `getSession` can
66
+ * evaluate the same checks without holding on to the full `Request`
67
+ * object. `getSession` is part of the auth service contract and only
68
+ * receives `headers`, but the channel guarantees that the same
69
+ * `host`, `x-forwarded-host`, and `x-pibo-socket-peer` headers are
70
+ * present as they would be on a full `Request`.
71
+ */
72
+ export function isLoopbackDevAuthHeaders(headers) {
73
+ const host = firstHeaderValue(headers.get("host"));
74
+ const forwardedHost = firstHeaderValue(headers.get("x-forwarded-host"));
75
+ return isLoopbackHost(host) && (!forwardedHost || isLoopbackHost(forwardedHost));
76
+ }
77
+ export function isLoopbackSocketPeerForDevAuthHeaders(headers) {
78
+ return isLoopbackSocketAddress(firstHeaderValue(headers.get(SOCKET_PEER_HEADER)));
79
+ }
80
+ export function createDevAuthService() {
42
81
  const containerToken = generateToken();
43
82
  const debugSession = {
44
83
  identity: {
@@ -58,6 +97,16 @@ function createDevAuthService() {
58
97
  },
59
98
  stop() { },
60
99
  async getSession(headers) {
100
+ // In local auth mode the loopback bind is the real security
101
+ // boundary. Once the channel has confirmed that the request
102
+ // reached us from a loopback host and a loopback TCP socket
103
+ // peer, the caller is on the same host as the gateway and
104
+ // there is no cookie jar to share. Headless clients (VS Code
105
+ // extension, CLI scripts) can use the same dev identity as the
106
+ // browser without needing the HttpOnly session cookie.
107
+ if (isLoopbackDevAuthHeaders(headers) && isLoopbackSocketPeerForDevAuthHeaders(headers)) {
108
+ return debugSession;
109
+ }
61
110
  const token = getCookieValue(headers);
62
111
  if (token === containerToken)
63
112
  return debugSession;
@@ -76,6 +125,9 @@ function createDevAuthService() {
76
125
  if (!isLoopbackDevAuthRequest(request)) {
77
126
  return Response.json({ error: "Dev auth only accepts loopback requests" }, { status: 403 });
78
127
  }
128
+ if (!isLoopbackSocketPeerForDevAuth(request)) {
129
+ return Response.json({ error: "Dev auth only accepts loopback socket peers" }, { status: 403 });
130
+ }
79
131
  const url = new URL(request.url);
80
132
  if (url.pathname === "/api/auth/sign-in/social") {
81
133
  // Simulate the Google OAuth redirect — go straight to callback
package/dist/setup/cli.js CHANGED
@@ -5,6 +5,7 @@ import { dirname, isAbsolute, join } from "node:path";
5
5
  import { Command } from "commander";
6
6
  import { getPiboConfigValue, loadPiboConfig } from "../config/config.js";
7
7
  import { getPiboHome } from "../core/pibo-home.js";
8
+ import { getWslInfo, isWsl } from "../core/wsl.js";
8
9
  function parsePort(value) {
9
10
  const port = Number(value);
10
11
  if (!Number.isInteger(port) || port < 1 || port > 65535)
@@ -117,6 +118,9 @@ export function createUserHostSetupPlan(options = {}) {
117
118
  const warnings = [];
118
119
  if (!options.domain)
119
120
  warnings.push("No production domain was provided; generated Caddy/Auth examples use placeholders.");
121
+ if (process.platform === "win32" && !isWsl()) {
122
+ warnings.push("Pibo host setup targets Linux. Native Windows is not supported. Install WSL2 (https://aka.ms/wsl) and run setup inside the WSL distribution. See docs/guides/pibo-on-windows-via-wsl.md.");
123
+ }
120
124
  const generatedFiles = [
121
125
  {
122
126
  path: `/etc/systemd/system/${serviceName}.service`,
@@ -187,6 +191,9 @@ export function createDeveloperHostSetupPlan(options = {}) {
187
191
  warnings.push("No origin fork was provided. Developer hosts should use a server-specific fork as origin.");
188
192
  if (!options.prodDomain || !options.devDomain)
189
193
  warnings.push("Production and dev domains should both be configured before requesting HTTPS certificates.");
194
+ if (process.platform === "win32" && !isWsl()) {
195
+ warnings.push("Pibo developer-host setup targets Linux. Native Windows is not supported. Install WSL2 (https://aka.ms/wsl) and run setup inside the WSL distribution. See docs/guides/pibo-on-windows-via-wsl.md.");
196
+ }
190
197
  const generatedFiles = [
191
198
  {
192
199
  path: "/etc/systemd/system/pibo-web.service",
@@ -365,7 +372,7 @@ function addCommandCheck(checks, command, required) {
365
372
  }
366
373
  function authConfigChecks(piboHome) {
367
374
  const configPath = join(piboHome, "config.json");
368
- const notReady = "Pibo web will not start until Better Auth is configured. Set auth.baseURL, auth.secret, auth.googleClientId, auth.googleClientSecret, and auth.allowedEmails.";
375
+ const notReady = "Pibo web will not start until Better Auth is configured. Set auth.baseURL, auth.secret, auth.googleClientId, auth.googleClientSecret, and auth.allowedEmails. For local-only development, use `pibo config set auth.mode local` and run `pibo gateway:web --auth=local`.";
369
376
  if (!existsSync(configPath))
370
377
  return [
371
378
  { name: "auth.ready", status: "fail", detail: notReady },
@@ -375,6 +382,24 @@ function authConfigChecks(piboHome) {
375
382
  const config = loadPiboConfig(configPath);
376
383
  const checks = [];
377
384
  const missing = [];
385
+ const authModeValue = getPiboConfigValue(config, "auth.mode");
386
+ const isLocalMode = authModeValue === "local";
387
+ const isBetterAuthMode = authModeValue === "better-auth" || authModeValue === undefined;
388
+ checks.push({
389
+ name: "auth.mode",
390
+ status: isLocalMode || isBetterAuthMode ? "ok" : "fail",
391
+ detail: isLocalMode
392
+ ? "auth.mode is 'local' — Google OAuth is not required; gateway must be bound to loopback."
393
+ : isBetterAuthMode
394
+ ? "auth.mode is unset (defaults to 'better-auth')."
395
+ : `auth.mode is '${String(authModeValue)}' — must be 'better-auth' or 'local'.`,
396
+ });
397
+ if (isLocalMode) {
398
+ // Local mode does not require any of the Better Auth keys. We still
399
+ // surface what is configured for operator awareness.
400
+ checks.unshift({ name: "auth.ready", status: "ok", detail: "Auth is in local mode. No Google OAuth required. Bind the gateway to a loopback address." });
401
+ return checks;
402
+ }
378
403
  const requiredStrings = [
379
404
  { key: "auth.baseURL", detail: "Set with `pibo config set auth.baseURL https://your-domain.example`." },
380
405
  { key: "auth.secret", detail: "Set a random value with at least 32 characters." },
@@ -458,29 +483,56 @@ async function createDoctorStatus(options) {
458
483
  const dockerInfo = commandOutput("docker", ["info", "--format", "{{.ServerVersion}}"]);
459
484
  checks.push({ name: "docker.daemon", status: dockerInfo ? "ok" : options.requireDocker ? "fail" : "warn", detail: dockerInfo ? `Docker daemon ${dockerInfo}` : "Docker daemon is not reachable" });
460
485
  }
486
+ const wslInfo = getWslInfo();
487
+ if (wslInfo.isWsl) {
488
+ const versionLabel = wslInfo.version ? `WSL${wslInfo.version}` : "WSL";
489
+ const distroLabel = wslInfo.distro ? ` (${wslInfo.distro})` : "";
490
+ checks.push({ name: "platform.wsl", status: "ok", detail: `Running inside ${versionLabel}${distroLabel}; Pibo is fully supported here. See docs/guides/pibo-on-windows-via-wsl.md.` });
491
+ }
492
+ else if (process.platform === "win32") {
493
+ checks.push({
494
+ name: "platform.wsl",
495
+ status: "fail",
496
+ detail: "Native Windows is not supported. Install WSL2 (https://aka.ms/wsl) and run Pibo inside the WSL distribution. See docs/guides/pibo-on-windows-via-wsl.md.",
497
+ });
498
+ }
461
499
  checks.push(...swapCheck(options.minSwapGb));
462
500
  checks.push(...authConfigChecks(piboHome));
463
501
  checks.push(...await dnsChecks(options.domain, options.expectedIp, "production"));
464
502
  checks.push(...await dnsChecks(options.devDomain, options.expectedIp, "development"));
503
+ const recommendations = [
504
+ "Use user-host setup for normal npm installs.",
505
+ "Use developer-host setup only when you need prod/dev gateways, Docker compute workers, GitHub App PR flow, and branch worktrees.",
506
+ "Configure auth before starting pibo-web; Better Auth requires baseURL, secret, Google OAuth values, and allowed emails.",
507
+ "Docker is only required for developer-host compute workers; user-host installs can ignore Docker warnings.",
508
+ "Swap is not created automatically; for developer hosts, provision swap at the OS level and verify it with `--min-swap-gb`.",
509
+ ];
510
+ if (wslInfo.isWsl) {
511
+ recommendations.push("You are inside WSL: install pibo with `npm install -g @pasko70/pibo` in the WSL shell, then open this folder in VSCode via the WSL extension so the editor talks to the WSL gateway.");
512
+ recommendations.push("Browser-Use and Agent-Browser work directly under WSLg on Windows 11. On Windows 10, install an X server (e.g. VcXsrv) and export DISPLAY=:0 inside WSL.");
513
+ }
514
+ else if (process.platform === "win32") {
515
+ recommendations.push("Pibo does not run natively on Windows. Install WSL2 with `wsl --install` and follow docs/guides/pibo-on-windows-via-wsl.md.");
516
+ }
465
517
  return {
466
518
  node: process.versions.node,
467
519
  nodeMajorOk,
468
520
  platform: process.platform,
469
521
  uid: typeof process.getuid === "function" ? process.getuid() : undefined,
470
522
  piboHome,
523
+ wsl: wslInfo,
471
524
  checks,
472
- recommendations: [
473
- "Use user-host setup for normal npm installs.",
474
- "Use developer-host setup only when you need prod/dev gateways, Docker compute workers, GitHub App PR flow, and branch worktrees.",
475
- "Configure auth before starting pibo-web; Better Auth requires baseURL, secret, Google OAuth values, and allowed emails.",
476
- "Docker is only required for developer-host compute workers; user-host installs can ignore Docker warnings.",
477
- "Swap is not created automatically; for developer hosts, provision swap at the OS level and verify it with `--min-swap-gb`."
478
- ],
525
+ recommendations,
479
526
  };
480
527
  }
481
528
  function printDoctorStatus(status) {
482
529
  console.log(`Node: ${status.node} (${status.nodeMajorOk ? "ok" : "requires >=24"})`);
483
530
  console.log(`Platform: ${status.platform}`);
531
+ if (status.wsl.isWsl) {
532
+ const versionLabel = status.wsl.version ? `WSL${status.wsl.version}` : "WSL";
533
+ const distroLabel = status.wsl.distro ? ` (${status.wsl.distro})` : "";
534
+ console.log(`WSL: ${versionLabel}${distroLabel}`);
535
+ }
484
536
  console.log(`PIBO_HOME: ${status.piboHome}`);
485
537
  console.log("Checks:");
486
538
  for (const check of status.checks)
@@ -6,6 +6,15 @@ import { PiboWebHttpError, nodeRequestToWebRequest, responseHtml, responseJson,
6
6
  export const DEFAULT_WEB_CHANNEL_HOST = "127.0.0.1";
7
7
  export const DEFAULT_WEB_CHANNEL_PORT = 4788;
8
8
  export const WEB_CHANNEL_NAME = "web-host";
9
+ /**
10
+ * Internal header that carries the TCP socket peer address from the web host
11
+ * channel to the auth plugin. This is one of three independent safety layers
12
+ * for the local auth service (see `docs/specs/capabilities/web-auth-and-same-origin-host.md`
13
+ * REQ-010). The header is added on the request side by the channel and MUST
14
+ * be stripped from any response by `sendWebResponse` so it never reaches the
15
+ * browser.
16
+ */
17
+ export const SOCKET_PEER_HEADER = "x-pibo-socket-peer";
9
18
  function redirect(location) {
10
19
  return new Response(null, {
11
20
  status: 302,
@@ -33,6 +42,41 @@ function firstHeaderValue(value) {
33
42
  function isLoopbackAddress(address) {
34
43
  return address === "::1" || address === "127.0.0.1" || address?.startsWith("127.") === true || address?.startsWith("::ffff:127.") === true;
35
44
  }
45
+ /**
46
+ * Return a new Request that includes the TCP socket peer address in the
47
+ * `x-pibo-socket-peer` header. The body is preserved via the request body
48
+ * stream consumed into a buffer because the original Request is not cloneable
49
+ * once the body has been read.
50
+ */
51
+ function withSocketPeerHeader(request, peerAddress) {
52
+ const headers = new Headers(request.headers);
53
+ if (peerAddress)
54
+ headers.set(SOCKET_PEER_HEADER, peerAddress);
55
+ return new Request(request.url, {
56
+ method: request.method,
57
+ headers,
58
+ body: request.body,
59
+ duplex: "half",
60
+ redirect: request.redirect,
61
+ signal: request.signal,
62
+ });
63
+ }
64
+ /**
65
+ * Strip the internal socket peer header from a Response so it never reaches
66
+ * the browser. Auth plugins that accidentally echo the header will not leak
67
+ * the TCP peer information to the client.
68
+ */
69
+ export function stripSocketPeerHeaderFromResponse(response) {
70
+ if (!response.headers.has(SOCKET_PEER_HEADER))
71
+ return response;
72
+ const headers = new Headers(response.headers);
73
+ headers.delete(SOCKET_PEER_HEADER);
74
+ return new Response(response.body, {
75
+ status: response.status,
76
+ statusText: response.statusText,
77
+ headers,
78
+ });
79
+ }
36
80
  function createRequestBaseURL(nodeRequest, host, port) {
37
81
  if (isLoopbackAddress(nodeRequest.socket.remoteAddress)) {
38
82
  const forwardedHost = firstHeaderValue(nodeRequest.headers["x-forwarded-host"]);
@@ -144,7 +188,13 @@ export function createWebHostChannel(options = {}) {
144
188
  const handleRequest = async (nodeRequest, nodeResponse) => {
145
189
  try {
146
190
  const baseURL = createRequestBaseURL(nodeRequest, host, port);
147
- const request = await nodeRequestToWebRequest(nodeRequest, baseURL);
191
+ const baseRequest = await nodeRequestToWebRequest(nodeRequest, baseURL);
192
+ // Inject the TCP socket peer into every request so the local auth
193
+ // plugin can apply the same loopback predicate from `getSession`
194
+ // regardless of whether the call came from a browser cookie, the
195
+ // VS Code extension, or a CLI script. The header is stripped from
196
+ // any outgoing response by `stripSocketPeerHeaderFromResponse`.
197
+ const request = withSocketPeerHeader(baseRequest, nodeRequest.socket.remoteAddress);
148
198
  const url = new URL(request.url);
149
199
  const canonicalRedirect = createCanonicalRedirect(request, options.canonicalBaseURL);
150
200
  if (canonicalRedirect) {
@@ -163,7 +213,8 @@ export function createWebHostChannel(options = {}) {
163
213
  return;
164
214
  }
165
215
  if (url.pathname.startsWith("/api/auth/")) {
166
- await sendWebResponse(nodeResponse, await handleAuthRequest(request));
216
+ const authResponse = stripSocketPeerHeaderFromResponse(await handleAuthRequest(request));
217
+ await sendWebResponse(nodeResponse, authResponse);
167
218
  return;
168
219
  }
169
220
  const ctx = requireContext();
package/dist/web/http.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { gzipSync } from "node:zlib";
2
2
  export const MAX_WEB_REQUEST_BODY_BYTES = 4 * 1024 * 1024;
3
3
  const MIN_COMPRESS_RESPONSE_BYTES = 1024;
4
+ const INTERNAL_SOCKET_PEER_HEADER = "x-pibo-socket-peer";
4
5
  export class PiboWebHttpError extends Error {
5
6
  statusCode;
6
7
  constructor(message, statusCode) {
@@ -115,9 +116,11 @@ function responseHeaders(webResponse) {
115
116
  const headers = {};
116
117
  const setCookie = webResponse.headers.getSetCookie?.();
117
118
  webResponse.headers.forEach((value, key) => {
118
- if (key.toLowerCase() !== "set-cookie") {
119
- headers[key] = value;
120
- }
119
+ if (key.toLowerCase() === "set-cookie")
120
+ return;
121
+ if (key.toLowerCase() === INTERNAL_SOCKET_PEER_HEADER)
122
+ return;
123
+ headers[key] = value;
121
124
  });
122
125
  if (setCookie?.length) {
123
126
  headers["set-cookie"] = setCookie;