@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.
- package/README.md +9 -0
- package/dist/apps/chat-ui/assets/{dist-CrBzZkUp.js → dist-7YaJd19a.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-DfcIkFSN.js → dist-8Noo5eCN.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-B4lw9z4F.js → dist-BIvPnn_C.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-DGLWQLX8.js → dist-BbNE72h8.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-CGINd7XU.js → dist-CVQU42Fn.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-CyyISotB.js → dist-ChSZNqKE.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-y9D6la48.js → dist-CiYeO8nN.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-DFbnFNpX.js → dist-CjSI6y5z.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-BcT_c1EJ.js → dist-D7TCkoFT.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-sKDHTwaT.js → dist-KXCMNKIL.js} +1 -1
- package/dist/apps/chat-ui/assets/{dist-BbTaHWON.js → dist-Ubstha8t.js} +1 -1
- package/dist/apps/chat-ui/assets/{index-CUkuvI3v.js → index-CmxtUVG1.js} +3 -3
- package/dist/apps/chat-ui/index.html +1 -1
- package/dist/apps/vscode-artifacts/latest.vsix +0 -0
- package/dist/apps/vscode-artifacts/pibo-vscode-1.3.0.vsix +0 -0
- package/dist/apps/vscode-artifacts/pibo-vscode-1.3.1.vsix +0 -0
- package/dist/apps/vscode-artifacts/pibo-vscode-1.3.2.vsix +0 -0
- package/dist/cli.js +15 -1
- package/dist/config/config.js +9 -0
- package/dist/core/context-build.js +36 -2
- package/dist/core/wsl.js +100 -0
- package/dist/gateway/web.js +51 -11
- package/dist/plugins/dev-auth.js +53 -1
- package/dist/setup/cli.js +60 -8
- package/dist/web/channel.js +53 -2
- package/dist/web/http.js +6 -3
- package/docs/ops/install-user-host.md +21 -1
- package/docs/ops/vscode-extension-release.md +24 -2
- 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-
|
|
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">
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
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
|
package/dist/config/config.js
CHANGED
|
@@ -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, "&")
|
|
255
|
+
.replace(/</g, "<")
|
|
256
|
+
.replace(/>/g, ">")
|
|
257
|
+
.replace(/"/g, """)
|
|
258
|
+
.replace(/'/g, "'");
|
|
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:
|
|
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
|
|
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
|
}
|
package/dist/core/wsl.js
ADDED
|
@@ -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
|
+
}
|
package/dist/gateway/web.js
CHANGED
|
@@ -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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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 (
|
|
37
|
-
|
|
64
|
+
if (explicit === "local" || legacyDevAuth || configMode === "local") {
|
|
65
|
+
requireLocalAuthBind(options);
|
|
66
|
+
localAuthStartupWarning(options);
|
|
67
|
+
return "dev-auth";
|
|
38
68
|
}
|
|
39
|
-
|
|
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
|
-
|
|
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;
|
package/dist/plugins/dev-auth.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/dist/web/channel.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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()
|
|
119
|
-
|
|
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;
|