Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Supervaizer Changelog

> **Created:** 2025-08-05
> **Updated:** 2026-05-19
> **Updated:** 2026-05-20

All notable changes to this project will be documented in this file.

Expand All @@ -11,19 +11,32 @@ All notable changes to this project will be documented in this file.

- Review and test feature/data-persistence
- Complete feature/smart-install implementation
- Fix receive_human_input
- When AgentMethodField returns its value (in the kwargs of job_start), the value should be casted in the appropriate type :
- example: here the 'How many times to say hello' is supposed to be an 'int'.
- agent_simple:job_start:74 - AGENT ExampleAgent: Received kwargs: {'action': 'start', 'fields': {'How many times to say hello': '3'}, 'context': JobContext(workspace_id='odm', job_id='01KGM75NQ76AWBAXHXERW8FKHW', started_by='alp', started_at=datetime.datetime(2026, 2, 4, 11, 39, 0, 712598, tzinfo=TzInfo(0)), mission_id='01KGG50ZMFYMHG9N5FGCACF0XA', mission_name='Operate Agent Hello World AI Agent', mission_context=None, job_instructions=JobInstructions(max_cases=None, max_duration=None, max_cost=None, stop_on_warning=False, stop_on_error=True, job_start_time=None)), 'agent_parameters': [{'name': 'SIMPLE AGENT PARAMETER', 'team_id': 2, 'description': 'Setup agent parameter in this workspace', 'is_environment': True, 'value': '123456', 'is_secret': False, 'is_required': False}, {'name': 'SIMPLE AGENT SECRET', 'team_id': 2, 'description': 'Setup agent secret in this workspace', 'is_environment': True, 'value': '123456', 'is_secret': True, 'is_required': False}]}

## [Unreleased]

### Changed

- **Cloud Logging structured output** — `SUPERVAIZER_LOG_FORMAT=json` now switches controller stderr logs to newline-delimited JSON with Cloud Logging-compatible `severity` plus bound fields such as access-denial `path`, `reason`, and truncated `key_preview`; local text logging remains the default.

### Tests

- `tests/test_common.py` — structured JSON log output for API access-denial records
- `just test`

| Status | Count |
| ---------- | ----- |
| ✅ Passed | 658 |
| 🤔 Skipped | 0 |
| 🔴 Failed | 0 |
| ⏱️ in | 92s |

## [1.1.0] - 2026-05-19

### Added

- **Workspace agent authorization** — Studio-signed Ed25519 workspace authorization tokens on `X-Supervaize-Workspace-Authorization`; the SDK verifies JWKS-backed tokens and exposes `V2VerifiedWorkspaceContext` for handlers. Workspace and tenant slugs remain routing hints only.
- **Workspace binding protocol** — Agents can declare optional `workspace_binding` metadata with bootstrap `workspace_binding.options`, `workspace_binding.create`, and the `workspace_binding.create` surface so Studio can bind an agent-side record before a Workspace Agent Grant exists.
- **Workspace binding rotocol** — Agents can declare optional `workspace_binding` metadata with bootstrap `workspace_binding.options`, `workspace_binding.create`, and the `workspace_binding.create` surface so Studio can bind an agent-side record before a Workspace Agent Grant exists.
- **Workspace authorization docs** — `docs/2026_05_WORKSPACE_AGENT_GRANTS.md` plus workspace authorization and binding bootstrap rules in `docs/2026_05_PROTOCOLS.md` and `docs/2026_05_SUPERVAIZER_v2.md`.

### Changed
Expand Down
67 changes: 63 additions & 4 deletions src/supervaizer/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@
import base64
import json
import os
import sys
import traceback
from collections.abc import Callable
from typing import Any, TypeVar
from typing import Any, TextIO, TypeVar

import demjson3
from cryptography.hazmat.primitives import hashes
Expand All @@ -28,6 +29,57 @@
log = logger.bind(module="supervaize")

T = TypeVar("T")
STRUCTURED_LOG_FORMAT_ENV = "SUPERVAIZER_LOG_FORMAT"
STRUCTURED_LOG_FORMAT_JSON = "json"
_DEFAULT_LOG_FORMAT = (
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green>|"
"<level> {level}</level> | <level>{message}</level>"
)


def structured_logging_enabled() -> bool:
"""Return whether Supervaizer should emit newline-delimited JSON logs."""
return (
os.getenv(STRUCTURED_LOG_FORMAT_ENV, "").strip().lower()
== STRUCTURED_LOG_FORMAT_JSON
)


def configure_controller_logging(
log_level: str,
*,
sink: TextIO = sys.stderr,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve stderr sink at call time

Avoid binding sys.stderr in a default argument here: default values are evaluated at import time, so if the process later replaces sys.stderr (for example pytest capture, embedding hosts, or runtime redirection), configure_controller_logging() will still write to the stale original stream and logs will bypass the active sink. The previous inline setup in Server.launch() resolved sys.stderr at call time; using sink: TextIO | None = None and assigning sink = sys.stderr inside the function preserves expected redirection behavior.

Useful? React with 👍 / 👎.

) -> int:
"""Configure Supervaizer controller logs for local text or Cloud Logging JSON."""
log.remove()
if structured_logging_enabled():
return log.add(
lambda message: _write_structured_log(message.record, sink),
level=log_level,
)
return log.add(
sink,
colorize=True,
format=_DEFAULT_LOG_FORMAT,
level=log_level,
)


def _write_structured_log(record: dict[str, Any], sink: TextIO) -> None:
payload: dict[str, Any] = {
"severity": record["level"].name,
"message": record["message"],
"timestamp": record["time"].isoformat(),
"logger": record["name"],
"module": record["module"],
"function": record["function"],
"line": record["line"],
}
for key, value in record["extra"].items():
target_key = key if key not in payload else f"extra_{key}"
payload[target_key] = value
sink.write(json.dumps(payload, default=str, separators=(",", ":")) + "\n")
sink.flush()


def is_local_mode() -> bool:
Expand Down Expand Up @@ -220,13 +272,20 @@ def dict(self) -> dict[str, Any]:

def log_access_denied_tailscale(ip: str, path: str, reason: str) -> None: # <-- ADDED
"""Log a Tailscale access denial with structured fields."""
log.warning(f"[access:tailscale] denied ip={ip!r} path={path!r} reason={reason!r}")
log.bind(access_type="tailscale", ip=ip, path=path, reason=reason).warning(
f"[access:tailscale] denied ip={ip!r} path={path!r} reason={reason!r}"
)


def log_access_denied_api(key: str | None, path: str, reason: str) -> None: # <-- ADDED
"""Log an API-key access denial; key is truncated to avoid leaking secrets."""
key_preview = (key[:6] + "…") if key and len(key) > 6 else (key or "<none>")
log.warning(
key_preview = f"{key[:6]}..." if key and len(key) > 6 else (key or "<none>")
log.bind(
access_type="api",
key_preview=key_preview,
path=path,
reason=reason,
).warning(
f"[access:api] denied key={key_preview!r} path={path!r} reason={reason!r}"
)

Expand Down
12 changes: 3 additions & 9 deletions src/supervaizer/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,14 @@
# https://mozilla.org/MPL/2.0/.

import asyncio
from hashlib import sha256
import os
import secrets
import sys
import time
import uuid
from collections.abc import AsyncIterator, Callable
from contextlib import asynccontextmanager
from datetime import datetime # <-- REMOVED: Path (no longer needed)
from hashlib import sha256
from typing import Any, ClassVar, TypeVar, cast
from urllib.parse import urlunparse

Expand All @@ -45,6 +44,7 @@
ApiResult,
ApiSuccess,
SvBaseModel,
configure_controller_logging,
decrypt_value,
encrypt_value,
is_local_mode,
Expand Down Expand Up @@ -735,13 +735,7 @@ def registration_info(self) -> dict[str, Any]:

def launch(self, log_level: str | None = "INFO") -> None:
if log_level:
log.remove()
log.add(
sys.stderr,
colorize=True,
format="<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green>|<level> {level}</level> | <level>{message}</level>",
level=log_level,
)
configure_controller_logging(log_level)

# Add log handler for admin streaming if API key is enabled
if self.api_key:
Expand Down
28 changes: 28 additions & 0 deletions tests/test_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@


import json
from io import StringIO
from typing import Any

import pytest
Expand All @@ -23,8 +24,12 @@
ApiError,
ApiSuccess,
SvBaseModel,
STRUCTURED_LOG_FORMAT_ENV,
configure_controller_logging,
decrypt_value,
encrypt_value,
log,
log_access_denied_api,
singleton,
)

Expand Down Expand Up @@ -203,6 +208,29 @@ def __init__(self, status_code: int, response_text: str) -> None:
assert error.log_message == "❌ error : "


def test_configure_controller_logging_outputs_cloud_logging_json(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Structured logging emits JSON with Cloud Logging severity and queryable fields."""
monkeypatch.setenv(STRUCTURED_LOG_FORMAT_ENV, "json")
output = StringIO()
sink_id = configure_controller_logging("INFO", sink=output)
try:
log_access_denied_api("abcdef123456", "/a2a", "invalid key")
finally:
log.remove(sink_id)

payload = json.loads(output.getvalue())
assert payload["severity"] == "WARNING"
assert payload["message"] == (
"[access:api] denied key='abcdef...' path='/a2a' reason='invalid key'"
)
assert payload["access_type"] == "api"
assert payload["key_preview"] == "abcdef..."
assert payload["path"] == "/a2a"
assert payload["reason"] == "invalid key"


def test_singleton() -> None:
"""Test singleton decorator"""

Expand Down
Loading