🎉 Initial commit: PiAgentUI — AI Agent Desktop & Web UI
Build & Push Frontend Docker Image / build-and-push (push) Canceled after 0s
Build Validation / validate (push) Canceled after 6s
Build & Push Backend Docker Image / build-and-push (push) Failing after 10m10s
Build & Push Gateway Docker Image / build-and-push (push) Failing after 10m9s
Build & Push Frontend Docker Image / build-and-push (push) Canceled after 0s
Build Validation / validate (push) Canceled after 6s
Build & Push Backend Docker Image / build-and-push (push) Failing after 10m10s
Build & Push Gateway Docker Image / build-and-push (push) Failing after 10m9s
A professional workspace for AI coding agents with: - Real-time streaming chat with SSE - Session management (fork, clone, tree navigation) - MCP tools browser & metadata panel - Skills browser (global, project, package sources) - Voice input & STT provider configuration - Integrated terminal & file explorer - Model selector with capability metadata - Keyboard-first UI with command palette - 8 languages (en, pt-BR, es, ja, zh-CN, ar, bn, hi) - Tauri 2 desktop app - React 19 + TypeScript + Vite 8 + Tailwind CSS 4 - Pi Agent extension runtime Originally forked from OpenCodeUI, now an independent project. License: GPL-3.0-only
This commit is contained in:
commit
b3489e1ede
983 files changed
+158475
No files matched your search
@@ -0,0 +1,172 @@
|
||||
import * as path from 'node:path'
|
||||
import * as os from 'node:os'
|
||||
|
||||
/**
|
||||
* Checks if a host string matches a loopback host (localhost or 127.0.0.1 or ::1)
|
||||
*/
|
||||
export function isLoopbackHost(host: string | undefined): boolean {
|
||||
if (!host) return false
|
||||
|
||||
// Handle bracketed IPv6 hosts (e.g. "[::1]" or "[::1]:3000")
|
||||
if (host.startsWith('[')) {
|
||||
const closingBracket = host.indexOf(']')
|
||||
if (closingBracket !== -1) {
|
||||
const hostname = host.slice(1, closingBracket)
|
||||
return hostname === '::1'
|
||||
}
|
||||
}
|
||||
|
||||
// Handle port splitting for IPv4 or localhost
|
||||
const lastColon = host.lastIndexOf(':')
|
||||
let hostname = host
|
||||
if (lastColon !== -1) {
|
||||
const colonsCount = (host.match(/:/g) || []).length
|
||||
if (colonsCount === 1 || host.includes('localhost')) {
|
||||
hostname = host.slice(0, lastColon)
|
||||
}
|
||||
}
|
||||
|
||||
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the Authorization header against the expected bearer token
|
||||
*/
|
||||
export function validateBearerToken(authHeader: string | undefined, expectedToken: string): boolean {
|
||||
if (!authHeader || !expectedToken) return false
|
||||
if (!authHeader.startsWith('Bearer ')) return false
|
||||
const token = authHeader.substring(7).trim()
|
||||
return token === expectedToken
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevents path traversal out of the designated base directory
|
||||
*/
|
||||
export function preventPathTraversal(baseDir: string, relativePath: string): boolean {
|
||||
try {
|
||||
const resolvedBase = path.resolve(baseDir)
|
||||
const resolvedTarget = path.resolve(path.join(baseDir, relativePath))
|
||||
return resolvedTarget.startsWith(resolvedBase)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates both Host and Origin headers to be local/loopback only
|
||||
*/
|
||||
export function validateHostAndOrigin(host: string | undefined, origin: string | undefined): boolean {
|
||||
return validateHostAndOriginForAccess(host, origin, false)
|
||||
}
|
||||
|
||||
function getHostname(host: string | undefined): string {
|
||||
if (!host) return ''
|
||||
if (host.startsWith('[')) {
|
||||
const closingBracket = host.indexOf(']')
|
||||
return closingBracket === -1 ? host : host.slice(1, closingBracket)
|
||||
}
|
||||
|
||||
const lastColon = host.lastIndexOf(':')
|
||||
const colonsCount = (host.match(/:/g) || []).length
|
||||
if (lastColon !== -1 && colonsCount === 1) {
|
||||
return host.slice(0, lastColon)
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
function isAllowedLanHost(host: string | undefined): boolean {
|
||||
const hostname = getHostname(host)
|
||||
if (!hostname || isLoopbackHost(hostname)) return false
|
||||
if (hostname === '0.0.0.0') return false
|
||||
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(hostname)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function isLikelyVirtualInterface(name: string, entry: os.NetworkInterfaceInfo): boolean {
|
||||
const normalizedName = name.toLowerCase()
|
||||
if (
|
||||
normalizedName.includes('wsl') ||
|
||||
normalizedName.includes('hyper-v') ||
|
||||
normalizedName.includes('vethernet') ||
|
||||
normalizedName.includes('default switch') ||
|
||||
normalizedName.includes('docker') ||
|
||||
normalizedName.includes('virtualbox') ||
|
||||
normalizedName.includes('vmware') ||
|
||||
normalizedName.includes('zerotier') ||
|
||||
normalizedName.includes('tailscale')
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Hyper-V virtual adapters commonly use Microsoft's 00:15:5d OUI.
|
||||
return entry.mac.toLowerCase().startsWith('00:15:5d:')
|
||||
}
|
||||
|
||||
export function validateHostAndOriginForAccess(
|
||||
host: string | undefined,
|
||||
origin: string | undefined,
|
||||
lanAccessEnabled: boolean,
|
||||
): boolean {
|
||||
const hostAllowed = isLoopbackHost(host) || (lanAccessEnabled && isAllowedLanHost(host))
|
||||
if (!hostAllowed) return false
|
||||
|
||||
if (origin) {
|
||||
try {
|
||||
const url = new URL(origin)
|
||||
const originAllowed = isLoopbackHost(url.host) || (lanAccessEnabled && isAllowedLanHost(url.host))
|
||||
if (!originAllowed) return false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function getLocalNetworkUrls(
|
||||
port: number,
|
||||
interfaces: NodeJS.Dict<os.NetworkInterfaceInfo[]> = os.networkInterfaces(),
|
||||
): string[] {
|
||||
const physicalUrls = new Set<string>()
|
||||
const fallbackUrls = new Set<string>()
|
||||
|
||||
for (const [name, entries] of Object.entries(interfaces)) {
|
||||
for (const entry of entries || []) {
|
||||
if (entry.internal || entry.family !== 'IPv4') continue
|
||||
if (!isAllowedLanHost(entry.address)) continue
|
||||
|
||||
const url = `http://${entry.address}:${port}`
|
||||
if (isLikelyVirtualInterface(name, entry)) {
|
||||
fallbackUrls.add(url)
|
||||
} else {
|
||||
physicalUrls.add(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const urls = physicalUrls.size > 0 ? physicalUrls : fallbackUrls
|
||||
return Array.from(urls).sort()
|
||||
}
|
||||
|
||||
export function validateSttBaseUrl(baseUrl: string | undefined): boolean {
|
||||
if (!baseUrl) return false
|
||||
try {
|
||||
const url = new URL(baseUrl)
|
||||
return url.protocol === 'http:' || url.protocol === 'https:'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Guards against oversized requests
|
||||
*/
|
||||
export function isRequestBodySizeAllowed(
|
||||
contentLength: string | undefined,
|
||||
maxBytes: number = 10 * 1024 * 1024,
|
||||
): boolean {
|
||||
if (!contentLength) return true
|
||||
const size = parseInt(contentLength, 10)
|
||||
if (isNaN(size)) return false
|
||||
return size <= maxBytes
|
||||
}
|
||||
Reference in new issue
Block a user