Compare commits

...
Author SHA1 Message Date
MervinPraisonandClaude Opus 4.8 4d8f9b0322 fix: persist approval custom answer/option via reason field (#297)
The approve/deny endpoints only store `reason`; map the selected option or
typed custom answer into it so the human's decision note is recorded, while
keeping `option`/`custom_answer` for a future richer backend.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-23 14:10:13 +00:00
MervinPraisonandClaude Opus 4.8 aeefceeaa2 fix: render approvals view as accessible Approval Card (fixes #297)
- Add self-contained toApprovalModel() normalizer (ApprovalPromptModel shape)
- Render pending items as an Approval Card: risk accent, question, options,
  custom answer and confidence copy — surfaced only when the API supplies them
- Preserve /api/approvals approve|deny endpoints and skill-approval deep links
- Escape all interpolated API values via esc() (fixes prior XSS gap)
- Improve a11y: role=group/radiogroup, aria-labels on buttons/inputs

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-23 13:49:18 +00:00
MervinPraisonandCursor 3058886312 chore: bump automation hub to v1.1.10 for merge gate fix
Co-authored-by: Cursor <[email protected]>
2026-09-22 15:30:07 +01:00
Mervin Praison 70d8610733 Merge pull request #291 from MervinPraison/claude/issue-290-20260916
docs: add SECURITY.md with private disclosure policy
2026-09-17 10:37:03 +01:00
MervinPraison 0f60ec41f1 Release v0.3.146 2026-09-17 03:02:10 +00:00
MervinPraisonandClaude Opus 4.8 2797667e04 docs: add SECURITY.md with private disclosure policy (fixes #290)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-16 16:28:01 +00:00
Mervin Praison f23aacdec9 Merge pull request #289 from MervinPraison/claude/issue-286-20260915
fix: auto-load ~/.praisonai/.env on aiui run so env: channel tokens resolve
2026-09-16 14:43:31 +01:00
MervinPraisonandClaude Opus 4.8 8dc8b9f49f fix: auto-load ~/.praisonai/.env on aiui run so env: tokens resolve (fixes #286)
Channels configured with env:VAR token references silently resolved to
empty strings when the dashboard was started without the shell exporting
the tokens, so bots showed 'Connected' but never replied. aiui run now
loads the standard PraisonAI .env files (~/.praisonai/.env, then ./.env)
into the process environment at startup, matching the PraisonAI CLI
convention. Existing process vars are never overridden and parsing is
dependency-free (no python-dotenv).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-09-15 13:16:37 +00:00
14 changed files with 290 additions and 48 deletions

No files matched your search

+2 -2
View File
@@ -27,7 +27,7 @@ jobs:
permissions:
issues: write
pull-requests: write
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].8
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].10
secrets: inherit
with:
hub_ref: v1.1.8
hub_ref: v1.1.10
+2 -2
View File
@@ -16,8 +16,8 @@ permissions:
jobs:
recover:
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].8
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].10
secrets: inherit
with:
hub_ref: v1.1.8
hub_ref: v1.1.10
pr_number: ${{ github.event.inputs.pr_number || '' }}
+2 -2
View File
@@ -25,8 +25,8 @@ permissions:
jobs:
detect-and-trigger:
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].8
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].10
secrets: inherit
with:
hub_ref: v1.1.8
hub_ref: v1.1.10
pr_number: ${{ github.event.inputs.pr_number || '' }}
+4 -4
View File
@@ -43,9 +43,9 @@ jobs:
id-token: write
steps:
- uses: actions/checkout@v4
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].8
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].10
with:
hub_ref: v1.1.8
hub_ref: v1.1.10
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@v1
@@ -87,9 +87,9 @@ jobs:
issues: write
actions: read
id-token: write
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].8
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].10
secrets: inherit
with:
hub_ref: v1.1.8
hub_ref: v1.1.10
pr_number: ${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || '' }}
merge_method: ${{ github.event.inputs.merge_method || 'merge' }}
+2 -2
View File
@@ -18,7 +18,7 @@ jobs:
issues: write
actions: read
id-token: write
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].8
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].10
secrets: inherit
with:
hub_ref: v1.1.8
hub_ref: v1.1.10
+2 -2
View File
@@ -25,9 +25,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].8
- uses: MervinPraison/github-automation-template/.github/actions/[email protected].10
with:
hub_ref: v1.1.8
hub_ref: v1.1.10
- name: Detect conflicting PRs and trigger Claude
uses: actions/github-script@v7
with:
+2 -2
View File
@@ -13,7 +13,7 @@ permissions:
jobs:
sync:
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].8
uses: MervinPraison/github-automation-template/.github/workflows/[email protected].10
secrets: inherit
with:
hub_ref: v1.1.8
hub_ref: v1.1.10
+37
View File
@@ -0,0 +1,37 @@
# Security Policy
We take the security of PraisonAIUI seriously and appreciate responsible
disclosure of vulnerabilities.
## Supported Versions
Security fixes are applied to the latest released version of PraisonAIUI.
Please make sure you are on the most recent release before reporting.
## Reporting a Vulnerability
**Please do not open a public issue for security vulnerabilities.**
Report privately through GitHub's **Private Vulnerability Reporting**:
1. Go to the [Security tab](https://github.com/MervinPraison/PraisonAIUI/security)
of this repository.
2. Click **"Report a vulnerability"** to open a private advisory.
3. Include as much detail as you can:
- Affected component(s) and version.
- Steps to reproduce.
- Impact assessment.
- Any suggested fix or mitigation.
If you are unable to use private reporting, you may open a public issue that
contains **no technical specifics** and requests a private channel, and a
maintainer will follow up.
## What to Expect
- We aim to acknowledge new reports within a few business days.
- We will keep you informed as we investigate and work on a fix.
- Once a fix is released, we are happy to credit you in the advisory (unless
you prefer to remain anonymous).
Thank you for helping keep PraisonAIUI and its users safe.
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "aiui"
version = "0.3.145"
version = "0.3.146"
description = "YAML-driven website generator - CLI and compiler"
readme = "README.md"
license = {text = "MIT"}
+1 -1
View File
@@ -1,3 +1,3 @@
"""Version information."""
__version__ = "0.3.145"
__version__ = "0.3.146"
+48
View File
@@ -44,6 +44,43 @@ def _icon(symbol: str, fallback: str) -> str:
return symbol if _supports_unicode() else fallback
def _load_env_files() -> int:
"""Load environment variables from the standard PraisonAI ``.env`` files.
Mirrors the PraisonAI CLI convention so ``env:VAR`` references (e.g. in
Channels config) resolve when the server was started without the shell
exporting the tokens. Reads ``~/.praisonai/.env`` first, then a ``.env`` in
the current working directory. Existing process env vars are never
overridden, and parsing is intentionally dependency-free (no python-dotenv).
Returns the number of variables newly set into ``os.environ``.
"""
import os
loaded = 0
for env_path in (Path.home() / ".praisonai" / ".env", Path.cwd() / ".env"):
try:
if not env_path.is_file():
continue
for line in env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
if line.startswith("export "):
line = line[len("export "):]
key, value = line.split("=", 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
if key and key not in os.environ:
os.environ[key] = value
loaded += 1
except Exception:
# Never let env loading break startup — resolution falls back to
# the existing process environment.
continue
return loaded
app = typer.Typer(
name="aiui",
help="PraisonAIUI - YAML-driven website generator",
@@ -1380,6 +1417,17 @@ def run(
console.print(f"[red]Error:[/red] App file not found: {app_file}")
raise typer.Exit(code=1)
# Auto-load standard PraisonAI env files so `env:VAR` references (e.g. in
# Channels config) resolve even when the shell did not export the tokens.
# This matches the PraisonAI CLI convention and fixes the "Connected but
# bot never replies" class of issues caused by empty env-resolved tokens.
_loaded = _load_env_files()
if _loaded:
console.print(
f"[green]{_icon('✓', 'OK')}[/green] Loaded {_loaded} environment "
f"variable(s) from PraisonAI config"
)
is_yaml = app_file.suffix in (".yaml", ".yml")
if is_yaml:
@@ -1,65 +1,154 @@
/**
* Approvals View — execution approval queue
* API: /api/approvals
*
* Pending items render as an Approval Card (human-in-the-loop): risk, question,
* optional preset options / custom answer / confidence copy — surfaced only when
* the API supplies them, so the card stays a plain approve/deny prompt otherwise.
*/
import { isSkillApproval } from '/plugins/views/_helpers.js';
import { isSkillApproval, esc, emptyState } from '/plugins/views/_helpers.js';
const RISK = {
low: { icon: '✅', color: '#22c55e' },
medium: { icon: '⚠️', color: '#eab308' },
high: { icon: '🟠', color: '#f97316' },
critical: { icon: '🔴', color: '#ef4444' },
};
/**
* Normalise a raw /api/approvals item into an ApprovalPromptModel-shaped object.
* Keeps the field-mapping in one place; only carries what the API actually sends.
*/
function toApprovalModel(a) {
const id = a.id || a.approval_id || '';
const risk = String(a.risk_level || '').toLowerCase();
const options = Array.isArray(a.options)
? a.options.map(o => (typeof o === 'string' ? { value: o, label: o } : { value: o.value ?? o.id ?? o.label, label: o.label ?? String(o.value ?? o.id) }))
: [];
const confidence = typeof a.confidence === 'number' ? Math.round(a.confidence * (a.confidence <= 1 ? 100 : 1)) : null;
return {
id,
title: a.tool_name || a.action || 'Unknown',
question: a.reason || a.description || a.question || '',
agent: a.agent_name || '',
risk,
riskMeta: RISK[risk] || { icon: '❓', color: 'var(--db-text-dim)' },
createdAt: a.created_at ? new Date(a.created_at * 1000).toLocaleString() : '',
options,
allowCustom: a.allow_custom_answer === true || (options.length === 0 && a.custom_answer !== false && !!a.question),
confidence,
isSkill: isSkillApproval(a),
};
}
function approvalCard(m) {
const optionsHtml = m.options.length
? `<div class="db-approval-options" role="radiogroup" aria-label="Response options" style="display:flex;flex-wrap:wrap;gap:6px;margin-top:10px">
${m.options.map((o, i) => `
<label style="display:inline-flex;align-items:center;gap:6px;font-size:12px;padding:4px 10px;border:1px solid var(--db-border);border-radius:6px;cursor:pointer">
<input type="radio" name="opt-${esc(m.id)}" value="${esc(o.value)}" ${i === 0 ? 'checked' : ''}> ${esc(o.label)}
</label>`).join('')}
</div>`
: '';
const customHtml = m.allowCustom
? `<input class="approv-custom" data-id="${esc(m.id)}" type="text" placeholder="Custom answer (optional)"
aria-label="Custom answer for ${esc(m.title)}"
style="margin-top:10px;width:100%;padding:6px 10px;font-size:12px;background:var(--db-bg);color:var(--db-text);border:1px solid var(--db-border);border-radius:6px">`
: '';
const confidenceHtml = m.confidence != null
? `<div style="font-size:11px;color:var(--db-text-dim);margin-top:6px">Model confidence: ${m.confidence}%</div>`
: '';
const skillHtml = m.isSkill
? `<a href="/skills?tab=pending&approval_id=${encodeURIComponent(m.id)}" data-skill-studio="1" style="display:inline-block;margin-top:8px;font-size:11px;color:var(--db-accent);text-decoration:none">Skill write? Open Skills Studio →</a>`
: '';
return `
<div class="db-card db-approval-card" data-id="${esc(m.id)}" role="group"
aria-label="Approval request: ${esc(m.title)}"
style="margin-bottom:12px;padding:16px 20px;border-left:3px solid ${m.riskMeta.color}">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px">
<div style="flex:1;min-width:0">
<div style="font-weight:600;font-size:14px">${m.riskMeta.icon} ${esc(m.title)}</div>
${m.question ? `<div style="font-size:13px;color:var(--db-text);margin-top:6px">${esc(m.question)}</div>` : ''}
<div style="font-size:12px;color:var(--db-text-dim);margin-top:4px">Agent: ${esc(m.agent) || '—'}${m.risk ? ` · risk: ${esc(m.risk)}` : ''}</div>
${m.createdAt ? `<div style="font-size:11px;color:var(--db-text-dim)">${esc(m.createdAt)}</div>` : ''}
${optionsHtml}
${customHtml}
${confidenceHtml}
${skillHtml}
</div>
<div style="display:flex;gap:8px;flex-shrink:0">
<button class="approv-yes" data-id="${esc(m.id)}" aria-label="Approve ${esc(m.title)}"
style="padding:6px 16px;background:rgba(34,197,94,0.15);color:#22c55e;border:1px solid rgba(34,197,94,0.3);border-radius:6px;cursor:pointer;font-size:12px">✓ Approve</button>
<button class="approv-no" data-id="${esc(m.id)}" aria-label="Deny ${esc(m.title)}"
style="padding:6px 16px;background:rgba(239,68,68,0.15);color:#ef4444;border:1px solid rgba(239,68,68,0.3);border-radius:6px;cursor:pointer;font-size:12px">✗ Deny</button>
</div>
</div>
</div>`;
}
export async function render(container) {
container.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
let pending = [], history = [], policies = {};
try { const r = await fetch('/api/approvals/pending'); pending = (await r.json()).approvals || []; } catch(e) {}
try { const r = await fetch('/api/approvals/history'); history = (await r.json()).approvals || (await r.json()).history || []; } catch(e) {}
try { const r = await fetch('/api/approvals/history'); const d = await r.json(); history = d.approvals || d.history || []; } catch(e) {}
try { const r = await fetch('/api/approvals/policies'); policies = await r.json(); } catch(e) {}
const riskIcons = { low: '✅', medium: '⚠️', high: '🟠', critical: '🔴' };
const models = pending.map(toApprovalModel);
container.innerHTML = `
<div class="db-columns" style="grid-template-columns:repeat(3,1fr);margin-bottom:24px">
<div class="db-card"><div class="db-card-title">Pending</div><div class="db-card-value" style="color:#eab308">${pending.length}</div></div>
<div class="db-card"><div class="db-card-title">Risk Threshold</div><div class="db-card-value" style="font-size:18px">${policies.risk_threshold || 'high'}</div></div>
<div class="db-card"><div class="db-card-title">Pending</div><div class="db-card-value" style="color:#eab308">${models.length}</div></div>
<div class="db-card"><div class="db-card-title">Risk Threshold</div><div class="db-card-value" style="font-size:18px">${esc(policies.risk_threshold || 'high')}</div></div>
<div class="db-card"><div class="db-card-title">Auto-Approve Tools</div><div class="db-card-value" style="font-size:18px">${(policies.auto_approve_tools || []).length}</div></div>
</div>
<h3 style="margin:0 0 12px;font-size:15px;font-weight:600">Pending Approvals</h3>
<div id="approv-pending" style="margin-bottom:28px">
${pending.length === 0 ? '<div class="db-viewer"><pre>No pending approvals</pre></div>' : ''}
${models.length === 0 ? emptyState({ icon: '✅', title: 'No pending approvals', body: 'All clear — nothing needs your decision.' }) : models.map(approvalCard).join('')}
</div>
<h3 style="margin:0 0 12px;font-size:15px;font-weight:600">Recent History</h3>
<div id="approv-history"></div>
`;
const pendingEl = container.querySelector('#approv-pending');
pending.forEach(a => {
const card = document.createElement('div');
card.className = 'db-card';
card.style.cssText = 'margin-bottom:10px;padding:16px 20px;display:flex;align-items:center;justify-content:space-between';
const isSkill = isSkillApproval(a);
card.innerHTML = `
<div style="flex:1">
<div style="font-weight:500;font-size:14px">${riskIcons[a.risk_level] || '❓'} ${a.tool_name || a.action || 'Unknown'}</div>
<div style="font-size:12px;color:var(--db-text-dim);margin-top:4px">Agent: ${a.agent_name || '—'} · ${a.reason || ''}</div>
<div style="font-size:11px;color:var(--db-text-dim)">${a.created_at ? new Date(a.created_at * 1000).toLocaleString() : ''}</div>
${isSkill ? `<a href="/skills?tab=pending&approval_id=${encodeURIComponent(a.id || a.approval_id || '')}" data-skill-studio="1" style="font-size:11px;color:var(--db-accent);text-decoration:none">Skill write? Open Skills Studio →</a>` : ''}
</div>
<div style="display:flex;gap:8px">
<button class="approv-yes" data-id="${a.id || a.approval_id}" style="padding:6px 16px;background:rgba(34,197,94,0.15);color:#22c55e;border:1px solid rgba(34,197,94,0.3);border-radius:6px;cursor:pointer;font-size:12px">✓ Approve</button>
<button class="approv-no" data-id="${a.id || a.approval_id}" style="padding:6px 16px;background:rgba(239,68,68,0.15);color:#ef4444;border:1px solid rgba(239,68,68,0.3);border-radius:6px;cursor:pointer;font-size:12px">✗ Deny</button>
</div>
`;
pendingEl.appendChild(card);
});
function collectExtra(id) {
const card = container.querySelector(`.db-approval-card[data-id="${CSS.escape(id)}"]`);
const body = {};
if (!card) return body;
const opt = card.querySelector(`input[name="opt-${CSS.escape(id)}"]:checked`);
if (opt) body.option = opt.value;
const custom = card.querySelector('.approv-custom');
if (custom && custom.value.trim()) body.custom_answer = custom.value.trim();
// Also surface the human's choice as `reason` — the only extra field the
// approve/deny endpoints persist today — so the answer isn't dropped while
// `option`/`custom_answer` stay for a future richer backend.
const reason = body.custom_answer || body.option;
if (reason != null && reason !== '') body.reason = String(reason);
return body;
}
container.querySelectorAll('.approv-yes').forEach(b => b.addEventListener('click', async () => { try { await fetch(`/api/approvals/${b.dataset.id}/approve`, {method:'POST'}); render(container); } catch(e){} }));
container.querySelectorAll('.approv-no').forEach(b => b.addEventListener('click', async () => { try { await fetch(`/api/approvals/${b.dataset.id}/deny`, {method:'POST'}); render(container); } catch(e){} }));
async function decide(id, action) {
try {
await fetch(`/api/approvals/${encodeURIComponent(id)}/${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(collectExtra(id)),
});
render(container);
} catch(e) {}
}
container.querySelectorAll('.approv-yes').forEach(b => b.addEventListener('click', () => decide(b.dataset.id, 'approve')));
container.querySelectorAll('.approv-no').forEach(b => b.addEventListener('click', () => decide(b.dataset.id, 'deny')));
const historyEl = container.querySelector('#approv-history');
(Array.isArray(history) ? history : []).slice(0, 20).forEach(h => {
const div = document.createElement('div');
div.style.cssText = 'padding:8px 0;border-bottom:1px solid var(--db-border);font-size:13px;display:flex;justify-content:space-between';
const approved = h.status === 'approved' || h.approved;
div.innerHTML = `<span>${h.tool_name || h.action || '?'} — ${h.agent_name || ''}</span><span style="color:${approved ? '#22c55e' : '#ef4444'}">${approved ? '✓ approved' : '✗ denied'}</span>`;
div.innerHTML = `<span>${esc(h.tool_name || h.action || '?')} — ${esc(h.agent_name || '')}</span><span style="color:${approved ? '#22c55e' : '#ef4444'}">${approved ? '✓ approved' : '✗ denied'}</span>`;
historyEl.appendChild(div);
});
if (!history.length) historyEl.innerHTML = '<div style="font-size:13px;color:var(--db-text-dim)">No history</div>';
+68
View File
@@ -0,0 +1,68 @@
"""Unit tests for `_load_env_files` — auto-loading PraisonAI `.env` files.
Regression coverage for issue #286: `env:VAR` channel token references
resolved to empty strings because `aiui run` never loaded the standard
`~/.praisonai/.env` file, so bots showed "Connected" but never replied.
"""
import os
from praisonaiui.cli import _load_env_files
def _write_env(path, body):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(body, encoding="utf-8")
def test_loads_home_praisonai_env(tmp_path, monkeypatch):
"""Variables in ~/.praisonai/.env are loaded into the process env."""
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("TELEGRAM_BOT_TOKEN", raising=False)
_write_env(tmp_path / ".praisonai" / ".env", 'TELEGRAM_BOT_TOKEN="123:AAtoken"\n')
loaded = _load_env_files()
assert loaded >= 1
assert os.environ["TELEGRAM_BOT_TOKEN"] == "123:AAtoken"
def test_does_not_override_existing_env(tmp_path, monkeypatch):
"""An already-set process variable is never clobbered by the file."""
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("SLACK_BOT_TOKEN", "xoxb-from-shell")
_write_env(tmp_path / ".praisonai" / ".env", "SLACK_BOT_TOKEN=xoxb-from-file\n")
_load_env_files()
assert os.environ["SLACK_BOT_TOKEN"] == "xoxb-from-shell"
def test_ignores_comments_blanks_and_export_prefix(tmp_path, monkeypatch):
"""Comments/blank lines are skipped and `export ` prefixes are stripped."""
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("FOO", raising=False)
monkeypatch.delenv("BAR", raising=False)
_write_env(
tmp_path / ".praisonai" / ".env",
"# a comment\n\nexport FOO=foo\nBAR='bar'\n",
)
_load_env_files()
assert os.environ["FOO"] == "foo"
assert os.environ["BAR"] == "bar"
def test_missing_files_are_harmless(tmp_path, monkeypatch):
"""No env files present → returns 0 and does not raise."""
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
monkeypatch.chdir(tmp_path)
assert _load_env_files() == 0
Generated
+1 -1
View File
@@ -207,7 +207,7 @@ wheels = [
[[package]]
name = "aiui"
version = "0.3.145"
version = "0.3.146"
source = { editable = "." }
dependencies = [
{ name = "pydantic" },