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
13 changes: 8 additions & 5 deletions docs/EXCEPTION_INBOX.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,14 @@ replayed, or undeclared cursor or receipt refuses instead of guessing.
- Browser mutations also require a valid Host, same-origin request,
`application/json`, and the session CSRF token.
- The authenticated local OS account is recorded as the operator.
- Queue records use opaque IDs and category-derived copy. Workflow names,
parameters, raw halt reasons, observed text, local paths, and raw reports do
not enter the DTO.
- Artifact lookup accepts only report-referenced PNG IDs and never follows a
symlink.
- Queue and summary records use opaque IDs and category-derived copy. Workflow
names, parameters, raw halt reasons, observed text, local paths, and raw
reports do not enter those DTOs. Protected values appear only when the local
operator explicitly opens an admitted JSON/JSONL artifact.
- Artifact lookup accepts only report-referenced PNG IDs or bounded JSON
families selected by opaque IDs. It never accepts a filesystem path or
follows a symlink. The viewer reads only the configured local roots and has
no Cloud or other network transport.
- The notification endpoint returns only a count and `#/attention`; it is the
PHI-free seam for a desktop/tray OS notification.

Expand Down
8 changes: 5 additions & 3 deletions openadapt_flow/console/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
"""Authenticated loopback operator console over engine artifacts.

The browser receives explicit redacted projections of compiled bundles, run
reports, durable pause/approval state, and versioned skill libraries. It does
not receive raw workflow/report models, protected labels, parameter values, or
local paths. The console invents no new engine semantics:
reports, durable pause/approval state, and versioned skill libraries. Protected
labels and parameter values appear only when the local operator explicitly
opens an admitted JSON/JSONL artifact in the bearer-authenticated viewer;
filesystem paths never cross the API boundary. The console invents no new engine
semantics:

- Every number it shows is computed by the SAME callables the CLI uses
(``policy.evaluate_policy`` / ``policy.lint_workflow`` / the identity- and
Expand Down
69 changes: 64 additions & 5 deletions openadapt_flow/console/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@
remove the normal console. Attended mutations additionally require an exact
engine-issued pause capability and a deployment-bound action service.

Browser DTOs use opaque ids and explicit projections; protected workflow
labels, parameters, identity evidence, local paths, and raw reports do not
cross the API boundary. Run artifacts are limited to PNGs explicitly
Browser summary DTOs use opaque ids and explicit projections; protected
workflow labels, parameters, identity evidence, and local paths do not cross
that API boundary. Raw JSON is available only through the explicit protected
local-artifact viewer. Run screenshots remain limited to PNGs explicitly
referenced by the protected report and resolved without following symlinks.
"""

Expand All @@ -24,7 +25,7 @@
import secrets
import shlex
from pathlib import Path
from typing import Any, Optional
from typing import Any, Literal, Optional
from urllib.parse import urlsplit

from fastapi import FastAPI, HTTPException, Query, Request
Expand All @@ -34,7 +35,7 @@
from openadapt_flow import __version__
from openadapt_flow.console import actions as actions_mod
from openadapt_flow.console import attention as attention_mod
from openadapt_flow.console import data
from openadapt_flow.console import data, json_artifacts
from openadapt_flow.runtime.durable.approval import ResumeRefused
from openadapt_flow.runtime.durable.attended import (
AttendedActionRefused,
Expand Down Expand Up @@ -491,6 +492,64 @@ def run_detail(run_id: str) -> dict[str, Any]:
run_dir = _resolve_run(runs, run_id)
return data.run_detail(runs, run_dir)

# -- explicit local JSON artifacts -------------------------------------

def json_artifact_owner(
scope: str, owner_id: str
) -> tuple[Path, Literal["runs", "workflows"]]:
if scope == "runs":
return _resolve_run(runs, owner_id), "runs"
if scope == "workflows":
return _resolve_bundle(bundles, owner_id), "workflows"
raise HTTPException(status_code=404, detail="no such artifact scope")

def load_json_artifact(
scope: str, owner_id: str, artifact_id: str
) -> json_artifacts.LoadedJsonArtifact:
owner, admitted_scope = json_artifact_owner(scope, owner_id)
artifact = json_artifacts.resolve_artifact(
owner,
artifact_id,
scope=admitted_scope,
)
if artifact is None:
raise HTTPException(status_code=404, detail="no such JSON artifact")
try:
return json_artifacts.load_artifact(artifact)
except json_artifacts.JsonArtifactError as exc:
raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc

@app.get("/api/artifacts/json/{scope}/{owner_id}")
def json_artifact_list(scope: str, owner_id: str) -> list[dict[str, Any]]:
owner, admitted_scope = json_artifact_owner(scope, owner_id)
return [
artifact.public()
for artifact in json_artifacts.list_artifacts(
owner,
scope=admitted_scope,
)
]

@app.get("/api/artifacts/json/{scope}/{owner_id}/{artifact_id}")
def json_artifact_document(
scope: str, owner_id: str, artifact_id: str
) -> dict[str, Any]:
return load_json_artifact(scope, owner_id, artifact_id).public()

@app.get("/api/artifacts/json/{scope}/{owner_id}/{artifact_id}/raw")
def json_artifact_raw(scope: str, owner_id: str, artifact_id: str) -> Response:
loaded = load_json_artifact(scope, owner_id, artifact_id)
media_type = (
"application/json"
if loaded.artifact.format == "json"
else "application/x-ndjson"
)
return Response(
loaded.raw,
media_type=media_type,
headers={"X-Content-SHA256": loaded.sha256},
)

# -- needs attention ----------------------------------------------------

@app.get("/api/attention")
Expand Down
Loading