solvent-agent 0.2.1__tar.gz → 0.2.2__tar.gz
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.
- {solvent_agent-0.2.1/solvent_agent.egg-info → solvent_agent-0.2.2}/PKG-INFO +1 -1
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/__init__.py +1 -1
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/agent.py +15 -4
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/dashboard.py +55 -40
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/export.py +44 -2
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/gateway.py +18 -8
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/stages.py +49 -28
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/treasury.py +28 -13
- {solvent_agent-0.2.1 → solvent_agent-0.2.2/solvent_agent.egg-info}/PKG-INFO +1 -1
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_dashboard_ops_xss.py +48 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_export.py +48 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_gateway.py +30 -0
- solvent_agent-0.2.2/tests/test_server_job_submission.py +152 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_stages.py +14 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_treasury.py +9 -0
- solvent_agent-0.2.1/tests/test_server_job_submission.py +0 -76
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/LICENSE +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/README.md +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/pyproject.toml +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/setup.cfg +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/__main__.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/_filelock.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/access.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/alerts.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/backlog.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/calibration.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/capacity.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/channels/__init__.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/channels/telegram.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/chat.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/checkout.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/cli.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/config.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/config_cmd.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/customers.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/dashboard_chat.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/delivery.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/doctor.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/event_hub.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/finance.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/guardrail_cmd.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/guardrails.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/init.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/intake.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/job_cmd.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/jobs.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/logs.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/memory.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/money.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/nemotron.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/notifications.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/observability.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/onboarding.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/optimize.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/pairing.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/paths.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/pricing.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/products.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/py.typed +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/qr.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/quality.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/queue.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/quote_cmd.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/rate_limit.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/receipt.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/reconcile.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/review.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/security.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/server.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/service.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/simulate.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/status.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/stripe_client.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/templates/workspace/AGENTS.md +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/templates/workspace/BRAIN.md +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/templates/workspace/SOUL.md +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/templates/workspace/skills/commission-research/SKILL.md +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/tools.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/upgrade.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/webhook_log.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/worker.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/workspace.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent_agent.egg-info/SOURCES.txt +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent_agent.egg-info/dependency_links.txt +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent_agent.egg-info/entry_points.txt +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent_agent.egg-info/requires.txt +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent_agent.egg-info/top_level.txt +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_agent.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_alerts.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_backlog.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_calibration.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_capacity.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_chat_tools.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_checkout.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_cli_routing.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_cogs_reconciliation.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_config.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_config_cmd.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_counter_offer.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_customers.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_dashboard_chat.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_dashboard_financials.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_dashboard_security.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_delivery.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_doctor.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_event_hub.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_filelock.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_finance.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_guardrails.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_init.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_intake.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_job_cmd.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_jobs.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_logs.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_memory.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_metrics_block.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_money.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_nemotron_parse.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_notifications.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_observability.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_onboarding.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_optimize.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_packaging.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_paths.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_pricing.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_products.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_qr.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_quality.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_queue.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_rate_limit.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_receipt.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_reconcile.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_research_brief.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_retry.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_review.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_route_security.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_runtime_config_paths.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_security.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_server.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_simulate.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_spend_policy.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_status.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_stripe_client.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_telegram_pairing.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_tools.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_upgrade.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_upgrade_hint.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_webhook_log.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_webhook_security.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_worker.py +0 -0
- {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_workspace.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: solvent-agent
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: A self-funding analyst agent: earns on Stripe, fulfils with NVIDIA Nemotron, spends within guardrails.
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/ianalloway/solvent-agent
|
|
@@ -22,7 +22,7 @@ from collections.abc import Callable
|
|
|
22
22
|
from .calibration import calibration_factor
|
|
23
23
|
from .guardrails import Guardrails
|
|
24
24
|
from .pricing import PricingPolicy
|
|
25
|
-
from .stages import StageRunner, _job_id_of,
|
|
25
|
+
from .stages import StageRunner, _job_id_of, coerce_job, screen_and_record
|
|
26
26
|
from .stripe_client import StripeClient
|
|
27
27
|
from .treasury import Treasury
|
|
28
28
|
|
|
@@ -79,10 +79,21 @@ class Solvent:
|
|
|
79
79
|
return self._runner.advance_job(job_id)
|
|
80
80
|
|
|
81
81
|
def enqueue_job(self, job: dict) -> dict:
|
|
82
|
-
"""Validate and persist a job for async worker processing.
|
|
83
|
-
|
|
82
|
+
"""Validate and persist a new job for async worker processing.
|
|
83
|
+
|
|
84
|
+
Invalid input is declined before anything is written. A valid job then
|
|
85
|
+
claims its id with an atomic insert, so a submission can never
|
|
86
|
+
overwrite or restart an existing job.
|
|
87
|
+
"""
|
|
88
|
+
coerced, err = coerce_job(job)
|
|
89
|
+
if err:
|
|
90
|
+
return self._emit(stage="declined", job_id=_job_id_of(job, None), reason=err)
|
|
91
|
+
assert coerced is not None
|
|
92
|
+
if not self.t.insert_job(coerced["id"], "received"):
|
|
93
|
+
return {"stage": "declined", "job_id": coerced["id"], "reason": "job id already exists"}
|
|
94
|
+
validated, err = screen_and_record(coerced, self.t)
|
|
84
95
|
if err:
|
|
85
|
-
return self._emit(stage="declined", job_id=
|
|
96
|
+
return self._emit(stage="declined", job_id=coerced["id"], reason=err)
|
|
86
97
|
assert validated is not None
|
|
87
98
|
q = self._runner._stage_quote(validated)
|
|
88
99
|
if q.get("stage") == "declined" or not q.get("accept"):
|
|
@@ -38,6 +38,8 @@ def _script_json(value: object) -> str:
|
|
|
38
38
|
.replace("<", "\\u003c")
|
|
39
39
|
.replace(">", "\\u003e")
|
|
40
40
|
.replace("&", "\\u0026")
|
|
41
|
+
.replace("\u2028", "\\u2028")
|
|
42
|
+
.replace("\u2029", "\\u2029")
|
|
41
43
|
)
|
|
42
44
|
|
|
43
45
|
|
|
@@ -1367,15 +1369,6 @@ def render(snapshot: dict, log: list[dict], *, live: bool = False) -> Path:
|
|
|
1367
1369
|
let jobsData = {_script_json(jobs_data)};
|
|
1368
1370
|
let currentOpenJobId = null;
|
|
1369
1371
|
|
|
1370
|
-
function esc(value) {{
|
|
1371
|
-
return String(value ?? "")
|
|
1372
|
-
.replace(/&/g, "&")
|
|
1373
|
-
.replace(/</g, "<")
|
|
1374
|
-
.replace(/>/g, ">")
|
|
1375
|
-
.replace(/"/g, """)
|
|
1376
|
-
.replace(/'/g, "'");
|
|
1377
|
-
}}
|
|
1378
|
-
|
|
1379
1372
|
function openBriefModal(jobId) {{
|
|
1380
1373
|
document.getElementById("modal-title").innerText = "Research Brief Preview: " + jobId;
|
|
1381
1374
|
const iframe = document.getElementById("brief-iframe");
|
|
@@ -1398,21 +1391,28 @@ def render(snapshot: dict, log: list[dict], *, live: bool = False) -> Path:
|
|
|
1398
1391
|
return;
|
|
1399
1392
|
}}
|
|
1400
1393
|
|
|
1401
|
-
|
|
1394
|
+
container.replaceChildren();
|
|
1402
1395
|
jobIds.forEach(jobId => {{
|
|
1403
1396
|
const job = jobsData[jobId] || {{}};
|
|
1404
|
-
const
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
`;
|
|
1397
|
+
const row = document.createElement("div");
|
|
1398
|
+
row.style.cssText = "display:flex; justify-content:space-between; align-items:center; background:rgba(255,255,255,0.02); border:1px solid var(--color-border); padding:10px 14px; border-radius:10px; margin-bottom: 8px;";
|
|
1399
|
+
const labels = document.createElement("div");
|
|
1400
|
+
labels.style.cssText = "display:flex; flex-direction:column; gap:2px; max-width:70%;";
|
|
1401
|
+
const idEl = document.createElement("span");
|
|
1402
|
+
idEl.style.cssText = "font-size:11px; font-family:var(--font-mono); color:var(--color-text-muted);";
|
|
1403
|
+
idEl.textContent = jobId;
|
|
1404
|
+
const titleEl = document.createElement("span");
|
|
1405
|
+
titleEl.style.cssText = "font-size:13px; font-weight:600; color:var(--color-text-primary); text-overflow:ellipsis; overflow:hidden; white-space:nowrap;";
|
|
1406
|
+
titleEl.textContent = job.title || `Research Brief (${{jobId}})`;
|
|
1407
|
+
labels.append(idEl, titleEl);
|
|
1408
|
+
const button = document.createElement("button");
|
|
1409
|
+
button.className = "btn btn-outline btn-sm";
|
|
1410
|
+
button.style.cssText = "padding:4px 8px; font-size:11px;";
|
|
1411
|
+
button.textContent = "Preview";
|
|
1412
|
+
button.addEventListener("click", () => openBriefModal(jobId));
|
|
1413
|
+
row.append(labels, button);
|
|
1414
|
+
container.appendChild(row);
|
|
1414
1415
|
}});
|
|
1415
|
-
container.innerHTML = html;
|
|
1416
1416
|
}}
|
|
1417
1417
|
|
|
1418
1418
|
// Auto-scroll terminal log to the bottom
|
|
@@ -1431,28 +1431,43 @@ def render(snapshot: dict, log: list[dict], *, live: bool = False) -> Path:
|
|
|
1431
1431
|
|
|
1432
1432
|
document.getElementById("drawer-title").innerText = "Job Details: " + jobId;
|
|
1433
1433
|
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1434
|
+
const meta = document.getElementById("drawer-metadata");
|
|
1435
|
+
meta.replaceChildren();
|
|
1436
|
+
const metaRow = (label, value, className) => {{
|
|
1437
|
+
const row = document.createElement("div");
|
|
1438
|
+
row.className = "meta-row";
|
|
1439
|
+
const strong = document.createElement("strong");
|
|
1440
|
+
strong.textContent = label;
|
|
1441
|
+
const span = document.createElement("span");
|
|
1442
|
+
if (className) span.className = className;
|
|
1443
|
+
span.textContent = value;
|
|
1444
|
+
row.append(strong, " ", span);
|
|
1445
|
+
meta.appendChild(row);
|
|
1446
|
+
}};
|
|
1447
|
+
const status = String(job.status);
|
|
1448
|
+
metaRow("Title:", job.title || "N/A");
|
|
1449
|
+
metaRow("Status:", status.toUpperCase(), "badge badge-" + status.replace(/[^a-z_]/gi, ""));
|
|
1450
|
+
metaRow("Client Budget:", formatCents(job.price));
|
|
1451
|
+
metaRow("Incurred COGS:", formatCents(getExpensesTotal(job.expenses)));
|
|
1452
|
+
metaRow("Net P&L:", (job.pnl >= 0 ? "+" : "") + formatCents(job.pnl), job.pnl >= 0 ? "green" : "red");
|
|
1453
|
+
metaRow("Est. Margin:", String(job.margin_pct) + "%");
|
|
1442
1454
|
|
|
1443
1455
|
if (job.expenses.length > 0) {{
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1456
|
+
const heading = document.createElement("h4");
|
|
1457
|
+
heading.style.cssText = "margin-top: 16px; margin-bottom: 8px; font-size:12px; color:var(--color-text-muted)";
|
|
1458
|
+
heading.textContent = "ITEMIZED EXPENSES";
|
|
1459
|
+
const list = document.createElement("ul");
|
|
1460
|
+
list.className = "drawer-expenses-list";
|
|
1448
1461
|
job.expenses.forEach(e => {{
|
|
1449
|
-
|
|
1462
|
+
const item = document.createElement("li");
|
|
1463
|
+
const vendor = document.createElement("strong");
|
|
1464
|
+
vendor.textContent = String(e.vendor) + ":";
|
|
1465
|
+
item.append(vendor, ` -${{formatCents(e.amount)}} (${{e.memo}})`);
|
|
1466
|
+
list.appendChild(item);
|
|
1450
1467
|
}});
|
|
1451
|
-
|
|
1468
|
+
meta.append(heading, list);
|
|
1452
1469
|
}}
|
|
1453
1470
|
|
|
1454
|
-
document.getElementById("drawer-metadata").innerHTML = metaHTML;
|
|
1455
|
-
|
|
1456
1471
|
const hasBrief = Object.prototype.hasOwnProperty.call(briefs || {{}}, jobId);
|
|
1457
1472
|
const reportText = hasBrief
|
|
1458
1473
|
? "_Brief generated. Use the hosted brief link with a delivery token to view full contents._"
|
|
@@ -1503,13 +1518,13 @@ def render(snapshot: dict, log: list[dict], *, live: bool = False) -> Path:
|
|
|
1503
1518
|
html = html.replace(/\\*(.*?)\\*/g, '<em>$1</em>');
|
|
1504
1519
|
|
|
1505
1520
|
// Double line breaks to paragraphs
|
|
1506
|
-
const paragraphs = html.split(
|
|
1521
|
+
const paragraphs = html.split(/\\n\\n+/);
|
|
1507
1522
|
html = paragraphs.map(p => {{
|
|
1508
1523
|
p = p.trim();
|
|
1509
1524
|
if (!p) return '';
|
|
1510
1525
|
if (p.startsWith('<h') || p.startsWith('<ul') || p.startsWith('<li')) return p;
|
|
1511
|
-
return `<p>${{p.replace(
|
|
1512
|
-
}}).join(
|
|
1526
|
+
return `<p>${{p.replace(/\\n/g, '<br>')}}</p>`;
|
|
1527
|
+
}}).join("\\n");
|
|
1513
1528
|
|
|
1514
1529
|
return html;
|
|
1515
1530
|
}}
|
|
@@ -228,8 +228,44 @@ _TABLES = {
|
|
|
228
228
|
}
|
|
229
229
|
|
|
230
230
|
|
|
231
|
+
#: Free-text columns that can carry customer- or vendor-supplied strings. Only
|
|
232
|
+
#: these are neutralised, so numeric columns stay numeric for the spreadsheet.
|
|
233
|
+
_UNTRUSTED_TEXT_COLUMNS = {
|
|
234
|
+
"ledger": frozenset(
|
|
235
|
+
{"memo", "job_id", "vendor", "stripe_ref", "stripe_session_id", "stripe_link_id"}
|
|
236
|
+
),
|
|
237
|
+
"jobs": frozenset(
|
|
238
|
+
{"id", "status", "topic", "customer_email", "current_stage", "error_reason", "deliverable_url"}
|
|
239
|
+
),
|
|
240
|
+
"metrics": frozenset({"job_id", "decline_reason", "block_rule"}),
|
|
241
|
+
"customers": frozenset({"email"}),
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
_FORMULA_TRIGGERS = ("=", "+", "-", "@", "\t", "\r")
|
|
245
|
+
_LEADING_NOISE = re.compile(r"^[\s\x00-\x1f\x7f\u200b-\u200f\u2060\ufeff]*")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def neutralise_cell(value: Any) -> Any:
|
|
249
|
+
"""Stop a spreadsheet from evaluating a text cell as a formula.
|
|
250
|
+
|
|
251
|
+
A string whose first significant character (after any leading whitespace
|
|
252
|
+
or control characters) is ``= + - @``, or that starts with a tab or
|
|
253
|
+
carriage return, is prefixed with a single quote. Non-strings pass through.
|
|
254
|
+
"""
|
|
255
|
+
if not isinstance(value, str) or not value:
|
|
256
|
+
return value
|
|
257
|
+
significant = _LEADING_NOISE.sub("", value, count=1)
|
|
258
|
+
if value.startswith(_FORMULA_TRIGGERS) or significant.startswith(_FORMULA_TRIGGERS):
|
|
259
|
+
return "'" + value
|
|
260
|
+
return value
|
|
261
|
+
|
|
262
|
+
|
|
231
263
|
def write_csv(data: dict[str, Any], out_dir: Path) -> list[Path]:
|
|
232
|
-
"""Write one CSV per table; returns the files written.
|
|
264
|
+
"""Write one CSV per table; returns the files written.
|
|
265
|
+
|
|
266
|
+
Untrusted text columns are passed through `neutralise_cell` so a job topic
|
|
267
|
+
or memo like ``=HYPERLINK(...)`` is shown as text, not run as a formula.
|
|
268
|
+
"""
|
|
233
269
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
234
270
|
written = []
|
|
235
271
|
for table, columns in _TABLES.items():
|
|
@@ -237,8 +273,14 @@ def write_csv(data: dict[str, Any], out_dir: Path) -> list[Path]:
|
|
|
237
273
|
with path.open("w", encoding="utf-8", newline="") as fh:
|
|
238
274
|
writer = csv.DictWriter(fh, fieldnames=list(columns), extrasaction="ignore")
|
|
239
275
|
writer.writeheader()
|
|
276
|
+
text_columns = _UNTRUSTED_TEXT_COLUMNS.get(table, frozenset())
|
|
240
277
|
for row in data[table]:
|
|
241
|
-
writer.writerow(
|
|
278
|
+
writer.writerow(
|
|
279
|
+
{
|
|
280
|
+
key: neutralise_cell(value) if key in text_columns else value
|
|
281
|
+
for key, value in row.items()
|
|
282
|
+
}
|
|
283
|
+
)
|
|
242
284
|
written.append(path)
|
|
243
285
|
return written
|
|
244
286
|
|
|
@@ -38,6 +38,11 @@ def notify(channel: str, external_id: str, text: str) -> None:
|
|
|
38
38
|
pass
|
|
39
39
|
|
|
40
40
|
|
|
41
|
+
#: Commands that answer from local state without calling the model; only these
|
|
42
|
+
#: skip the per-user rate limit. Everything else, /quote included, is counted.
|
|
43
|
+
RATE_EXEMPT_COMMANDS = frozenset({"/help", "/status", "/jobs"})
|
|
44
|
+
|
|
45
|
+
|
|
41
46
|
def _rate_ok(user_key: str) -> bool:
|
|
42
47
|
allowed, _reason = _rate_limiter.check(user_key)
|
|
43
48
|
return allowed
|
|
@@ -56,10 +61,16 @@ class Gateway:
|
|
|
56
61
|
*,
|
|
57
62
|
user_label: str | None = None,
|
|
58
63
|
) -> str:
|
|
64
|
+
user_key = f"{channel}:{external_id}"
|
|
65
|
+
if _rate_limiter.is_banned(user_key):
|
|
66
|
+
return "You are temporarily blocked from messaging this bot."
|
|
67
|
+
|
|
59
68
|
session = self.memory.get_or_create(channel, external_id, user_label)
|
|
60
|
-
|
|
69
|
+
stripped = text.strip()
|
|
70
|
+
is_command = stripped.startswith("/")
|
|
71
|
+
command = stripped.split(maxsplit=1)[0].lower() if is_command else ""
|
|
61
72
|
|
|
62
|
-
if not
|
|
73
|
+
if command not in RATE_EXEMPT_COMMANDS and not _rate_ok(user_key):
|
|
63
74
|
return "Rate limit exceeded. Please wait before sending more messages."
|
|
64
75
|
|
|
65
76
|
if channel == "telegram" and not pairing.is_allowed(external_id, user_label):
|
|
@@ -83,7 +94,7 @@ class Gateway:
|
|
|
83
94
|
cmd = parts[0].lower()
|
|
84
95
|
arg = parts[1] if len(parts) > 1 else ""
|
|
85
96
|
|
|
86
|
-
if cmd
|
|
97
|
+
if cmd in ("/help", "/start"):
|
|
87
98
|
return (
|
|
88
99
|
"SOLVENT research business bot.\n"
|
|
89
100
|
"/status — treasury snapshot\n"
|
|
@@ -130,13 +141,14 @@ class Gateway:
|
|
|
130
141
|
port = 443
|
|
131
142
|
return _qr.render_token(token, host=host, port=port)
|
|
132
143
|
|
|
133
|
-
if cmd == "/quote"
|
|
144
|
+
if cmd == "/quote":
|
|
145
|
+
if "|" not in arg:
|
|
146
|
+
return "Usage: /quote topic | 50.00"
|
|
134
147
|
topic, budget_s = [x.strip() for x in arg.split("|", 1)]
|
|
135
148
|
try:
|
|
136
149
|
cents = dollars_to_cents(budget_s)
|
|
137
150
|
except ValueError:
|
|
138
151
|
return "Usage: /quote topic | 50.00"
|
|
139
|
-
from .chat import handle_message
|
|
140
152
|
|
|
141
153
|
prompt = f"Quote a brief on '{topic}' with budget_cents={cents}. Use quote_brief tool."
|
|
142
154
|
return handle_message(
|
|
@@ -147,9 +159,7 @@ class Gateway:
|
|
|
147
159
|
channel=channel,
|
|
148
160
|
)
|
|
149
161
|
|
|
150
|
-
return
|
|
151
|
-
session["id"], text, agent=self.agent, memory=self.memory, channel=channel
|
|
152
|
-
)
|
|
162
|
+
return f"Unknown command {cmd[:32]}. Send /help for the list of commands."
|
|
153
163
|
|
|
154
164
|
def on_job_event(self, event: dict) -> None:
|
|
155
165
|
"""Push job lifecycle updates to sessions watching a job."""
|
|
@@ -29,51 +29,50 @@ def _job_id_of(submitted: Any, validated: dict | None) -> str:
|
|
|
29
29
|
for candidate in (validated, submitted):
|
|
30
30
|
if isinstance(candidate, dict):
|
|
31
31
|
job_id = candidate.get("id")
|
|
32
|
-
if isinstance(job_id, str) and
|
|
32
|
+
if isinstance(job_id, str) and delivery.is_safe_job_id(job_id):
|
|
33
33
|
return job_id
|
|
34
34
|
return "unknown"
|
|
35
35
|
|
|
36
36
|
|
|
37
|
-
def
|
|
38
|
-
"""
|
|
37
|
+
def _coerce_job(job: Any) -> tuple[dict | None, str | None, str | None]:
|
|
38
|
+
"""Field validation with no side effects.
|
|
39
|
+
|
|
40
|
+
Returns ``(coerced_job, error, recorded_reason)``; ``recorded_reason`` is
|
|
41
|
+
what the worker path stores on the job row for that error, or ``None`` when
|
|
42
|
+
the error has never been recorded.
|
|
43
|
+
"""
|
|
39
44
|
if not isinstance(job, dict):
|
|
40
|
-
return None, "job must be a dictionary"
|
|
45
|
+
return None, "job must be a dictionary", None
|
|
41
46
|
job_id = job.get("id")
|
|
42
|
-
if not job_id or not isinstance(job_id, str):
|
|
43
|
-
return None, "missing or invalid job ID"
|
|
47
|
+
if not job_id or not isinstance(job_id, str) or not delivery.is_safe_job_id(job_id):
|
|
48
|
+
return None, "missing or invalid job ID", None
|
|
44
49
|
try:
|
|
45
50
|
job = dict(job)
|
|
46
51
|
sanitise_job(job)
|
|
47
52
|
except SOLVENTSecurityError as exc:
|
|
48
|
-
|
|
49
|
-
return None, f"security violation: {exc}"
|
|
53
|
+
return None, f"security violation: {exc}", str(exc)
|
|
50
54
|
|
|
51
55
|
# A job may name a product instead of pricing itself; the product fills in
|
|
52
56
|
# the scope and list price, and everything below validates it as normal.
|
|
53
57
|
try:
|
|
54
58
|
job = apply_product(job)
|
|
55
59
|
except UnknownProduct as exc:
|
|
56
|
-
|
|
57
|
-
return None, str(exc)
|
|
60
|
+
return None, str(exc), str(exc)
|
|
58
61
|
|
|
59
62
|
topic = job.get("topic")
|
|
60
63
|
if not topic or not isinstance(topic, str) or not topic.strip():
|
|
61
|
-
|
|
62
|
-
return None, "missing or blank topic"
|
|
64
|
+
return None, "missing or blank topic", "missing or blank topic"
|
|
63
65
|
|
|
64
66
|
budget_cents = job.get("budget_cents")
|
|
65
67
|
if budget_cents is None:
|
|
66
|
-
|
|
67
|
-
return None, "missing budget_cents"
|
|
68
|
+
return None, "missing budget_cents", "missing budget_cents"
|
|
68
69
|
try:
|
|
69
70
|
budget_cents = int(float(budget_cents))
|
|
70
|
-
if budget_cents < 0 or budget_cents > 1_000_000:
|
|
71
|
-
treasury.upsert_job(job_id, "failed", error_reason="invalid budget")
|
|
72
|
-
return None, "invalid budget"
|
|
73
|
-
job["budget_cents"] = budget_cents
|
|
74
71
|
except (ValueError, TypeError):
|
|
75
|
-
|
|
76
|
-
|
|
72
|
+
return None, "budget_cents must be a valid numeric value", "budget_cents must be numeric"
|
|
73
|
+
if budget_cents < 0 or budget_cents > 1_000_000:
|
|
74
|
+
return None, "invalid budget", "invalid budget"
|
|
75
|
+
job["budget_cents"] = budget_cents
|
|
77
76
|
|
|
78
77
|
for param, default_val in [
|
|
79
78
|
("est_tokens", 8_000),
|
|
@@ -85,18 +84,29 @@ def validate_and_coerce_job(job: dict, treasury: Treasury) -> tuple[dict | None,
|
|
|
85
84
|
val = default_val
|
|
86
85
|
try:
|
|
87
86
|
val = int(float(val))
|
|
88
|
-
if val < 0:
|
|
89
|
-
return None, f"{param} cannot be negative"
|
|
90
|
-
if param == "est_tokens" and val > 100_000:
|
|
91
|
-
return None, "est_tokens exceeds maximum limit"
|
|
92
|
-
if param in ("market_data_calls", "web_search_calls") and val > 50:
|
|
93
|
-
return None, f"{param} exceeds maximum limit"
|
|
94
|
-
job[param] = val
|
|
95
87
|
except (ValueError, TypeError):
|
|
96
|
-
return None, f"{param} must be a valid numeric value"
|
|
88
|
+
return None, f"{param} must be a valid numeric value", None
|
|
89
|
+
if val < 0:
|
|
90
|
+
return None, f"{param} cannot be negative", None
|
|
91
|
+
if param == "est_tokens" and val > 100_000:
|
|
92
|
+
return None, "est_tokens exceeds maximum limit", None
|
|
93
|
+
if param in ("market_data_calls", "web_search_calls") and val > 50:
|
|
94
|
+
return None, f"{param} exceeds maximum limit", None
|
|
95
|
+
job[param] = val
|
|
97
96
|
|
|
98
97
|
job.setdefault("customer_email", "client@example.com")
|
|
98
|
+
return job, None, None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def coerce_job(job: Any) -> tuple[dict | None, str | None]:
|
|
102
|
+
"""Validate and normalise a submitted job without writing anything."""
|
|
103
|
+
coerced, err, _recorded = _coerce_job(job)
|
|
104
|
+
return coerced, err
|
|
99
105
|
|
|
106
|
+
|
|
107
|
+
def screen_and_record(job: dict, treasury: Treasury) -> tuple[dict | None, str | None]:
|
|
108
|
+
"""Run the intake screen on a coerced job and record the outcome on its row."""
|
|
109
|
+
job_id = job["id"]
|
|
100
110
|
# Commercial screen: duplicates, bursts, oversized orders, unreachable
|
|
101
111
|
# customers. Runs before pricing, so a screened-out job costs nothing.
|
|
102
112
|
screen = screen_job(job, treasury)
|
|
@@ -128,6 +138,17 @@ def validate_and_coerce_job(job: dict, treasury: Treasury) -> tuple[dict | None,
|
|
|
128
138
|
return job, None
|
|
129
139
|
|
|
130
140
|
|
|
141
|
+
def validate_and_coerce_job(job: dict, treasury: Treasury) -> tuple[dict | None, str | None]:
|
|
142
|
+
"""Return (coerced_job, error_reason), recording failures on the job row."""
|
|
143
|
+
coerced, err, recorded = _coerce_job(job)
|
|
144
|
+
if err:
|
|
145
|
+
if recorded is not None:
|
|
146
|
+
treasury.upsert_job(job["id"], "failed", error_reason=recorded)
|
|
147
|
+
return None, err
|
|
148
|
+
assert coerced is not None
|
|
149
|
+
return screen_and_record(coerced, treasury)
|
|
150
|
+
|
|
151
|
+
|
|
131
152
|
class StageRunner:
|
|
132
153
|
"""Runs idempotent stages for a single job."""
|
|
133
154
|
|
|
@@ -486,19 +486,34 @@ class Treasury:
|
|
|
486
486
|
params.append(job_id)
|
|
487
487
|
conn.execute(f"UPDATE jobs SET {', '.join(fields)} WHERE id = ?", params)
|
|
488
488
|
else:
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
489
|
+
self._insert_job_row(conn, job_id, status, ts, kwargs, verb="INSERT")
|
|
490
|
+
|
|
491
|
+
def _insert_job_row(
|
|
492
|
+
self, conn: sqlite3.Connection, job_id: str, status: str, ts: float, kwargs: dict, *, verb: str
|
|
493
|
+
) -> int:
|
|
494
|
+
cols = ["id", "status", "created_at", "updated_at"]
|
|
495
|
+
vals = [job_id, status, ts, ts]
|
|
496
|
+
for col in self._JOB_COLS:
|
|
497
|
+
cols.append(col)
|
|
498
|
+
val = kwargs.get(col)
|
|
499
|
+
if col == "job_payload_json" and isinstance(val, dict):
|
|
500
|
+
val = json.dumps(val)
|
|
501
|
+
vals.append(val)
|
|
502
|
+
placeholders = ", ".join(["?"] * len(cols))
|
|
503
|
+
cur = conn.execute(f"{verb} INTO jobs ({', '.join(cols)}) VALUES ({placeholders})", vals)
|
|
504
|
+
return cur.rowcount
|
|
505
|
+
|
|
506
|
+
def insert_job(self, job_id: str, status: str, **kwargs) -> bool:
|
|
507
|
+
"""Create a new job row atomically; return False if the id is already taken.
|
|
508
|
+
|
|
509
|
+
Unlike `upsert_job` this never touches an existing record, so it is the
|
|
510
|
+
only write a fresh submission may make before it owns the row.
|
|
511
|
+
"""
|
|
512
|
+
with self.lock(), self._conn() as conn, conn:
|
|
513
|
+
inserted = self._insert_job_row(
|
|
514
|
+
conn, job_id, status, time.time(), kwargs, verb="INSERT OR IGNORE"
|
|
515
|
+
)
|
|
516
|
+
return inserted == 1
|
|
502
517
|
|
|
503
518
|
def get_job(self, job_id: str) -> dict | None:
|
|
504
519
|
with self.lock(), self._conn() as conn:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: solvent-agent
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: A self-funding analyst agent: earns on Stripe, fulfils with NVIDIA Nemotron, spends within guardrails.
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/ianalloway/solvent-agent
|
|
@@ -78,3 +78,51 @@ def test_inline_script_json_cannot_close_script_tag(tmp_path, monkeypatch):
|
|
|
78
78
|
assert "</script><script>globalThis" not in page
|
|
79
79
|
# The data still round-trips as JSON for the client.
|
|
80
80
|
assert json.loads(dashboard._script_json({"t": breakout}))["t"] == breakout
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_inline_script_json_escapes_js_line_terminators():
|
|
84
|
+
encoded = dashboard._script_json({"t": "a
b
c"})
|
|
85
|
+
assert "
" not in encoded and "
" not in encoded
|
|
86
|
+
assert json.loads(encoded)["t"] == "a
b
c"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _js_function(page: str, name: str) -> str:
|
|
90
|
+
start = page.index(f"function {name}(")
|
|
91
|
+
return page[start : page.index("\n }\n", start)]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_client_renders_job_fields_as_text_not_markup(tmp_path, monkeypatch):
|
|
95
|
+
monkeypatch.setenv("SOLVENT_HOME", str(tmp_path))
|
|
96
|
+
monkeypatch.setattr(dashboard, "OUT", tmp_path / "treasury_dashboard.html")
|
|
97
|
+
page = dashboard.render(_snapshot(), []).read_text()
|
|
98
|
+
|
|
99
|
+
for name in ("updateBriefsList", "updateDrawerContent"):
|
|
100
|
+
body = _js_function(page, name)
|
|
101
|
+
assert "textContent" in body
|
|
102
|
+
assert "metaHTML" not in body
|
|
103
|
+
assert "container.innerHTML = html" not in body
|
|
104
|
+
assert "onclick=" not in body
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_rendered_inline_scripts_parse_as_javascript(tmp_path, monkeypatch):
|
|
108
|
+
import re
|
|
109
|
+
import shutil
|
|
110
|
+
import subprocess
|
|
111
|
+
|
|
112
|
+
import pytest
|
|
113
|
+
|
|
114
|
+
node = shutil.which("node")
|
|
115
|
+
if node is None:
|
|
116
|
+
pytest.skip("node is not installed")
|
|
117
|
+
monkeypatch.setenv("SOLVENT_HOME", str(tmp_path))
|
|
118
|
+
monkeypatch.setattr(dashboard, "OUT", tmp_path / "treasury_dashboard.html")
|
|
119
|
+
page = dashboard.render(_snapshot(), []).read_text()
|
|
120
|
+
|
|
121
|
+
scripts = re.findall(r"<script>(.*?)</script>", page, re.S)
|
|
122
|
+
assert scripts
|
|
123
|
+
for index, script in enumerate(scripts):
|
|
124
|
+
path = tmp_path / f"inline-{index}.js"
|
|
125
|
+
path.write_text(script, encoding="utf-8")
|
|
126
|
+
result = subprocess.run([node, "--check", str(path)], capture_output=True, text=True)
|
|
127
|
+
assert result.returncode == 0, result.stderr
|
|
128
|
+
assert "html.split(/\\n\\n+/)" in page
|
|
@@ -14,6 +14,7 @@ from solvent.export import (
|
|
|
14
14
|
collect,
|
|
15
15
|
format_summary,
|
|
16
16
|
ledger_rows,
|
|
17
|
+
neutralise_cell,
|
|
17
18
|
parse_period,
|
|
18
19
|
period_summary,
|
|
19
20
|
write_csv,
|
|
@@ -179,3 +180,50 @@ def test_format_summary_reports_the_close_and_the_files(treasury, tmp_path):
|
|
|
179
180
|
assert "PERIOD CLOSE" in rendered
|
|
180
181
|
assert "$49.00" in rendered
|
|
181
182
|
assert "ledger.csv" in rendered
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@pytest.mark.parametrize(
|
|
186
|
+
"raw",
|
|
187
|
+
[
|
|
188
|
+
"=HYPERLINK(\"http://evil.example\",\"x\")",
|
|
189
|
+
"+1+cmd|' /C calc'!A0",
|
|
190
|
+
"-2+3",
|
|
191
|
+
"@SUM(A1:A9)",
|
|
192
|
+
"\t=1+1",
|
|
193
|
+
"\r=1+1",
|
|
194
|
+
" =1+1",
|
|
195
|
+
"\x00\u200b=1+1",
|
|
196
|
+
],
|
|
197
|
+
)
|
|
198
|
+
def test_formula_like_text_is_neutralised(raw):
|
|
199
|
+
assert neutralise_cell(raw) == "'" + raw
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@pytest.mark.parametrize("value", ["a brief", "a@x.example", "", None, 4_900, -600, 12.5])
|
|
203
|
+
def test_ordinary_values_pass_through(value):
|
|
204
|
+
assert neutralise_cell(value) == value
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_write_csv_neutralises_untrusted_text_but_keeps_numbers(tmp_path):
|
|
208
|
+
t = Treasury(path=tmp_path / "ledger.db")
|
|
209
|
+
t.seed(10_000)
|
|
210
|
+
t.upsert_job(
|
|
211
|
+
"J1",
|
|
212
|
+
"completed",
|
|
213
|
+
topic='=HYPERLINK("http://evil.example","open")',
|
|
214
|
+
budget_cents=4_900,
|
|
215
|
+
customer_email="a@x.example",
|
|
216
|
+
)
|
|
217
|
+
t.spend(600, "@SUM(1+1)", job_id="J1", vendor="-vendor")
|
|
218
|
+
write_csv(collect(t), tmp_path / "books")
|
|
219
|
+
|
|
220
|
+
with (tmp_path / "books" / "jobs.csv").open(encoding="utf-8") as fh:
|
|
221
|
+
job = next(csv.DictReader(fh))
|
|
222
|
+
assert job["topic"] == '\'=HYPERLINK("http://evil.example","open")'
|
|
223
|
+
assert job["budget_cents"] == "4900"
|
|
224
|
+
|
|
225
|
+
with (tmp_path / "books" / "ledger.csv").open(encoding="utf-8") as fh:
|
|
226
|
+
expense = next(r for r in csv.DictReader(fh) if r["kind"] == "expense")
|
|
227
|
+
assert expense["memo"] == "'@SUM(1+1)"
|
|
228
|
+
assert expense["vendor"] == "'-vendor"
|
|
229
|
+
assert expense["signed_cents"] == "-600"
|