Skip to content
Open
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
1 change: 1 addition & 0 deletions CAPABILITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ improvising or retrying when something is off.
| See a compiled program as a map (every step, exact edge, loop and stop rule, with each step's checks) and play a recorded run over it; one React package that Desktop, app.openadapt.ai and openadapt.ai share | On main ([packages/program-graph](packages/program-graph/README.md)) | Real compiled OpenEMR referral program and a recorded scoreboard run, both on synthetic data | `@openadapt/program-graph`; `openadapt-flow visualize --format json` |
| Named sections for every compiled program ("Who: title, first name, last name", "Contact", "Create New Patient"), found without a model from the save, the recorded form structure, the fields and any narration; a person can rename, merge or split them on the review screen | On main | Unit tests; the certified OpenEMR referral bundle and a real recording of it on a local test copy with made-up patients | `openadapt-flow sections`; [SECTIONS.md](packages/flow/docs/SECTIONS.md) |
| Encrypt PHI at rest: every bundle file that can hold patient data under one sealed index (unknown files refused), web recordings from the first frame, and the run directories of sealed bundles; compile, induce, scaffold-verifier, qualify, admit, run, resume and undo read them in memory and refuse without the key | On main | Synthetic (no-plaintext audit-hook test over record, compile and a VERIFIED run; per-frame cost measured) | `openadapt-flow seal`, `record`; [phi_at_rest.md](packages/flow/docs/phi_at_rest.md) |
| Encryption at rest under a configured or locally generated key (0600): durable checkpoints by default; with `OPENADAPT_FLOW_AT_REST=strict`, every recording, bundle and run directory too. Each plaintext write is logged on stderr and in a ledger that Desktop shows as a notice | On main | Synthetic (key, opt-out, strict-mode and run-directory tests) | `openadapt_flow.at_rest`; [phi_at_rest.md](packages/flow/docs/phi_at_rest.md#default-opt-out-and-strict-mode) |
| Signed receipts (Seals), countersigned and timestamped, offline by default | Released | Unit tests, real TSA tokens | `openadapt-flow seal`, `countersign`, `local-seal` |
| Offline Seals signed with a key the customer holds: a key file, the OS keychain, or a PKCS#11 token or HSM; pinned keys with rotation and revocation; no fallback to a generated key | On main ([#1392](https://github.com/OpenAdaptAI/OpenAdapt/pull/1392)) | Unit tests with synthetic data (keychain and PKCS#11 through stand-ins) | `openadapt-flow local-seal --key`, `local-seal key create`, `pin`, `revoke`; `openadapt seal verify FOLDER` checks every sealed run in a folder; [OFFLINE_SEALS.md](packages/flow/docs/OFFLINE_SEALS.md#customer-held-keys) |
| Anchor an offline Seal in the public Sigstore Rekor transparency log (opt-in; sends one digest and the public key), and verify the anchor offline from the stored inclusion proof | On main ([#1394](https://github.com/OpenAdaptAI/OpenAdapt/pull/1394)) | Unit tests against a simulated Rekor log; real public Rekor entries and Sigstore TSA tokens for the landing demo's referral runs, verified offline in tests; Ed25519ph matches the RFC 8032 test vector | `openadapt-flow local-seal anchor`, `local-seal verify --require-anchor`; [OFFLINE_SEALS.md](packages/flow/docs/OFFLINE_SEALS.md#optional-anchor-a-seal-in-a-public-transparency-log) |
Expand Down
1 change: 1 addition & 0 deletions llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -717,6 +717,7 @@ improvising or retrying when something is off.
| See a compiled program as a map (every step, exact edge, loop and stop rule, with each step's checks) and play a recorded run over it; one React package that Desktop, app.openadapt.ai and openadapt.ai share | On main ([packages/program-graph](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/program-graph/README.md)) | Real compiled OpenEMR referral program and a recorded scoreboard run, both on synthetic data | `@openadapt/program-graph`; `openadapt-flow visualize --format json` |
| Named sections for every compiled program ("Who: title, first name, last name", "Contact", "Create New Patient"), found without a model from the save, the recorded form structure, the fields and any narration; a person can rename, merge or split them on the review screen | On main | Unit tests; the certified OpenEMR referral bundle and a real recording of it on a local test copy with made-up patients | `openadapt-flow sections`; [SECTIONS.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/SECTIONS.md) |
| Encrypt PHI at rest: every bundle file that can hold patient data under one sealed index (unknown files refused), web recordings from the first frame, and the run directories of sealed bundles; compile, induce, scaffold-verifier, qualify, admit, run, resume and undo read them in memory and refuse without the key | On main | Synthetic (no-plaintext audit-hook test over record, compile and a VERIFIED run; per-frame cost measured) | `openadapt-flow seal`, `record`; [phi_at_rest.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/phi_at_rest.md) |
| Encryption at rest under a configured or locally generated key (0600): durable checkpoints by default; with `OPENADAPT_FLOW_AT_REST=strict`, every recording, bundle and run directory too. Each plaintext write is logged on stderr and in a ledger that Desktop shows as a notice | On main | Synthetic (key, opt-out, strict-mode and run-directory tests) | `openadapt_flow.at_rest`; [phi_at_rest.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/phi_at_rest.md#default-opt-out-and-strict-mode) |
| Signed receipts (Seals), countersigned and timestamped, offline by default | Released | Unit tests, real TSA tokens | `openadapt-flow seal`, `countersign`, `local-seal` |
| Offline Seals signed with a key the customer holds: a key file, the OS keychain, or a PKCS#11 token or HSM; pinned keys with rotation and revocation; no fallback to a generated key | On main ([#1392](https://github.com/OpenAdaptAI/OpenAdapt/pull/1392)) | Unit tests with synthetic data (keychain and PKCS#11 through stand-ins) | `openadapt-flow local-seal --key`, `local-seal key create`, `pin`, `revoke`; `openadapt seal verify FOLDER` checks every sealed run in a folder; [OFFLINE_SEALS.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/OFFLINE_SEALS.md#customer-held-keys) |
| Anchor an offline Seal in the public Sigstore Rekor transparency log (opt-in; sends one digest and the public key), and verify the anchor offline from the stored inclusion proof | On main ([#1394](https://github.com/OpenAdaptAI/OpenAdapt/pull/1394)) | Unit tests against a simulated Rekor log; real public Rekor entries and Sigstore TSA tokens for the landing demo's referral runs, verified offline in tests; Ed25519ph matches the RFC 8032 test vector | `openadapt-flow local-seal anchor`, `local-seal verify --require-anchor`; [OFFLINE_SEALS.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/OFFLINE_SEALS.md#optional-anchor-a-seal-in-a-public-transparency-log) |
Expand Down
14 changes: 14 additions & 0 deletions packages/agent/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,3 +184,17 @@ def _isolated_seal_keys(tmp_path_factory, monkeypatch):
"openadapt_agent.seal.default_keys_path",
lambda: tmp_path_factory.mktemp("home") / ".openadapt" / "seal-keys.json",
)


@pytest.fixture(autouse=True)
def _isolated_flow_at_rest(tmp_path_factory, monkeypatch):
"""Keep Flow's at-rest key and plaintext ledger out of the real home.

Flow encrypts at rest by default (openadapt_flow.at_rest); these tests
use its recorded opt-out, as the Flow suite does.
"""
monkeypatch.setenv(
"OPENADAPT_FLOW_AT_REST_HOME", str(tmp_path_factory.mktemp("at-rest-home"))
)
monkeypatch.setenv("OPENADAPT_FLOW_PLAINTEXT_AT_REST", "test suite: synthetic data")
monkeypatch.delenv("OPENADAPT_FLOW_AT_REST", raising=False)
8 changes: 8 additions & 0 deletions packages/desktop/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
# CHANGELOG


## v0.26.0 (2026-11-03)

### Features

- show a notice when Flow logs a recording or run record stored without encryption



## v0.25.0 (2026-10-28)

### Features
Expand Down
2 changes: 1 addition & 1 deletion packages/desktop/openadapt_desktop/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,4 @@
+-- backends/ Storage backend plugins
"""

__version__ = "0.25.0"
__version__ = "0.26.0"
74 changes: 74 additions & 0 deletions packages/desktop/openadapt_desktop/at_rest_notice.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""What Desktop tells the person about patient data stored unencrypted.

Flow encrypts recordings, bundles and checkpoints by default. A few artifacts
can't be encrypted yet (run directories outside strict mode, desktop
recordings, approval requests, ``visualize`` and similar output), and Desktop
runs Flow under the recorded plaintext opt-out while its own per-workflow
keychain encryption covers certified workflows. Every such plaintext write
is a line in Flow's ledger, ``plaintext-at-rest.jsonl``. This module reads
that ledger so the window can show a notice, not only a line on stderr.

It reads the file directly, with the same location rule as
``openadapt_flow.at_rest``, so it works with any installed Flow version.
"""

from __future__ import annotations

import json
import os
from pathlib import Path
from typing import Any

LEDGER_NAME = "plaintext-at-rest.jsonl"
OPT_OUT_ENV = "OPENADAPT_FLOW_PLAINTEXT_AT_REST"
MODE_ENV = "OPENADAPT_FLOW_AT_REST"
DESKTOP_OPT_OUT_REASON = (
"OpenAdapt Desktop: certified workflows are encrypted with per-workflow "
"keys in the system keychain; Flow's default encryption is not wired into "
"Desktop yet"
)


def ledger_path(env: dict[str, str] | None = None) -> Path:
env = dict(os.environ) if env is None else env
root = env.get("OPENADAPT_FLOW_AT_REST_HOME") or env.get("OPENADAPT_HOME")
return (Path(root) if root else Path.home() / ".openadapt") / LEDGER_NAME


def strict(env: dict[str, str] | None = None) -> bool:
env = dict(os.environ) if env is None else env
return (env.get(MODE_ENV) or "").strip().lower() == "strict"


def flow_env(env: dict[str, str]) -> dict[str, str]:
"""The Flow subprocess environment, with Desktop's recorded opt-out.

Strict mode and an opt-out the person set themselves are left alone.
"""
if not strict(env) and not (env.get(OPT_OUT_ENV) or "").strip():
env[OPT_OUT_ENV] = DESKTOP_OPT_OUT_REASON
return env


def status(env: dict[str, str] | None = None, *, limit: int = 200) -> dict[str, Any]:
"""Counts and the latest unencrypted write, for the window's notice."""
entries: list[dict[str, Any]] = []
try:
lines = ledger_path(env).read_text(encoding="utf-8").splitlines()[-limit:]
except OSError:
lines = []
for line in lines:
try:
entry = json.loads(line)
except ValueError:
continue
if isinstance(entry, dict):
entries.append(entry)
latest = entries[-1] if entries else {}
return {
"strict": strict(env),
"unencrypted_writes": len(entries),
"latest_purpose": str(latest.get("purpose") or ""),
"latest_at": latest.get("recorded_at"),
"ledger": str(ledger_path(env)),
}
8 changes: 8 additions & 0 deletions packages/desktop/openadapt_desktop/dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,8 @@ def _register(self) -> None:
"shadow_set_work_domains": self.shadow_set_work_domains,
"shadow_decide": self.shadow_decide,
"shadow_scan": self.shadow_scan,
# Unencrypted patient data on this computer (Flow's ledger).
"at_rest_status": self.at_rest_status,
}

# ------------------------------------------------------- shadow mode
Expand Down Expand Up @@ -6235,6 +6237,12 @@ def _runner_service(self) -> Any:
self.services.runner = RunnerService(self.config, self.services, emit=self.emit)
return self.services.runner

def at_rest_status(self, **params: Any) -> dict:
"""How many writes Flow recorded as unencrypted, for the notice."""
from openadapt_desktop.at_rest_notice import status

return status()

def runner_status(self, **params: Any) -> dict:
"""Return the ``RunnerStatus``-shaped dict for the Runner screen."""
return self._runner_service().status()
Expand Down
7 changes: 6 additions & 1 deletion packages/desktop/openadapt_desktop/flow_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -549,7 +549,12 @@ def _subprocess_env() -> dict[str, str]:
# commands from --approver-root. Drop any inherited value so no Flow
# version or helper can read one.
env.pop("OPENADAPT_APPROVER_ROOT", None)
return env
# Flow encrypts at rest by default; Desktop's own encryption covers
# certified workflows, so Flow runs under its recorded opt-out here, and
# each plaintext write it records shows as a notice in the window.
from openadapt_desktop.at_rest_notice import flow_env

return flow_env(env)


def _safe_command_for_log(cmd: list[str]) -> str:
Expand Down
4 changes: 2 additions & 2 deletions packages/desktop/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "openadapt-desktop",
"version": "0.25.0",
"version": "0.26.0",
"description": "Installed companion for OpenAdapt authoring, teaching, and local pairing",
"private": true,
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion packages/desktop/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "openadapt-desktop"
version = "0.25.0"
version = "0.26.0"
description = "The OpenAdapt desktop app and local engine: record a data-entry task, run it on a list, and check each saved record"
readme = "README.md"
requires-python = ">=3.11"
Expand Down
2 changes: 1 addition & 1 deletion packages/desktop/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "openadapt-desktop"
version = "0.25.0"
version = "0.26.0"
description = "Installed companion for OpenAdapt authoring, teaching, and local pairing"
authors = ["OpenAdapt AI"]
license = "MIT"
Expand Down
2 changes: 1 addition & 1 deletion packages/desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicoverbruggen/tauri-v2-schema/main/schema.json",
"productName": "OpenAdapt Desktop",
"version": "0.25.0",
"version": "0.26.0",
"identifier": "ai.openadapt.desktop",
"build": {
"frontendDist": "../dist",
Expand Down
65 changes: 65 additions & 0 deletions packages/desktop/src/App.atRestNotice.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";

// Flow records each write of patient data it can't encrypt yet in a local
// ledger; the window must say so (openadapt_desktop/at_rest_notice.py).

vi.mock("./lib/engine", async (importOriginal) => {
const actual = await importOriginal<typeof import("./lib/engine")>();
return {
...actual,
engineTry: vi.fn(),
onEngineEvent: vi.fn(() => Promise.resolve(() => {})),
sidecarRunning: vi.fn(async () => true),
};
});

vi.mock("./screens/Welcome", () => ({
Welcome: () => <div>Welcome screen</div>,
}));

import App from "./App";
import { CMD, engineTry } from "./lib/engine";

afterEach(() => {
cleanup();
vi.clearAllMocks();
});

function engineWith(atRest: Record<string, unknown> | null) {
vi.mocked(engineTry).mockImplementation(async (command, _params, fallback) => {
if (command === CMD.GET_AUTH_STATUS) return { authenticated: true };
if (command === CMD.GET_WORKFLOWS) return [];
if (command === CMD.GET_FIRST_WORKFLOW_STATE) return { ok: true, state: null };
if (command === CMD.GET_NEEDS_ATTENTION) {
return { count: 0, open_halts: 0, failed_runs: 0 };
}
if (command === CMD.GET_SYNC_STATE) return { state: "synced", queued: 0 };
if (command === CMD.AUTHORING_STATUS) return { status: "idle" };
if (command === CMD.AT_REST_STATUS) return atRest;
return fallback ?? null;
});
}

it("names unencrypted writes and where they are listed", async () => {
engineWith({
strict: false,
unencrypted_writes: 3,
latest_purpose: "a run directory at /tmp/run",
ledger: "/Users/me/.openadapt/plaintext-at-rest.jsonl",
});
render(<App />);
const [notice, ...others] = await screen.findAllByText(/without encryption/);
expect(others).toHaveLength(0);
expect(notice.textContent).toContain("3 recordings or run records");
expect(notice.textContent).toContain("/Users/me/.openadapt/plaintext-at-rest.jsonl");
});

it("shows nothing when nothing was written unencrypted", async () => {
engineWith({ strict: false, unencrypted_writes: 0, latest_purpose: "", ledger: "x" });
render(<App />);
await waitFor(() =>
expect(vi.mocked(engineTry)).toHaveBeenCalledWith(CMD.AT_REST_STATUS, {}, null),
);
expect(screen.queryAllByText(/without encryption/)).toHaveLength(0);
});
33 changes: 33 additions & 0 deletions packages/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,13 @@ function DesktopEntryShell({ children }: { children: React.ReactNode }) {
);
}

type AtRestStatus = {
strict: boolean;
unencrypted_writes: number;
latest_purpose: string;
ledger: string;
};

export default function App() {
const [auth, setAuth] = useState<AuthStatus | null>(null);
const [checkedAuth, setCheckedAuth] = useState(false);
Expand Down Expand Up @@ -257,6 +264,7 @@ export default function App() {
allowed?: boolean;
coach_only?: boolean;
} | null>(null);
const [atRest, setAtRest] = useState<AtRestStatus | null>(null);
const [authoringUrl, setAuthoringUrl] = useState("");
const [firstRunPersistencePending, setFirstRunPersistencePending] =
useState(false);
Expand Down Expand Up @@ -384,6 +392,9 @@ export default function App() {
if (authoringStatus?.status && authoringStatus.status !== "idle") {
setAuthoring(authoringStatus);
}
setAtRest(
await engineTry<AtRestStatus | null>(CMD.AT_REST_STATUS, {}, null),
);
} finally {
setCheckedAuth(true);
}
Expand Down Expand Up @@ -490,6 +501,23 @@ export default function App() {
)}
</div>
) : null;
// Flow records every write of patient data it can't encrypt yet; say so in
// the window, not only on stderr (openadapt_desktop/at_rest_notice.py).
const atRestNotice =
atRest && atRest.unencrypted_writes > 0 ? (
<div className="pairing-notice" role="status">
<span>
{`OpenAdapt stored ${atRest.unencrypted_writes === 1 ? "1 recording or run record" : `${atRest.unencrypted_writes} recordings or run records`} on this computer without encryption. Keep full-disk encryption on. The list is in ${atRest.ledger}.`}
</span>
<button
type="button"
aria-label="Dismiss unencrypted data notice"
onClick={() => setAtRest(null)}
>
×
</button>
</div>
) : null;
const authoringNotice =
authoring &&
(authoring.status === "pending_allow" ||
Expand Down Expand Up @@ -729,6 +757,7 @@ export default function App() {
<>
{pairingNotice}
{authoringNotice}
{atRestNotice}
{firstWorkflowStageNotice}
<DesktopEntryShell>
<div className="center-stage"><span className="page-sub">Loading…</span></div>
Expand All @@ -742,6 +771,7 @@ export default function App() {
<>
{pairingNotice}
{authoringNotice}
{atRestNotice}
{firstWorkflowStageNotice}
<DesktopEntryShell>
<Login
Expand All @@ -764,6 +794,7 @@ export default function App() {
<>
{pairingNotice}
{authoringNotice}
{atRestNotice}
{firstWorkflowStageNotice}
<DesktopEntryShell>
<EngineOffline busy={entryRetrying} onRetry={() => void retryEntry()} />
Expand All @@ -779,6 +810,7 @@ export default function App() {
<>
{pairingNotice}
{authoringNotice}
{atRestNotice}
{firstWorkflowStageNotice}
<DesktopEntryShell>
<Welcome
Expand Down Expand Up @@ -824,6 +856,7 @@ export default function App() {
<>
{pairingNotice}
{authoringNotice}
{atRestNotice}
{firstWorkflowStageNotice}
<ItemApprovalDialog />
<div className="app">
Expand Down
1 change: 1 addition & 0 deletions packages/desktop/src/lib/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export const CMD = {
AUTHORING_ALLOW: "authoring_allow",
AUTHORING_DENY: "authoring_deny",
AUTHORING_STATUS: "authoring_status",
AT_REST_STATUS: "at_rest_status",
AUTHORING_PIN_TARGET: "authoring_pin_target",
// config / settings (lane, phi_mode, hosted host)
GET_CONFIG: "get_config",
Expand Down
Loading