From 764d9cd8b4cc39d77af7b01b407cd43c378e9612 Mon Sep 17 00:00:00 2001 From: anpl1623 Date: Tue, 6 Oct 2026 01:02:55 -0400 Subject: [PATCH 1/8] Make taint follow provenance and origin rules bind every spelling Taint was decided by a flag each tool set about itself. It is now read from where the bytes came from, and a call is judged by what it may read: its tool name and every capability it plans. The list of what does not read is the short one, so a domain nobody has listed yet is held to the floor. A failed read taints the run and a failed write does not. A copy reads its source only to write it, so it leaves the run clean. A denial reaches the model without the resources its plan resolved, such as a symlink's target, and the audit record keeps them. Taint now survives a retry and memory. A retry starts from what every earlier attempt at the task recorded, and its own audit trail says so with one agent.taint.raised per inherited source. A memory written from an external source taints the next run before its first tool call. When taint is why a person is being asked, the approval reason names where the data came from. Origin rules are canonicalised on both sides. The request and the policy pattern are each reduced to lower case with no default port, so a rule binds the server it names however either was written. Patterns are matched part by part, so a host wildcard can no longer run on into the port: a pattern with no port means the default port, and `:*` means any port. That closes a deny bypass, where `https://*` admitted `https://evil.example:8443` past a deny of `https://evil.example`. IPv4 written as IPv6 and the unspecified addresses are refused, because they reach loopback under a rule that names neither. Two event tags changed, to schedule.fired and agent.task.abandoned. Records written under the old tags are still read. Drift between a tool's manifest and what it plans is written to the audit log as tool.manifest_exceeded, and every shipped manifest is pinned in a test. The scheduler abandons a whole dead dependency chain in one tick. The Tauri command surface and its view models are split into one module per area, with no change to any command or binding, before the next waves roughly double them. In the desktop app the approval queue, activity feed, alerts and drafts become shared SDK modules with pure, tested rules, under vitest. The stylesheet is split into token, layout and component sheets, with every colour defined once and a test that recomputes the contrast table from the tokens. Controls get a visible focus ring and a 3:1 edge. The shell's columns contain their own positioned content, so the window no longer scrolls away from the sidebar, and rows wrap instead of overlapping when the page is zoomed. scripts/screenshots.mjs captures each screen from fixtures with the system Chrome. --- .github/workflows/ci.yml | 5 + .gitignore | 3 + CHANGELOG.md | 29 + CONTRIBUTING.md | 7 +- apps/desktop/package-lock.json | 374 +++++++- apps/desktop/package.json | 5 +- apps/desktop/scripts/screenshots.mjs | 314 +++++++ apps/desktop/src-tauri/src/commands.rs | 800 ------------------ .../src-tauri/src/commands/activity.rs | 24 + apps/desktop/src-tauri/src/commands/agents.rs | 103 +++ .../src-tauri/src/commands/approvals.rs | 33 + apps/desktop/src-tauri/src/commands/mod.rs | 406 +++++++++ .../src-tauri/src/commands/policies.rs | 54 ++ .../src-tauri/src/commands/settings.rs | 110 +++ apps/desktop/src-tauri/src/commands/tasks.rs | 100 +++ apps/desktop/src-tauri/src/dto.rs | 630 -------------- apps/desktop/src-tauri/src/dto/agent.rs | 115 +++ apps/desktop/src-tauri/src/dto/approval.rs | 86 ++ apps/desktop/src-tauri/src/dto/event.rs | 50 ++ apps/desktop/src-tauri/src/dto/mod.rs | 174 ++++ apps/desktop/src-tauri/src/dto/settings.rs | 76 ++ apps/desktop/src-tauri/src/dto/task.rs | 200 +++++ apps/desktop/src/App.tsx | 17 +- apps/desktop/src/components/common.tsx | 231 +++-- apps/desktop/src/components/hooks.test.ts | 59 ++ apps/desktop/src/components/hooks.ts | 132 +++ apps/desktop/src/components/status.test.ts | 80 ++ apps/desktop/src/components/status.tsx | 237 ++++++ apps/desktop/src/sdk/alerts.test.ts | 125 +++ apps/desktop/src/sdk/alerts.ts | 161 ++++ apps/desktop/src/sdk/approvals.test.ts | 265 ++++++ apps/desktop/src/sdk/approvals.ts | 408 +++++++++ apps/desktop/src/sdk/cache.test.ts | 34 + apps/desktop/src/sdk/cache.ts | 115 +++ apps/desktop/src/sdk/drafts.test.ts | 82 ++ apps/desktop/src/sdk/drafts.ts | 191 +++++ apps/desktop/src/sdk/eventStream.test.ts | 152 ++++ apps/desktop/src/sdk/eventStream.ts | 244 ++++++ apps/desktop/src/sdk/live.test.ts | 106 +++ apps/desktop/src/sdk/live.ts | 201 +++++ apps/desktop/src/sdk/useAsync.test.ts | 91 ++ apps/desktop/src/sdk/useAsync.ts | 166 +++- apps/desktop/src/styles.css | 639 +------------- apps/desktop/src/styles/base.css | 193 +++++ apps/desktop/src/styles/charts.css | 242 ++++++ apps/desktop/src/styles/components.css | 685 +++++++++++++++ apps/desktop/src/styles/focus.css | 31 + apps/desktop/src/styles/fonts.css | 9 + apps/desktop/src/styles/layout.css | 386 +++++++++ apps/desktop/src/styles/status.css | 184 ++++ apps/desktop/src/styles/stylesheets.test.ts | 139 +++ apps/desktop/src/styles/tokens.css | 209 +++++ apps/desktop/vite.config.ts | 13 + crates/agentos-browser/src/error.rs | 15 +- crates/agentos-browser/src/tools.rs | 119 ++- crates/agentos-cli/src/commands/audit.rs | 30 +- crates/agentos-cli/src/commands/tools.rs | 11 +- crates/agentos-core/src/event.rs | 264 +++++- crates/agentos-core/src/task.rs | 33 + crates/agentos-core/src/tool.rs | 60 +- crates/agentos-core/src/trust.rs | 9 +- crates/agentos-permissions/src/error.rs | 73 +- crates/agentos-permissions/src/lib.rs | 9 +- crates/agentos-permissions/src/origin.rs | 766 +++++++++++++++++ crates/agentos-permissions/src/pattern.rs | 148 +++- crates/agentos-permissions/src/policy.rs | 37 + crates/agentos-permissions/src/yaml.rs | 127 ++- .../migrations/0005_run_taint_sources.sql | 12 + crates/agentos-persistence/src/runs.rs | 68 +- crates/agentos-runtime/src/agent_loop.rs | 79 +- crates/agentos-runtime/src/lib.rs | 29 +- crates/agentos-runtime/src/scheduler.rs | 126 ++- crates/agentos-runtime/tests/agent_loop.rs | 228 +++++ crates/agentos-runtime/tests/scheduler.rs | 183 ++++ crates/agentos-runtime/tests/tool_manifest.rs | 159 ++++ crates/agentos-tools/src/lib.rs | 11 +- crates/agentos-tools/src/pipeline.rs | 310 ++++++- crates/agentos-tools/src/taint.rs | 56 +- crates/agentos-tools/src/terminal.rs | 7 +- crates/agentos-tools/src/tool.rs | 47 + .../agentos-tools/tests/pipeline_security.rs | 507 ++++++++++- .../0002-trust-boundary-in-the-type-system.md | 22 +- docs/adr/0003-taint-tracking.md | 5 +- 83 files changed, 10684 insertions(+), 2391 deletions(-) create mode 100644 apps/desktop/scripts/screenshots.mjs delete mode 100644 apps/desktop/src-tauri/src/commands.rs create mode 100644 apps/desktop/src-tauri/src/commands/activity.rs create mode 100644 apps/desktop/src-tauri/src/commands/agents.rs create mode 100644 apps/desktop/src-tauri/src/commands/approvals.rs create mode 100644 apps/desktop/src-tauri/src/commands/mod.rs create mode 100644 apps/desktop/src-tauri/src/commands/policies.rs create mode 100644 apps/desktop/src-tauri/src/commands/settings.rs create mode 100644 apps/desktop/src-tauri/src/commands/tasks.rs delete mode 100644 apps/desktop/src-tauri/src/dto.rs create mode 100644 apps/desktop/src-tauri/src/dto/agent.rs create mode 100644 apps/desktop/src-tauri/src/dto/approval.rs create mode 100644 apps/desktop/src-tauri/src/dto/event.rs create mode 100644 apps/desktop/src-tauri/src/dto/mod.rs create mode 100644 apps/desktop/src-tauri/src/dto/settings.rs create mode 100644 apps/desktop/src-tauri/src/dto/task.rs create mode 100644 apps/desktop/src/components/hooks.test.ts create mode 100644 apps/desktop/src/components/hooks.ts create mode 100644 apps/desktop/src/components/status.test.ts create mode 100644 apps/desktop/src/components/status.tsx create mode 100644 apps/desktop/src/sdk/alerts.test.ts create mode 100644 apps/desktop/src/sdk/alerts.ts create mode 100644 apps/desktop/src/sdk/approvals.test.ts create mode 100644 apps/desktop/src/sdk/approvals.ts create mode 100644 apps/desktop/src/sdk/cache.test.ts create mode 100644 apps/desktop/src/sdk/cache.ts create mode 100644 apps/desktop/src/sdk/drafts.test.ts create mode 100644 apps/desktop/src/sdk/drafts.ts create mode 100644 apps/desktop/src/sdk/eventStream.test.ts create mode 100644 apps/desktop/src/sdk/eventStream.ts create mode 100644 apps/desktop/src/sdk/live.test.ts create mode 100644 apps/desktop/src/sdk/live.ts create mode 100644 apps/desktop/src/sdk/useAsync.test.ts create mode 100644 apps/desktop/src/styles/base.css create mode 100644 apps/desktop/src/styles/charts.css create mode 100644 apps/desktop/src/styles/components.css create mode 100644 apps/desktop/src/styles/focus.css create mode 100644 apps/desktop/src/styles/fonts.css create mode 100644 apps/desktop/src/styles/layout.css create mode 100644 apps/desktop/src/styles/status.css create mode 100644 apps/desktop/src/styles/stylesheets.test.ts create mode 100644 apps/desktop/src/styles/tokens.css create mode 100644 crates/agentos-permissions/src/origin.rs create mode 100644 crates/agentos-persistence/migrations/0005_run_taint_sources.sql create mode 100644 crates/agentos-runtime/tests/tool_manifest.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d280687..63ae0e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,6 +78,11 @@ jobs: - name: Type-check and build run: npm run build working-directory: apps/desktop + # The interface's pure logic — formatting, parsing, decisions about what an + # operator is shown — is tested in Node, without a browser or the runtime. + - name: Unit tests + run: npm test + working-directory: apps/desktop # The TypeScript types are generated from the Rust view models. If the two # have drifted, regenerating produces a diff — which means someone changed # a Rust type and did not commit the binding it implies. diff --git a/.gitignore b/.gitignore index 21dddef..d575c02 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ dist/ # Tauri's generated schemas and build artefacts apps/desktop/src-tauri/gen/ + +# Output of `npm run screenshots`, regenerated on demand from fixtures +apps/desktop/screenshots/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 39c3063..7547006 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,35 @@ it is technically a breaking change. ## [Unreleased] +### Security + +- **Taint follows provenance, not a flag.** A tool used to decide for itself whether its output + tainted the run, through `returns_untrusted_data`. That field is now catalogue metadata with no + effect on authorisation. A call taints the run when the bytes it returned came from outside the + trust boundary, or when its plan holds a capability that reads — whatever the tool says about its + own output. A failed read taints the run too, because its error text reaches the model. +- **Taint survives the run boundary.** A retry starts from every source an earlier attempt at the + same task recorded, and a memory written from an external source taints the next run before its + first tool call. Both used to start clean. When taint is why a person is being asked, the approval + reason now names where the data came from. +- **Origin rules bind every spelling, and a deny now covers every port.** Requests and policy + patterns are both reduced to a canonical origin: lower-case scheme and host, no default port. A + pattern without a port now means the default port only; write `:*` for any port. Previously a host + wildcard ran on into the port, so `https://*` admitted `https://evil.example:8443` past a deny of + `https://evil.example`. **A policy that relied on a wildcard reaching a non-default port must now + say `:*`.** IPv4 addresses written as IPv6 (`[::ffff:127.0.0.1]`) and the unspecified addresses + (`0.0.0.0`, `[::]`) are refused, because each reaches this machine under a rule that names neither. +- **Manifest drift is audited.** A tool that plans a capability its manifest does not declare is + recorded as `tool.manifest_exceeded`. The call is not failed — the policy engine evaluates the real + plan either way — but the drift is now in the audit chain rather than only in a log line. + +### Changed + +- The `schedule_fired` and `task_abandoned` event tags are now `schedule.fired` and + `agent.task.abandoned`, matching every other event and their own `kind`. Records written under the + old tags are still read. +- The scheduler abandons a whole dead dependency chain in one tick, rather than one layer per tick. + ## [0.2.0] ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 64d1bbc..cb57de6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,8 +46,11 @@ smell worth discussing. 2. Implement `metadata`, `validate`, `plan` and `execute`. 3. In `plan`, declare every capability the call needs — **including both ends of a transfer**. Reading a file you may read and writing it somewhere you may not is still exfiltration. -4. Set `returns_untrusted_data: true` if the output could be influenced by anyone other than the - operator. This is what drives taint escalation, and getting it wrong quietly removes a control. +4. Label every result with the `DataSource` its bytes came from, and list every capability `plan` + can require in the tool's manifest. Taint follows the label, and a plan that reads the outside + world taints the run whatever the label says; a plan that exceeds the manifest is written to + the audit log. `returns_untrusted_data` is catalogue information for `agentos tools` only and + has no effect on authorisation. 5. Register it in `standard_registry()`, or — if the tool family needs its own crate, the way the browser and computer layers do — in `agentos_runtime::build_registry`. There is one composition root on purpose: a second registry assembled by hand is how the catalogue and the runtime drift diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index 240fffb..eedc61a 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -18,7 +18,8 @@ "@types/react-dom": "^19.2.0", "@vitejs/plugin-react": "^5.0.0", "typescript": "^5.9.0", - "vite": "^7.0.0" + "vite": "^7.0.0", + "vitest": "4.1.11" } }, "node_modules/@babel/code-frame": { @@ -1211,6 +1212,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tauri-apps/api": { "version": "2.11.1", "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz", @@ -1498,6 +1506,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1546,6 +1572,129 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.11.19", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.19.tgz", @@ -1614,6 +1763,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1653,6 +1812,13 @@ "dev": true, "license": "ISC" }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", @@ -1705,6 +1871,26 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1791,6 +1977,16 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1827,6 +2023,27 @@ "node": ">=18" } }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1969,6 +2186,13 @@ "semver": "bin/semver.js" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1979,6 +2203,37 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -1996,6 +2251,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2116,6 +2381,113 @@ } } }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b8b92a3..d5f6b4b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "tsc --noEmit && vite build", "check": "tsc --noEmit", + "test": "vitest run --passWithNoTests", "preview": "vite preview", + "screenshots": "node scripts/screenshots.mjs", "tauri": "tauri" }, "dependencies": { @@ -21,7 +23,8 @@ "@types/react-dom": "^19.2.0", "@vitejs/plugin-react": "^5.0.0", "typescript": "^5.9.0", - "vite": "^7.0.0" + "vite": "^7.0.0", + "vitest": "4.1.11" }, "allowScripts": { "esbuild@0.28.2": true diff --git a/apps/desktop/scripts/screenshots.mjs b/apps/desktop/scripts/screenshots.mjs new file mode 100644 index 0000000..360f5e7 --- /dev/null +++ b/apps/desktop/scripts/screenshots.mjs @@ -0,0 +1,314 @@ +#!/usr/bin/env node +/** + * Capture one screenshot per screen of the desktop interface. + * + * The interface is served by a Vite dev server started in-process. Outside the + * Tauri window a development build answers every runtime command from fixtures + * (src/sdk/fixtures.ts), so the screens render their demo state without the + * application running. The pages are photographed by the system's own Chrome in + * headless mode: its `--screenshot` flag does the whole job, which keeps this + * script free of a browser-automation dependency and the hundred-odd megabytes + * of bundled Chromium that would come with one. + * + * Usage: npm run screenshots -- [--out ] [--routes dashboard,approvals] + * + * Output is written to apps/desktop/screenshots/ by default, which is ignored + * by git. Set CHROME_PATH to use a browser this script does not find itself. + */ + +import { spawn } from "node:child_process"; +import { accessSync, constants, mkdirSync, mkdtempSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; + +import { createServer } from "vite"; + +/** + * Every screen, in navigation order. Each is loaded as `/#/`. + * + * The app does not read the hash yet: until the hash router lands, every one of + * these renders the dashboard. The list is kept complete now so the captures + * start meaning something the moment routing does, with no change here. + */ +const ROUTES = ["dashboard", "approvals", "tasks", "agents", "activity", "settings", "schedules"]; + +/** Logical viewport; the device scale factor doubles it in the PNG. */ +const WINDOW_SIZE = "1280,860"; + +/** + * Milliseconds of virtual time Chrome grants the page before capturing. Virtual + * time pauses while requests are in flight, so this is time for React to render + * and fixture promises to settle, not a wall-clock wait on the dev server. + */ +const VIRTUAL_TIME_BUDGET = 5000; + +/** Wall-clock ceiling on a single capture, so a wedged browser cannot hang the run. */ +const CAPTURE_TIMEOUT_MS = 60_000; + +/** Grace given to a browser asked to quit before it is killed outright. */ +const KILL_GRACE_MS = 5000; + +const appDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +function fail(message) { + console.error(`screenshots: ${message}`); + process.exit(1); +} + +function parseOptions() { + let parsed; + try { + parsed = parseArgs({ + options: { + out: { type: "string" }, + routes: { type: "string" }, + }, + strict: true, + allowPositionals: false, + }); + } catch (error) { + fail(error.message); + } + const { out, routes } = parsed.values; + + let selected = ROUTES; + if (routes !== undefined) { + selected = routes + .split(",") + .map((route) => route.trim()) + .filter((route) => route.length > 0); + // A mistyped route would otherwise produce a confident screenshot of the + // dashboard under the wrong name. + const unknown = selected.filter((route) => !ROUTES.includes(route)); + if (unknown.length > 0) { + fail(`unknown route ${unknown.join(", ")}; known routes are ${ROUTES.join(", ")}`); + } + if (selected.length === 0) { + fail("--routes named no routes"); + } + } + + return { + outDir: out === undefined ? join(appDir, "screenshots") : resolve(out), + routes: selected, + }; +} + +function isExecutable(path) { + try { + accessSync(path, constants.X_OK); + return statSync(path).isFile(); + } catch { + return false; + } +} + +/** Find a Chrome or Chromium binary, preferring an explicit CHROME_PATH. */ +function findChrome() { + if (process.env.CHROME_PATH) { + if (isExecutable(process.env.CHROME_PATH)) { + return process.env.CHROME_PATH; + } + fail(`CHROME_PATH is set to ${process.env.CHROME_PATH}, which is not an executable file`); + } + + const candidates = []; + if (process.platform === "darwin") { + const bundles = [ + "Google Chrome.app/Contents/MacOS/Google Chrome", + "Chromium.app/Contents/MacOS/Chromium", + "Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", + ]; + for (const root of ["/Applications", join(process.env.HOME ?? "", "Applications")]) { + for (const bundle of bundles) { + candidates.push(join(root, bundle)); + } + } + } else if (process.platform === "win32") { + const roots = [ + process.env.PROGRAMFILES, + process.env["PROGRAMFILES(X86)"], + process.env.LOCALAPPDATA, + ].filter(Boolean); + for (const root of roots) { + candidates.push(join(root, "Google", "Chrome", "Application", "chrome.exe")); + candidates.push(join(root, "Chromium", "Application", "chrome.exe")); + } + } else { + // Linux distributions disagree on the name, and snaps and flatpaks put the + // binary wherever they like, so search PATH rather than fixed locations. + const names = ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser"]; + for (const dir of (process.env.PATH ?? "").split(delimiter).filter(Boolean)) { + for (const name of names) { + candidates.push(join(dir, name)); + } + } + } + + const found = candidates.find(isExecutable); + if (found === undefined) { + fail("no Chrome or Chromium found; install one or set CHROME_PATH to its binary"); + } + return found; +} + +/** The browser currently capturing, so an interrupted run can end it too. */ +let activeBrowser = null; + +function stopBrowser(child) { + if (child.exitCode !== null || child.signalCode !== null) return; + child.kill(); + setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + }, KILL_GRACE_MS).unref(); +} + +function fileSize(path) { + try { + return statSync(path).size; + } catch { + return 0; + } +} + +/** + * Photograph one URL into `file`. + * + * Headless Chrome writes the screenshot once the virtual-time budget is spent, + * but it does not reliably exit afterwards: on macOS it has been seen to linger + * for a minute or indefinitely with the page still open. So the written file, + * not the process exit, is the signal that the capture is done. The file is + * taken as complete once its size holds steady across two polls, and Chrome is + * then ended here rather than waited for. + */ +function capture(chrome, url, file, profileDir) { + const args = [ + "--headless=new", + `--screenshot=${file}`, + `--window-size=${WINDOW_SIZE}`, + "--hide-scrollbars", + "--force-device-scale-factor=2", + `--virtual-time-budget=${VIRTUAL_TIME_BUDGET}`, + // A throwaway profile keeps the capture away from the user's own browser + // state, and lets it run while their Chrome is open. + `--user-data-dir=${profileDir}`, + "--no-first-run", + "--no-default-browser-check", + "--disable-extensions", + url, + ]; + + return new Promise((resolvePromise, reject) => { + const child = spawn(chrome, args, { stdio: ["ignore", "ignore", "pipe"] }); + activeBrowser = child; + let stderr = ""; + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + + let outcome = null; + let lastSize = 0; + const started = Date.now(); + + const finish = (error) => { + if (outcome !== null) return; + outcome = error ?? "done"; + clearInterval(poll); + stopBrowser(child); + }; + + const poll = setInterval(() => { + const size = fileSize(file); + if (size > 0 && size === lastSize) { + finish(); + } else if (Date.now() - started > CAPTURE_TIMEOUT_MS) { + finish(new Error(`Chrome did not capture ${url} within ${CAPTURE_TIMEOUT_MS / 1000}s`)); + } + lastSize = size; + }, 250); + + child.on("error", (error) => { + finish(error); + // A binary that never started emits no exit event to settle on. + if (child.pid === undefined) reject(error); + }); + // Settle only once the process is gone, so the next capture never starts + // while this browser still holds the port or the profile. + child.on("exit", () => { + activeBrowser = null; + if (outcome === null) { + // Chrome left on its own; whatever it wrote is all there will be. + finish( + fileSize(file) > 0 + ? undefined + : new Error(`Chrome exited without writing ${file}\n${stderr}`), + ); + } + if (outcome === "done") { + resolvePromise(); + } else { + reject(outcome); + } + }); + }); +} + +async function main() { + const { outDir, routes } = parseOptions(); + // Look for the browser before starting anything that would need tearing down. + const chrome = findChrome(); + mkdirSync(outDir, { recursive: true }); + + const server = await createServer({ + root: appDir, + configFile: join(appDir, "vite.config.ts"), + logLevel: "warn", + // Port 0 takes any free port. The config's fixed port exists for Tauri, and + // holding to it here would fail whenever `npm run dev` is already running. + server: { port: 0, strictPort: false, hmr: false }, + }); + const profileRoot = mkdtempSync(join(tmpdir(), "agentos-screenshots-")); + + // Vite installs its own SIGTERM handler, which closes the server and calls + // process.exit before any cleanup of ours could be awaited. The browser and + // the scratch profiles are therefore released from the exit hook, which runs + // synchronously however the process ends. + process.once("exit", () => { + if (activeBrowser !== null) stopBrowser(activeBrowser); + rmSync(profileRoot, { recursive: true, force: true }); + }); + for (const [signal, number] of [ + ["SIGINT", 2], + ["SIGTERM", 15], + ]) { + process.once(signal, () => { + void server.close().finally(() => process.exit(128 + number)); + }); + } + + try { + await server.listen(); + const base = server.resolvedUrls?.local[0]; + if (base === undefined) { + throw new Error("the dev server did not report a local URL"); + } + + for (const route of routes) { + const file = join(outDir, `${route}.png`); + rmSync(file, { force: true }); + // A profile per capture: a browser that is slow to release its profile + // lock must not turn the next launch into a hand-off to itself. + await capture(chrome, `${base}#/${route}`, file, join(profileRoot, route)); + console.log(file); + } + } finally { + await server.close(); + } +} + +main().catch((error) => { + console.error(`screenshots: ${error.message}`); + process.exit(1); +}); diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs deleted file mode 100644 index 1bcf1c8..0000000 --- a/apps/desktop/src-tauri/src/commands.rs +++ /dev/null @@ -1,800 +0,0 @@ -//! The command surface the interface calls. -//! -//! Every function here is a thin translation: parse an identifier, call the -//! runtime, shape the answer into a view model. There is no agent behaviour in -//! this file — the desktop application is a client of `agentos-runtime`, on -//! equal footing with the CLI, and the moment logic starts accumulating here the -//! two clients have begun to disagree. - -use std::sync::Arc; - -use agentos_core::agent::{AgentStatus, ModelConfig}; -use agentos_core::approval::ApprovalRequest; -use agentos_core::ids::{AgentId, ApprovalId, TaskId, TaskRunId}; -use agentos_providers::provider_ids; -use agentos_runtime::Runtime; -use agentos_secrets::{ - ChainSecretStore, EnvSecretStore, KeychainStatus, KeyringStore, SecretStore, provider_key, -}; -use agentos_tools::{ApprovalGate, ApprovalOutcome}; -use tauri::{AppHandle, State}; -use tokio_util::sync::CancellationToken; - -use crate::dto::{ - AgentDetail, AgentSummary, ApprovalDecisionInput, ApprovalView, CreateAgentInput, - DashboardView, EventView, ExecutionView, PolicyCheck, PolicyView, ProviderView, RunSummary, - SettingsView, StartedTask, StepView, TaskSummary, ToolView, TraceView, summarise_event, - task_summary, -}; -use crate::state::{AppState, DesktopApprovalGate}; - -/// Anything a command can fail with. -/// -/// Serialised as a plain message: the interface shows it to a person, and a -/// structured error would only be reconstructed into one anyway. -#[derive(Debug, thiserror::Error)] -pub enum DesktopError { - /// The runtime refused or failed. - #[error(transparent)] - Runtime(#[from] agentos_runtime::RuntimeError), - - /// Storage failed. - #[error(transparent)] - Database(#[from] agentos_persistence::DbError), - - /// An identifier from the interface was not a valid one. - #[error("`{value}` is not a valid {kind} identifier")] - BadId { - /// What kind was expected. - kind: &'static str, - /// What arrived. - value: String, - }, - - /// Secret storage failed. - #[error(transparent)] - Secrets(#[from] agentos_secrets::SecretError), - - /// The request could not be satisfied. - #[error("{0}")] - Rejected(String), -} - -impl serde::Serialize for DesktopError { - fn serialize(&self, serializer: S) -> Result { - serializer.serialize_str(&self.to_string()) - } -} - -/// Shorthand for a command result. -pub type Answer = Result; - -fn parse_id(kind: &'static str, value: &str) -> Answer { - value.parse::().map_err(|_| DesktopError::BadId { - kind, - value: value.to_owned(), - }) -} - -/// Compile a stored policy document into the view the interface shows. -fn policy_view(document: String, version: i64) -> PolicyView { - let compiled = agentos_permissions::PolicyDocument::from_yaml(&document) - .and_then(|parsed| parsed.compile()); - - match compiled { - Ok(policy) => PolicyView { - document, - version, - default_effect: policy.default_effect.as_str().to_owned(), - max_risk: policy.max_risk.map(|risk| risk.as_str().to_owned()), - taint_enabled: policy.taint.enabled, - taint_threshold: policy.taint.escalate_at_or_above.as_str().to_owned(), - rules: policy - .rules - .iter() - .map(agentos_permissions::PolicyRule::describe) - .collect(), - }, - // A stored policy that no longer compiles is shown as it is, with the - // failure visible. The runtime denies everything in that state, and the - // interface must not imply otherwise. - Err(error) => PolicyView { - document, - version, - default_effect: "deny".to_owned(), - max_risk: None, - taint_enabled: true, - taint_threshold: "medium".to_owned(), - rules: vec![format!("this policy does not compile: {error}")], - }, - } -} - -/// Turn approval requests into views, attaching the objective each belongs to. -async fn approval_views( - runtime: &Runtime, - requests: Vec, -) -> Answer> { - let mut views = Vec::with_capacity(requests.len()); - for request in requests { - let objective = runtime - .database() - .tasks() - .find(request.task_id) - .await? - .map(|task| task.objective) - .unwrap_or_default(); - views.push(ApprovalView::new(&request, objective)); - } - Ok(views) -} - -/// Load recent tasks with their agent names and latest runs. -async fn task_summaries(runtime: &Runtime, limit: i64) -> Answer> { - let tasks = runtime.database().tasks().list(limit).await?; - let mut out = Vec::with_capacity(tasks.len()); - for task in tasks { - let name = runtime - .database() - .agents() - .find(task.agent_id) - .await? - .map(|agent| agent.name) - .unwrap_or_else(|| "(deleted)".to_owned()); - let run = runtime.database().runs().latest_for_task(task.id).await?; - out.push(task_summary(&task, &name, run.as_ref())); - } - Ok(out) -} - -/// Recent audit events, newest last so the feed reads downward. -async fn recent_events(runtime: &Runtime, limit: i64) -> Answer> { - let mut records = runtime.database().audit_sink().tail(limit).await?; - records.reverse(); - Ok(records - .into_iter() - .map(|record| EventView { - id: record.id.to_string(), - sequence: Some(record.sequence), - at: agentos_core::format_timestamp(&record.at), - kind: record.kind.clone(), - run_id: record.run_id.map(|id| id.to_string()), - task_id: record.task_id.map(|id| id.to_string()), - summary: summarise_event(&record.payload), - security_relevant: SECURITY_KINDS.contains(&record.kind.as_str()), - }) - .collect()) -} - -/// Event kinds that record a refusal, an escalation or a rejection. -const SECURITY_KINDS: &[&str] = &[ - "permission.denied", - "permission.escalated_by_taint", - "approval.denied", - "tool.arguments.rejected", - "tool.unknown", - "agent.taint.raised", -]; - -// --------------------------------------------------------------------------- -// Dashboard -// --------------------------------------------------------------------------- - -/// Everything the dashboard shows. -#[tauri::command] -pub async fn dashboard(state: State<'_, AppState>) -> Answer { - let runtime = &state.runtime; - let agents = runtime.database().agents().list().await?; - - let active = runtime.database().tasks().list_active().await?; - let mut running_tasks = Vec::with_capacity(active.len()); - for task in active { - let name = runtime - .database() - .agents() - .find(task.agent_id) - .await? - .map(|agent| agent.name) - .unwrap_or_else(|| "(deleted)".to_owned()); - let run = runtime.database().runs().latest_for_task(task.id).await?; - running_tasks.push(task_summary(&task, &name, run.as_ref())); - } - - let pending = runtime.database().approvals().list_pending().await?; - let verification = runtime.verify_audit().await?; - - Ok(DashboardView { - agents: agents.iter().map(AgentSummary::from).collect(), - running_tasks, - pending_approvals: approval_views(runtime, pending).await?, - recent_events: recent_events(runtime, 40).await?, - recent_refusals: runtime - .database() - .executions() - .list_denied(10) - .await? - .iter() - .map(ExecutionView::from) - .collect(), - audit_events: runtime.database().audit_sink().count().await?, - audit_intact: verification.is_intact(), - }) -} - -// --------------------------------------------------------------------------- -// Agents -// --------------------------------------------------------------------------- - -/// Every configured agent. -#[tauri::command] -pub async fn list_agents(state: State<'_, AppState>) -> Answer> { - Ok(state - .runtime - .database() - .agents() - .list() - .await? - .iter() - .map(AgentSummary::from) - .collect()) -} - -/// One agent, with its policy and recent work. -#[tauri::command] -pub async fn get_agent(state: State<'_, AppState>, name: String) -> Answer { - let runtime = &state.runtime; - let agent = runtime.agent_by_name(&name).await?; - let policy = runtime - .database() - .agents() - .policy(agent.id) - .await? - .map(|stored| policy_view(stored.document, stored.version)); - - let tasks = runtime - .database() - .tasks() - .list_for_agent(agent.id, 20) - .await?; - let mut recent_tasks = Vec::with_capacity(tasks.len()); - for task in tasks { - let run = runtime.database().runs().latest_for_task(task.id).await?; - recent_tasks.push(task_summary(&task, &agent.name, run.as_ref())); - } - - Ok(AgentDetail { - summary: AgentSummary::from(&agent), - instructions: agent.instructions.clone(), - policy, - recent_tasks, - workspace: runtime - .config() - .workspace_for(&agent.name) - .display() - .to_string(), - }) -} - -/// Create an agent with a deny-by-default starter policy. -#[tauri::command] -pub async fn create_agent( - state: State<'_, AppState>, - input: CreateAgentInput, -) -> Answer { - let runtime = &state.runtime; - - // A tool the runtime does not have is a mistake worth catching here rather - // than at the moment an agent tries to use it. - let known = runtime.registry().names(); - for tool in &input.tools { - if !known.contains(tool) { - return Err(DesktopError::Rejected(format!("unknown tool `{tool}`"))); - } - } - - let mut model = ModelConfig::new(&input.provider, &input.model); - model.base_url = input.base_url.filter(|url| !url.trim().is_empty()); - model.vision = input.vision; - - let agent = runtime - .create_agent(&input.name, &input.instructions, model, input.tools) - .await?; - Ok(AgentSummary::from(&agent)) -} - -/// Enable or disable an agent. -#[tauri::command] -pub async fn set_agent_enabled( - state: State<'_, AppState>, - name: String, - enabled: bool, -) -> Answer { - let runtime = &state.runtime; - let mut agent = runtime.agent_by_name(&name).await?; - agent.status = if enabled { - AgentStatus::Enabled - } else { - AgentStatus::Disabled - }; - runtime.database().agents().update(&agent).await?; - Ok(AgentSummary::from(&agent)) -} - -// --------------------------------------------------------------------------- -// Policies -// --------------------------------------------------------------------------- - -/// Check a policy document without installing it. -#[tauri::command] -pub async fn check_policy(document: String) -> Answer { - match agentos_permissions::PolicyDocument::from_yaml(&document) - .and_then(|parsed| parsed.compile()) - { - Ok(_) => Ok(PolicyCheck { - valid: true, - error: None, - summary: Some(policy_view(document, 0)), - }), - Err(error) => Ok(PolicyCheck { - valid: false, - error: Some(error.to_string()), - summary: None, - }), - } -} - -/// Install a policy for an agent. -/// -/// Refuses a document that does not compile. A policy that fails to load makes -/// the runtime deny everything, which is safe but bewildering; better to say so -/// at the moment of saving. -#[tauri::command] -pub async fn set_policy( - state: State<'_, AppState>, - agent_id: String, - document: String, -) -> Answer { - let id: AgentId = parse_id("agent", &agent_id)?; - agentos_permissions::PolicyDocument::from_yaml(&document) - .and_then(|parsed| parsed.compile()) - .map_err(|error| { - DesktopError::Rejected(format!("this policy does not compile: {error}")) - })?; - - let version = state - .runtime - .database() - .agents() - .set_policy(id, &document) - .await?; - Ok(policy_view(document, version)) -} - -// --------------------------------------------------------------------------- -// Tasks -// --------------------------------------------------------------------------- - -/// Recent tasks. -#[tauri::command] -pub async fn list_tasks( - state: State<'_, AppState>, - limit: Option, -) -> Answer> { - task_summaries(&state.runtime, limit.unwrap_or(50)).await -} - -/// Start a task and return immediately. -/// -/// The run proceeds in the background; the interface follows it through the -/// activity stream and the trace. -#[tauri::command] -pub async fn start_task( - app: AppHandle, - state: State<'_, AppState>, - agent_id: String, - objective: String, -) -> Answer { - let id: AgentId = parse_id("agent", &agent_id)?; - if objective.trim().is_empty() { - return Err(DesktopError::Rejected( - "an objective is required".to_owned(), - )); - } - - let runtime = &state.runtime; - let task = runtime.create_task(id, &objective).await?; - - let gate: Arc = Arc::new(DesktopApprovalGate::new( - app, - state.approvals.clone(), - objective, - )); - let (run_id, _handle) = runtime - .start_task(&task, gate, CancellationToken::new()) - .await?; - - Ok(StartedTask { - task_id: task.id.to_string(), - run_id: run_id.to_string(), - }) -} - -/// Stop a run that is currently executing. -#[tauri::command] -pub async fn cancel_run(state: State<'_, AppState>, run_id: String) -> Answer { - let id: TaskRunId = parse_id("run", &run_id)?; - Ok(state.runtime.cancel_run(id).await) -} - -/// The full trace of one run. -#[tauri::command] -pub async fn get_trace(state: State<'_, AppState>, run_id: String) -> Answer { - let id: TaskRunId = parse_id("run", &run_id)?; - let trace = state.runtime.trace(id).await?; - Ok(TraceView { - run: RunSummary::from(&trace.run), - task_id: trace.run.task_id.to_string(), - agent_name: trace.agent_name.clone(), - objective: trace.objective.clone(), - steps: trace.steps.iter().map(StepView::from).collect(), - executions: trace.executions.iter().map(ExecutionView::from).collect(), - approvals: trace - .approvals - .iter() - .map(|request| ApprovalView::new(request, trace.objective.clone())) - .collect(), - }) -} - -/// The trace of a task's most recent run. -#[tauri::command] -pub async fn get_task_trace(state: State<'_, AppState>, task_id: String) -> Answer { - let id: TaskId = parse_id("task", &task_id)?; - let run = state - .runtime - .database() - .runs() - .latest_for_task(id) - .await? - .ok_or_else(|| DesktopError::Rejected("this task has never been run".to_owned()))?; - get_trace(state, run.id.to_string()).await -} - -// --------------------------------------------------------------------------- -// Approvals -// --------------------------------------------------------------------------- - -/// Approvals waiting on a human. -#[tauri::command] -pub async fn list_pending_approvals(state: State<'_, AppState>) -> Answer> { - let pending = state.runtime.database().approvals().list_pending().await?; - approval_views(&state.runtime, pending).await -} - -/// Answer an approval request. -#[tauri::command] -pub async fn resolve_approval( - state: State<'_, AppState>, - input: ApprovalDecisionInput, -) -> Answer { - let id: ApprovalId = parse_id("approval", &input.approval_id)?; - let outcome = if input.approved { - ApprovalOutcome::Approved - } else { - ApprovalOutcome::Denied { - note: input.note.filter(|note| !note.trim().is_empty()), - } - }; - Ok(state.approvals.resolve(id, outcome).await) -} - -// --------------------------------------------------------------------------- -// Activity, tools and settings -// --------------------------------------------------------------------------- - -/// Recent audit events. -#[tauri::command] -pub async fn activity(state: State<'_, AppState>, limit: Option) -> Answer> { - recent_events(&state.runtime, limit.unwrap_or(200)).await -} - -/// Verify the audit chain. -#[tauri::command] -pub async fn verify_audit(state: State<'_, AppState>) -> Answer> { - let verification = state.runtime.verify_audit().await?; - Ok(verification - .breaks - .iter() - .map(ToString::to_string) - .collect()) -} - -/// Every tool an agent can be granted. -#[tauri::command] -pub async fn list_tools(state: State<'_, AppState>) -> Answer> { - Ok(state - .runtime - .registry() - .all_metadata() - .iter() - .map(ToolView::from) - .collect()) -} - -/// The settings screen. -#[tauri::command] -pub async fn settings(state: State<'_, AppState>) -> Answer { - let runtime = &state.runtime; - let config = runtime.config(); - let keychain = KeyringStore::status(); - let secrets = ChainSecretStore::standard(); - - let providers = provider_ids::ALL - .iter() - .map(|id| { - let located = secrets.locate(&provider_key(id)); - ProviderView { - id: (*id).to_owned(), - configured: located.is_some(), - hint: located.as_ref().map(|(_, secret)| secret.hint()), - source: located.as_ref().map(|(store, _)| (*store).to_owned()), - note: match *id { - provider_ids::OLLAMA => "local; usually needs no key".to_owned(), - provider_ids::MOCK => "built in; no key needed".to_owned(), - _ => EnvSecretStore::variables_for(&provider_key(id)) - .last() - .map(|name| format!("or set {name} in the environment")) - .unwrap_or_default(), - }, - } - }) - .collect(); - - let browser = agentos_browser::locate(None); - Ok(SettingsView { - data_dir: config.data_dir.display().to_string(), - workspace: config.workspace.display().to_string(), - database: config.database_path.display().to_string(), - keychain_available: keychain.is_available(), - keychain_reason: match &keychain { - KeychainStatus::Available => None, - KeychainStatus::Unavailable { reason } => { - Some(reason.lines().next().unwrap_or(reason).to_owned()) - } - }, - providers, - browser_path: browser.as_ref().map(|path| path.display().to_string()), - browser_hint: browser.is_none().then(agentos_browser::install_hint), - tools: runtime - .registry() - .all_metadata() - .iter() - .map(ToolView::from) - .collect(), - }) -} - -/// Store a provider credential in the operating system keychain. -/// -/// Refuses when there is no keychain rather than pretending to succeed: on such -/// a machine the credential belongs in the environment, and saying so is more -/// use than a generic failure. -#[tauri::command] -pub async fn set_provider_key(provider: String, key: String) -> Answer<()> { - if let KeychainStatus::Unavailable { reason } = KeyringStore::status() { - let variable = EnvSecretStore::variables_for(&provider_key(&provider)) - .last() - .cloned() - .unwrap_or_default(); - return Err(DesktopError::Rejected(format!( - "this machine has no usable keychain ({}), so there is nowhere secure to store the \ - key. Set {variable} in the environment instead.", - reason.lines().next().unwrap_or(&reason) - ))); - } - - let key = key.trim(); - if key.is_empty() { - return Err(DesktopError::Rejected("no key was provided".to_owned())); - } - KeyringStore::new().set(&provider_key(&provider), key)?; - Ok(()) -} - -/// Remove a stored provider credential. -#[tauri::command] -pub async fn remove_provider_key(provider: String) -> Answer<()> { - KeyringStore::new().delete(&provider_key(&provider))?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use agentos_core::approval::ApprovalStatus; - use agentos_core::ids::ApprovalId; - use agentos_core::permission::Capability; - use agentos_core::risk::RiskLevel; - use agentos_core::task::{Task, TaskRun}; - use agentos_secrets::InMemorySecretStore; - - use super::*; - - /// A runtime with one agent, backed by a temporary directory. - /// - /// The `State` wrappers above are one-line delegations; what is worth - /// testing is the shaping underneath them, which is where a screen would - /// silently get the wrong answer. - async fn runtime_with_agent() -> (tempfile::TempDir, Runtime, agentos_core::agent::Agent) { - let guard = tempfile::TempDir::new().expect("temp dir"); - let root = std::fs::canonicalize(guard.path()).expect("canonical"); - let runtime = Runtime::in_memory(root, Arc::new(InMemorySecretStore::new())) - .await - .expect("runtime"); - let agent = runtime - .create_agent( - "sales", - "Handle follow-ups.", - ModelConfig::new("mock", "scripted"), - vec!["filesystem.read".to_owned()], - ) - .await - .expect("agent"); - (guard, runtime, agent) - } - - #[tokio::test] - async fn a_policy_view_summarises_what_it_grants() { - let view = policy_view( - "default: deny\npermissions:\n browser:\n navigate: ['http://localhost:*']\n" - .to_owned(), - 3, - ); - assert_eq!(view.default_effect, "deny"); - assert_eq!(view.version, 3); - assert!(view.taint_enabled, "taint escalation is on by default"); - assert_eq!(view.rules.len(), 1); - assert!(view.rules[0].contains("browser.navigate")); - } - - #[test] - fn a_policy_that_does_not_compile_says_so_rather_than_implying_permissions() { - // The runtime denies everything in this state. An interface that showed - // an empty rule list would imply "nothing configured" instead of - // "broken", which are very different things to an operator. - let view = policy_view("permisions: {}\n".to_owned(), 1); - assert_eq!(view.default_effect, "deny"); - assert_eq!(view.rules.len(), 1); - assert!( - view.rules[0].contains("does not compile"), - "{:?}", - view.rules - ); - } - - #[tokio::test] - async fn task_summaries_carry_the_agent_name_and_latest_run() { - let (_guard, runtime, agent) = runtime_with_agent().await; - let task = runtime - .create_task(agent.id, "Do the thing.") - .await - .unwrap(); - let run = TaskRun::new(task.id, 1); - runtime.database().runs().insert(&run).await.unwrap(); - - let summaries = task_summaries(&runtime, 10).await.unwrap(); - assert_eq!(summaries.len(), 1); - assert_eq!(summaries[0].agent_name, "sales"); - assert_eq!(summaries[0].objective, "Do the thing."); - assert_eq!( - summaries[0].latest_run.as_ref().map(|run| run.attempt), - Some(1) - ); - } - - #[tokio::test] - async fn a_task_whose_agent_was_deleted_still_renders() { - // Agents cascade-delete their tasks, so this is defensive rather than - // reachable today — but a list that panics is worse than one that says - // "(deleted)". - let (_guard, runtime, agent) = runtime_with_agent().await; - let orphan = Task::new(agentos_core::ids::AgentId::new(), "orphaned"); - let _ = agent; - // Inserting with an unknown agent is refused by the foreign key, which - // is the real guarantee; assert that rather than faking a broken row. - assert!(runtime.database().tasks().insert(&orphan).await.is_err()); - } - - #[tokio::test] - async fn approval_views_attach_the_objective_being_pursued() { - let (_guard, runtime, agent) = runtime_with_agent().await; - let task = runtime - .create_task(agent.id, "Find overdue accounts.") - .await - .unwrap(); - let run = TaskRun::new(task.id, 1); - runtime.database().runs().insert(&run).await.unwrap(); - - let request = ApprovalRequest { - id: ApprovalId::new(), - agent_id: agent.id, - agent_name: agent.name.clone(), - task_id: task.id, - run_id: run.id, - tool: "browser.type".to_owned(), - arguments: serde_json::json!({"selector": "#send"}), - capability: Capability::new("browser", "interact"), - risk: RiskLevel::High, - reason: "policy requires approval".to_owned(), - explanation: "Submit the form.".to_owned(), - affected_resources: vec!["origin:http://localhost:8420".to_owned()], - tainted: true, - taint_sources: vec!["web:http://localhost:8420/customers".to_owned()], - status: ApprovalStatus::Pending, - requested_at: agentos_core::now(), - decided_at: None, - decision_note: None, - }; - runtime - .database() - .approvals() - .insert(&request) - .await - .unwrap(); - - let views = approval_views(&runtime, vec![request]).await.unwrap(); - assert_eq!(views.len(), 1); - assert_eq!(views[0].objective, "Find overdue accounts."); - assert!(views[0].tainted); - assert_eq!(views[0].taint_sources.len(), 1); - // The arguments are pretty-printed for a person to read. - assert!(views[0].arguments.contains("\n")); - } - - #[tokio::test] - async fn recent_events_are_oldest_first_and_flag_refusals() { - let (_guard, runtime, agent) = runtime_with_agent().await; - - runtime - .audit() - .record(agentos_core::event::Event::new( - agentos_core::event::AgentEvent::TaskStarted { - objective: "first".to_owned(), - attempt: 1, - }, - )) - .await - .unwrap(); - runtime - .audit() - .record(agentos_core::event::Event::new( - agentos_core::event::AgentEvent::PermissionDenied { - tool: "terminal.exec".to_owned(), - capability: Capability::new("terminal", "exec"), - reason: "no rule matched".to_owned(), - matched_rule: None, - }, - )) - .await - .unwrap(); - let _ = agent; - - let events = recent_events(&runtime, 10).await.unwrap(); - assert!(events.len() >= 2); - - let first = events.iter().position(|e| e.kind == "agent.task.started"); - let denial = events.iter().position(|e| e.kind == "permission.denied"); - assert!(first < denial, "the feed should read oldest to newest"); - - let denial = &events[denial.expect("a denial was recorded")]; - assert!(denial.security_relevant); - assert_eq!(denial.summary, "terminal.exec"); - } - - #[tokio::test] - async fn a_bad_identifier_is_rejected_with_the_kind_it_expected() { - let error = parse_id::("agent", "not-a-uuid").unwrap_err(); - assert!(error.to_string().contains("agent identifier"), "{error}"); - } -} diff --git a/apps/desktop/src-tauri/src/commands/activity.rs b/apps/desktop/src-tauri/src/commands/activity.rs new file mode 100644 index 0000000..f5b66c5 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/activity.rs @@ -0,0 +1,24 @@ +//! The activity feed and audit verification. + +use tauri::State; + +use super::{Answer, recent_events}; +use crate::dto::EventView; +use crate::state::AppState; + +/// Recent audit events. +#[tauri::command] +pub async fn activity(state: State<'_, AppState>, limit: Option) -> Answer> { + recent_events(&state.runtime, limit.unwrap_or(200)).await +} + +/// Verify the audit chain. +#[tauri::command] +pub async fn verify_audit(state: State<'_, AppState>) -> Answer> { + let verification = state.runtime.verify_audit().await?; + Ok(verification + .breaks + .iter() + .map(ToString::to_string) + .collect()) +} diff --git a/apps/desktop/src-tauri/src/commands/agents.rs b/apps/desktop/src-tauri/src/commands/agents.rs new file mode 100644 index 0000000..e167139 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/agents.rs @@ -0,0 +1,103 @@ +//! Listing, inspecting, creating and enabling agents. + +use agentos_core::agent::{AgentStatus, ModelConfig}; +use tauri::State; + +use super::{Answer, DesktopError, policy_view}; +use crate::dto::{AgentDetail, AgentSummary, CreateAgentInput, task_summary}; +use crate::state::AppState; + +/// Every configured agent. +#[tauri::command] +pub async fn list_agents(state: State<'_, AppState>) -> Answer> { + Ok(state + .runtime + .database() + .agents() + .list() + .await? + .iter() + .map(AgentSummary::from) + .collect()) +} + +/// One agent, with its policy and recent work. +#[tauri::command] +pub async fn get_agent(state: State<'_, AppState>, name: String) -> Answer { + let runtime = &state.runtime; + let agent = runtime.agent_by_name(&name).await?; + let policy = runtime + .database() + .agents() + .policy(agent.id) + .await? + .map(|stored| policy_view(stored.document, stored.version)); + + let tasks = runtime + .database() + .tasks() + .list_for_agent(agent.id, 20) + .await?; + let mut recent_tasks = Vec::with_capacity(tasks.len()); + for task in tasks { + let run = runtime.database().runs().latest_for_task(task.id).await?; + recent_tasks.push(task_summary(&task, &agent.name, run.as_ref())); + } + + Ok(AgentDetail { + summary: AgentSummary::from(&agent), + instructions: agent.instructions.clone(), + policy, + recent_tasks, + workspace: runtime + .config() + .workspace_for(&agent.name) + .display() + .to_string(), + }) +} + +/// Create an agent with a deny-by-default starter policy. +#[tauri::command] +pub async fn create_agent( + state: State<'_, AppState>, + input: CreateAgentInput, +) -> Answer { + let runtime = &state.runtime; + + // A tool the runtime does not have is a mistake worth catching here rather + // than at the moment an agent tries to use it. + let known = runtime.registry().names(); + for tool in &input.tools { + if !known.contains(tool) { + return Err(DesktopError::Rejected(format!("unknown tool `{tool}`"))); + } + } + + let mut model = ModelConfig::new(&input.provider, &input.model); + model.base_url = input.base_url.filter(|url| !url.trim().is_empty()); + model.vision = input.vision; + + let agent = runtime + .create_agent(&input.name, &input.instructions, model, input.tools) + .await?; + Ok(AgentSummary::from(&agent)) +} + +/// Enable or disable an agent. +#[tauri::command] +pub async fn set_agent_enabled( + state: State<'_, AppState>, + name: String, + enabled: bool, +) -> Answer { + let runtime = &state.runtime; + let mut agent = runtime.agent_by_name(&name).await?; + agent.status = if enabled { + AgentStatus::Enabled + } else { + AgentStatus::Disabled + }; + runtime.database().agents().update(&agent).await?; + Ok(AgentSummary::from(&agent)) +} diff --git a/apps/desktop/src-tauri/src/commands/approvals.rs b/apps/desktop/src-tauri/src/commands/approvals.rs new file mode 100644 index 0000000..de85ea9 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/approvals.rs @@ -0,0 +1,33 @@ +//! Listing and answering approval requests. + +use agentos_core::ids::ApprovalId; +use agentos_tools::ApprovalOutcome; +use tauri::State; + +use super::{Answer, approval_views, parse_id}; +use crate::dto::{ApprovalDecisionInput, ApprovalView}; +use crate::state::AppState; + +/// Approvals waiting on a human. +#[tauri::command] +pub async fn list_pending_approvals(state: State<'_, AppState>) -> Answer> { + let pending = state.runtime.database().approvals().list_pending().await?; + approval_views(&state.runtime, pending).await +} + +/// Answer an approval request. +#[tauri::command] +pub async fn resolve_approval( + state: State<'_, AppState>, + input: ApprovalDecisionInput, +) -> Answer { + let id: ApprovalId = parse_id("approval", &input.approval_id)?; + let outcome = if input.approved { + ApprovalOutcome::Approved + } else { + ApprovalOutcome::Denied { + note: input.note.filter(|note| !note.trim().is_empty()), + } + }; + Ok(state.approvals.resolve(id, outcome).await) +} diff --git a/apps/desktop/src-tauri/src/commands/mod.rs b/apps/desktop/src-tauri/src/commands/mod.rs new file mode 100644 index 0000000..912f671 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/mod.rs @@ -0,0 +1,406 @@ +//! The command surface the interface calls. +//! +//! Every function here is a thin translation: parse an identifier, call the +//! runtime, shape the answer into a view model. There is no agent behaviour in +//! this module — the desktop application is a client of `agentos-runtime`, on +//! equal footing with the CLI, and the moment logic starts accumulating here the +//! two clients have begun to disagree. + +use agentos_core::approval::ApprovalRequest; +use agentos_runtime::Runtime; +use tauri::State; + +use crate::dto::{ + AgentSummary, ApprovalView, DashboardView, EventView, ExecutionView, PolicyView, TaskSummary, + summarise_event, task_summary, +}; +use crate::state::AppState; + +mod activity; +mod agents; +mod approvals; +mod policies; +mod settings; +mod tasks; + +pub use activity::*; +pub use agents::*; +pub use approvals::*; +pub use policies::*; +pub use settings::*; +pub use tasks::*; + +/// Anything a command can fail with. +/// +/// Serialised as a plain message: the interface shows it to a person, and a +/// structured error would only be reconstructed into one anyway. +#[derive(Debug, thiserror::Error)] +pub enum DesktopError { + /// The runtime refused or failed. + #[error(transparent)] + Runtime(#[from] agentos_runtime::RuntimeError), + + /// Storage failed. + #[error(transparent)] + Database(#[from] agentos_persistence::DbError), + + /// An identifier from the interface was not a valid one. + #[error("`{value}` is not a valid {kind} identifier")] + BadId { + /// What kind was expected. + kind: &'static str, + /// What arrived. + value: String, + }, + + /// Secret storage failed. + #[error(transparent)] + Secrets(#[from] agentos_secrets::SecretError), + + /// The request could not be satisfied. + #[error("{0}")] + Rejected(String), +} + +impl serde::Serialize for DesktopError { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.to_string()) + } +} + +/// Shorthand for a command result. +pub type Answer = Result; + +pub(crate) fn parse_id(kind: &'static str, value: &str) -> Answer { + value.parse::().map_err(|_| DesktopError::BadId { + kind, + value: value.to_owned(), + }) +} + +/// Compile a stored policy document into the view the interface shows. +pub(crate) fn policy_view(document: String, version: i64) -> PolicyView { + let compiled = agentos_permissions::PolicyDocument::from_yaml(&document) + .and_then(|parsed| parsed.compile()); + + match compiled { + Ok(policy) => PolicyView { + document, + version, + default_effect: policy.default_effect.as_str().to_owned(), + max_risk: policy.max_risk.map(|risk| risk.as_str().to_owned()), + taint_enabled: policy.taint.enabled, + taint_threshold: policy.taint.escalate_at_or_above.as_str().to_owned(), + rules: policy + .rules + .iter() + .map(agentos_permissions::PolicyRule::describe) + .collect(), + }, + // A stored policy that no longer compiles is shown as it is, with the + // failure visible. The runtime denies everything in that state, and the + // interface must not imply otherwise. + Err(error) => PolicyView { + document, + version, + default_effect: "deny".to_owned(), + max_risk: None, + taint_enabled: true, + taint_threshold: "medium".to_owned(), + rules: vec![format!("this policy does not compile: {error}")], + }, + } +} + +/// Turn approval requests into views, attaching the objective each belongs to. +pub(crate) async fn approval_views( + runtime: &Runtime, + requests: Vec, +) -> Answer> { + let mut views = Vec::with_capacity(requests.len()); + for request in requests { + let objective = runtime + .database() + .tasks() + .find(request.task_id) + .await? + .map(|task| task.objective) + .unwrap_or_default(); + views.push(ApprovalView::new(&request, objective)); + } + Ok(views) +} + +/// Load recent tasks with their agent names and latest runs. +pub(crate) async fn task_summaries(runtime: &Runtime, limit: i64) -> Answer> { + let tasks = runtime.database().tasks().list(limit).await?; + let mut out = Vec::with_capacity(tasks.len()); + for task in tasks { + let name = runtime + .database() + .agents() + .find(task.agent_id) + .await? + .map(|agent| agent.name) + .unwrap_or_else(|| "(deleted)".to_owned()); + let run = runtime.database().runs().latest_for_task(task.id).await?; + out.push(task_summary(&task, &name, run.as_ref())); + } + Ok(out) +} + +/// Recent audit events, newest last so the feed reads downward. +pub(crate) async fn recent_events(runtime: &Runtime, limit: i64) -> Answer> { + let mut records = runtime.database().audit_sink().tail(limit).await?; + records.reverse(); + Ok(records + .into_iter() + .map(|record| EventView { + id: record.id.to_string(), + sequence: Some(record.sequence), + at: agentos_core::format_timestamp(&record.at), + kind: record.kind.clone(), + run_id: record.run_id.map(|id| id.to_string()), + task_id: record.task_id.map(|id| id.to_string()), + summary: summarise_event(&record.payload), + security_relevant: agentos_core::event::is_security_kind(&record.kind), + }) + .collect()) +} + +// --------------------------------------------------------------------------- +// Dashboard +// --------------------------------------------------------------------------- + +/// Everything the dashboard shows. +#[tauri::command] +pub async fn dashboard(state: State<'_, AppState>) -> Answer { + let runtime = &state.runtime; + let agents = runtime.database().agents().list().await?; + + let active = runtime.database().tasks().list_active().await?; + let mut running_tasks = Vec::with_capacity(active.len()); + for task in active { + let name = runtime + .database() + .agents() + .find(task.agent_id) + .await? + .map(|agent| agent.name) + .unwrap_or_else(|| "(deleted)".to_owned()); + let run = runtime.database().runs().latest_for_task(task.id).await?; + running_tasks.push(task_summary(&task, &name, run.as_ref())); + } + + let pending = runtime.database().approvals().list_pending().await?; + let verification = runtime.verify_audit().await?; + + Ok(DashboardView { + agents: agents.iter().map(AgentSummary::from).collect(), + running_tasks, + pending_approvals: approval_views(runtime, pending).await?, + recent_events: recent_events(runtime, 40).await?, + recent_refusals: runtime + .database() + .executions() + .list_denied(10) + .await? + .iter() + .map(ExecutionView::from) + .collect(), + audit_events: runtime.database().audit_sink().count().await?, + audit_intact: verification.is_intact(), + }) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use agentos_core::agent::ModelConfig; + use agentos_core::approval::ApprovalStatus; + use agentos_core::ids::{AgentId, ApprovalId}; + use agentos_core::permission::Capability; + use agentos_core::risk::RiskLevel; + use agentos_core::task::{Task, TaskRun}; + use agentos_secrets::InMemorySecretStore; + + use super::*; + + /// A runtime with one agent, backed by a temporary directory. + /// + /// The `State` wrappers above are one-line delegations; what is worth + /// testing is the shaping underneath them, which is where a screen would + /// silently get the wrong answer. + async fn runtime_with_agent() -> (tempfile::TempDir, Runtime, agentos_core::agent::Agent) { + let guard = tempfile::TempDir::new().expect("temp dir"); + let root = std::fs::canonicalize(guard.path()).expect("canonical"); + let runtime = Runtime::in_memory(root, Arc::new(InMemorySecretStore::new())) + .await + .expect("runtime"); + let agent = runtime + .create_agent( + "sales", + "Handle follow-ups.", + ModelConfig::new("mock", "scripted"), + vec!["filesystem.read".to_owned()], + ) + .await + .expect("agent"); + (guard, runtime, agent) + } + + #[tokio::test] + async fn a_policy_view_summarises_what_it_grants() { + let view = policy_view( + "default: deny\npermissions:\n browser:\n navigate: ['http://localhost:*']\n" + .to_owned(), + 3, + ); + assert_eq!(view.default_effect, "deny"); + assert_eq!(view.version, 3); + assert!(view.taint_enabled, "taint escalation is on by default"); + assert_eq!(view.rules.len(), 1); + assert!(view.rules[0].contains("browser.navigate")); + } + + #[test] + fn a_policy_that_does_not_compile_says_so_rather_than_implying_permissions() { + // The runtime denies everything in this state. An interface that showed + // an empty rule list would imply "nothing configured" instead of + // "broken", which are very different things to an operator. + let view = policy_view("permisions: {}\n".to_owned(), 1); + assert_eq!(view.default_effect, "deny"); + assert_eq!(view.rules.len(), 1); + assert!( + view.rules[0].contains("does not compile"), + "{:?}", + view.rules + ); + } + + #[tokio::test] + async fn task_summaries_carry_the_agent_name_and_latest_run() { + let (_guard, runtime, agent) = runtime_with_agent().await; + let task = runtime + .create_task(agent.id, "Do the thing.") + .await + .unwrap(); + let run = TaskRun::new(task.id, 1); + runtime.database().runs().insert(&run).await.unwrap(); + + let summaries = task_summaries(&runtime, 10).await.unwrap(); + assert_eq!(summaries.len(), 1); + assert_eq!(summaries[0].agent_name, "sales"); + assert_eq!(summaries[0].objective, "Do the thing."); + assert_eq!( + summaries[0].latest_run.as_ref().map(|run| run.attempt), + Some(1) + ); + } + + #[tokio::test] + async fn a_task_whose_agent_was_deleted_still_renders() { + // Agents cascade-delete their tasks, so this is defensive rather than + // reachable today — but a list that panics is worse than one that says + // "(deleted)". + let (_guard, runtime, agent) = runtime_with_agent().await; + let orphan = Task::new(agentos_core::ids::AgentId::new(), "orphaned"); + let _ = agent; + // Inserting with an unknown agent is refused by the foreign key, which + // is the real guarantee; assert that rather than faking a broken row. + assert!(runtime.database().tasks().insert(&orphan).await.is_err()); + } + + #[tokio::test] + async fn approval_views_attach_the_objective_being_pursued() { + let (_guard, runtime, agent) = runtime_with_agent().await; + let task = runtime + .create_task(agent.id, "Find overdue accounts.") + .await + .unwrap(); + let run = TaskRun::new(task.id, 1); + runtime.database().runs().insert(&run).await.unwrap(); + + let request = ApprovalRequest { + id: ApprovalId::new(), + agent_id: agent.id, + agent_name: agent.name.clone(), + task_id: task.id, + run_id: run.id, + tool: "browser.type".to_owned(), + arguments: serde_json::json!({"selector": "#send"}), + capability: Capability::new("browser", "interact"), + risk: RiskLevel::High, + reason: "policy requires approval".to_owned(), + explanation: "Submit the form.".to_owned(), + affected_resources: vec!["origin:http://localhost:8420".to_owned()], + tainted: true, + taint_sources: vec!["web:http://localhost:8420/customers".to_owned()], + status: ApprovalStatus::Pending, + requested_at: agentos_core::now(), + decided_at: None, + decision_note: None, + }; + runtime + .database() + .approvals() + .insert(&request) + .await + .unwrap(); + + let views = approval_views(&runtime, vec![request]).await.unwrap(); + assert_eq!(views.len(), 1); + assert_eq!(views[0].objective, "Find overdue accounts."); + assert!(views[0].tainted); + assert_eq!(views[0].taint_sources.len(), 1); + // The arguments are pretty-printed for a person to read. + assert!(views[0].arguments.contains("\n")); + } + + #[tokio::test] + async fn recent_events_are_oldest_first_and_flag_refusals() { + let (_guard, runtime, agent) = runtime_with_agent().await; + + runtime + .audit() + .record(agentos_core::event::Event::new( + agentos_core::event::AgentEvent::TaskStarted { + objective: "first".to_owned(), + attempt: 1, + }, + )) + .await + .unwrap(); + runtime + .audit() + .record(agentos_core::event::Event::new( + agentos_core::event::AgentEvent::PermissionDenied { + tool: "terminal.exec".to_owned(), + capability: Capability::new("terminal", "exec"), + reason: "no rule matched".to_owned(), + matched_rule: None, + }, + )) + .await + .unwrap(); + let _ = agent; + + let events = recent_events(&runtime, 10).await.unwrap(); + assert!(events.len() >= 2); + + let first = events.iter().position(|e| e.kind == "agent.task.started"); + let denial = events.iter().position(|e| e.kind == "permission.denied"); + assert!(first < denial, "the feed should read oldest to newest"); + + let denial = &events[denial.expect("a denial was recorded")]; + assert!(denial.security_relevant); + assert_eq!(denial.summary, "terminal.exec"); + } + + #[tokio::test] + async fn a_bad_identifier_is_rejected_with_the_kind_it_expected() { + let error = parse_id::("agent", "not-a-uuid").unwrap_err(); + assert!(error.to_string().contains("agent identifier"), "{error}"); + } +} diff --git a/apps/desktop/src-tauri/src/commands/policies.rs b/apps/desktop/src-tauri/src/commands/policies.rs new file mode 100644 index 0000000..e592198 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/policies.rs @@ -0,0 +1,54 @@ +//! Checking and installing policy documents. + +use agentos_core::ids::AgentId; +use tauri::State; + +use super::{Answer, DesktopError, parse_id, policy_view}; +use crate::dto::{PolicyCheck, PolicyView}; +use crate::state::AppState; + +/// Check a policy document without installing it. +#[tauri::command] +pub async fn check_policy(document: String) -> Answer { + match agentos_permissions::PolicyDocument::from_yaml(&document) + .and_then(|parsed| parsed.compile()) + { + Ok(_) => Ok(PolicyCheck { + valid: true, + error: None, + summary: Some(policy_view(document, 0)), + }), + Err(error) => Ok(PolicyCheck { + valid: false, + error: Some(error.to_string()), + summary: None, + }), + } +} + +/// Install a policy for an agent. +/// +/// Refuses a document that does not compile. A policy that fails to load makes +/// the runtime deny everything, which is safe but bewildering; better to say so +/// at the moment of saving. +#[tauri::command] +pub async fn set_policy( + state: State<'_, AppState>, + agent_id: String, + document: String, +) -> Answer { + let id: AgentId = parse_id("agent", &agent_id)?; + agentos_permissions::PolicyDocument::from_yaml(&document) + .and_then(|parsed| parsed.compile()) + .map_err(|error| { + DesktopError::Rejected(format!("this policy does not compile: {error}")) + })?; + + let version = state + .runtime + .database() + .agents() + .set_policy(id, &document) + .await?; + Ok(policy_view(document, version)) +} diff --git a/apps/desktop/src-tauri/src/commands/settings.rs b/apps/desktop/src-tauri/src/commands/settings.rs new file mode 100644 index 0000000..384d3b2 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/settings.rs @@ -0,0 +1,110 @@ +//! The tool catalogue, the settings screen and provider credentials. + +use agentos_providers::provider_ids; +use agentos_secrets::{ + ChainSecretStore, EnvSecretStore, KeychainStatus, KeyringStore, SecretStore, provider_key, +}; +use tauri::State; + +use super::{Answer, DesktopError}; +use crate::dto::{ProviderView, SettingsView, ToolView}; +use crate::state::AppState; + +/// Every tool an agent can be granted. +#[tauri::command] +pub async fn list_tools(state: State<'_, AppState>) -> Answer> { + Ok(state + .runtime + .registry() + .all_metadata() + .iter() + .map(ToolView::from) + .collect()) +} + +/// The settings screen. +#[tauri::command] +pub async fn settings(state: State<'_, AppState>) -> Answer { + let runtime = &state.runtime; + let config = runtime.config(); + let keychain = KeyringStore::status(); + let secrets = ChainSecretStore::standard(); + + let providers = provider_ids::ALL + .iter() + .map(|id| { + let located = secrets.locate(&provider_key(id)); + ProviderView { + id: (*id).to_owned(), + configured: located.is_some(), + hint: located.as_ref().map(|(_, secret)| secret.hint()), + source: located.as_ref().map(|(store, _)| (*store).to_owned()), + note: match *id { + provider_ids::OLLAMA => "local; usually needs no key".to_owned(), + provider_ids::MOCK => "built in; no key needed".to_owned(), + _ => EnvSecretStore::variables_for(&provider_key(id)) + .last() + .map(|name| format!("or set {name} in the environment")) + .unwrap_or_default(), + }, + } + }) + .collect(); + + let browser = agentos_browser::locate(None); + Ok(SettingsView { + data_dir: config.data_dir.display().to_string(), + workspace: config.workspace.display().to_string(), + database: config.database_path.display().to_string(), + keychain_available: keychain.is_available(), + keychain_reason: match &keychain { + KeychainStatus::Available => None, + KeychainStatus::Unavailable { reason } => { + Some(reason.lines().next().unwrap_or(reason).to_owned()) + } + }, + providers, + browser_path: browser.as_ref().map(|path| path.display().to_string()), + browser_hint: browser.is_none().then(agentos_browser::install_hint), + tools: runtime + .registry() + .all_metadata() + .iter() + .map(ToolView::from) + .collect(), + }) +} + +/// Store a provider credential in the operating system keychain. +/// +/// Refuses when there is no keychain rather than pretending to succeed: on such +/// a machine the credential belongs in the environment, and saying so is more +/// use than a generic failure. +#[tauri::command] +pub async fn set_provider_key(provider: String, key: String) -> Answer<()> { + if let KeychainStatus::Unavailable { reason } = KeyringStore::status() { + let variable = EnvSecretStore::variables_for(&provider_key(&provider)) + .last() + .cloned() + .unwrap_or_default(); + return Err(DesktopError::Rejected(format!( + "this machine has no usable keychain ({}), so there is nowhere secure to store the \ + key. Set {variable} in the environment instead.", + reason.lines().next().unwrap_or(&reason) + ))); + } + + let key = key.trim(); + if key.is_empty() { + return Err(DesktopError::Rejected("no key was provided".to_owned())); + } + KeyringStore::new().set(&provider_key(&provider), key)?; + Ok(()) +} + +/// Remove a stored provider credential. +#[tauri::command] +pub async fn remove_provider_key(provider: String) -> Answer<()> { + KeyringStore::new().delete(&provider_key(&provider))?; + Ok(()) +} diff --git a/apps/desktop/src-tauri/src/commands/tasks.rs b/apps/desktop/src-tauri/src/commands/tasks.rs new file mode 100644 index 0000000..ab2fe25 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/tasks.rs @@ -0,0 +1,100 @@ +//! Starting, stopping and tracing tasks. + +use std::sync::Arc; + +use agentos_core::ids::{AgentId, TaskId, TaskRunId}; +use agentos_tools::ApprovalGate; +use tauri::{AppHandle, State}; +use tokio_util::sync::CancellationToken; + +use super::{Answer, DesktopError, parse_id, task_summaries}; +use crate::dto::{ + ApprovalView, ExecutionView, RunSummary, StartedTask, StepView, TaskSummary, TraceView, +}; +use crate::state::{AppState, DesktopApprovalGate}; + +/// Recent tasks. +#[tauri::command] +pub async fn list_tasks( + state: State<'_, AppState>, + limit: Option, +) -> Answer> { + task_summaries(&state.runtime, limit.unwrap_or(50)).await +} + +/// Start a task and return immediately. +/// +/// The run proceeds in the background; the interface follows it through the +/// activity stream and the trace. +#[tauri::command] +pub async fn start_task( + app: AppHandle, + state: State<'_, AppState>, + agent_id: String, + objective: String, +) -> Answer { + let id: AgentId = parse_id("agent", &agent_id)?; + if objective.trim().is_empty() { + return Err(DesktopError::Rejected( + "an objective is required".to_owned(), + )); + } + + let runtime = &state.runtime; + let task = runtime.create_task(id, &objective).await?; + + let gate: Arc = Arc::new(DesktopApprovalGate::new( + app, + state.approvals.clone(), + objective, + )); + let (run_id, _handle) = runtime + .start_task(&task, gate, CancellationToken::new()) + .await?; + + Ok(StartedTask { + task_id: task.id.to_string(), + run_id: run_id.to_string(), + }) +} + +/// Stop a run that is currently executing. +#[tauri::command] +pub async fn cancel_run(state: State<'_, AppState>, run_id: String) -> Answer { + let id: TaskRunId = parse_id("run", &run_id)?; + Ok(state.runtime.cancel_run(id).await) +} + +/// The full trace of one run. +#[tauri::command] +pub async fn get_trace(state: State<'_, AppState>, run_id: String) -> Answer { + let id: TaskRunId = parse_id("run", &run_id)?; + let trace = state.runtime.trace(id).await?; + Ok(TraceView { + run: RunSummary::from(&trace.run), + task_id: trace.run.task_id.to_string(), + agent_name: trace.agent_name.clone(), + objective: trace.objective.clone(), + steps: trace.steps.iter().map(StepView::from).collect(), + executions: trace.executions.iter().map(ExecutionView::from).collect(), + approvals: trace + .approvals + .iter() + .map(|request| ApprovalView::new(request, trace.objective.clone())) + .collect(), + }) +} + +/// The trace of a task's most recent run. +#[tauri::command] +pub async fn get_task_trace(state: State<'_, AppState>, task_id: String) -> Answer { + let id: TaskId = parse_id("task", &task_id)?; + let run = state + .runtime + .database() + .runs() + .latest_for_task(id) + .await? + .ok_or_else(|| DesktopError::Rejected("this task has never been run".to_owned()))?; + get_trace(state, run.id.to_string()).await +} diff --git a/apps/desktop/src-tauri/src/dto.rs b/apps/desktop/src-tauri/src/dto.rs deleted file mode 100644 index 643eb5c..0000000 --- a/apps/desktop/src-tauri/src/dto.rs +++ /dev/null @@ -1,630 +0,0 @@ -//! The typed contract between the runtime and the user interface. -//! -//! These are view models, not domain types. They exist for three reasons: -//! -//! * The interface wants denormalised, display-ready data — a task carries its -//! agent's name, an execution carries the decision that let it run — and -//! reshaping in the browser means every screen reinventing the same joins. -//! * `agentos-core` should not grow a TypeScript-binding dependency. A frontend -//! concern has no business in the domain crate. -//! * A view model can change to suit a screen without touching anything the -//! permission engine or the audit chain depends on. -//! -//! Every type here derives [`ts_rs::TS`] and exports into `src/bindings`, so the -//! TypeScript definitions are generated from these declarations rather than -//! written by hand. `cargo test -p agentos-desktop` regenerates them; a shape -//! that drifts fails to compile on the other side. -//! -//! Identifiers and timestamps cross as strings. The interface formats them -//! anyway, and it keeps the wire format explicit rather than dependent on how -//! two serialisation libraries happen to agree. - -use agentos_core::agent::Agent; -use agentos_core::approval::ApprovalRequest; -use agentos_core::task::{Task, TaskRun, TaskStep}; -use agentos_core::tool::ToolMetadata; -use agentos_persistence::ToolExecutionRecord; -use serde::{Deserialize, Serialize}; -use ts_rs::TS; - -/// Render a timestamp for the interface. -fn at(value: &agentos_core::Timestamp) -> String { - agentos_core::format_timestamp(value) -} - -/// Render an optional timestamp. -fn maybe_at(value: Option<&agentos_core::Timestamp>) -> Option { - value.map(at) -} - -/// An agent, as the list and dashboard show it. -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[ts(export, export_to = "../../src/bindings/")] -pub struct AgentSummary { - /// Identity. - pub id: String, - /// Display name. - pub name: String, - /// Provider identifier. - pub provider: String, - /// Model identifier. - pub model: String, - /// `enabled` or `disabled`. - pub status: String, - /// Tools this agent has been given. - pub tools: Vec, - /// Model turns allowed per run. - pub max_steps: u32, - /// When it was created. - pub created_at: String, -} - -impl From<&Agent> for AgentSummary { - fn from(agent: &Agent) -> Self { - Self { - id: agent.id.to_string(), - name: agent.name.clone(), - provider: agent.model.provider.clone(), - model: agent.model.model.clone(), - status: agent.status.as_str().to_owned(), - tools: agent.enabled_tools.clone(), - max_steps: agent.max_steps, - created_at: at(&agent.created_at), - } - } -} - -/// An agent with everything the detail screen needs. -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[ts(export, export_to = "../../src/bindings/")] -pub struct AgentDetail { - /// The summary fields. - pub summary: AgentSummary, - /// The agent's system instructions. - pub instructions: String, - /// Its policy, if one is stored. - pub policy: Option, - /// Recent tasks, newest first. - pub recent_tasks: Vec, - /// Where relative paths resolve for this agent. - pub workspace: String, -} - -/// A policy, summarised so the interface can show what it grants without -/// reimplementing the policy engine. -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[ts(export, export_to = "../../src/bindings/")] -pub struct PolicyView { - /// The YAML source, exactly as the operator wrote it. - pub document: String, - /// Incremented on every save. - #[ts(type = "number")] - pub version: i64, - /// What happens when no rule matches. - pub default_effect: String, - /// Global risk ceiling, if set. - pub max_risk: Option, - /// Whether reading untrusted data raises the approval bar. - pub taint_enabled: bool, - /// The risk level at which that escalation begins. - pub taint_threshold: String, - /// One line per rule, in the engine's own words. - pub rules: Vec, -} - -/// A task and the state of its latest attempt. -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[ts(export, export_to = "../../src/bindings/")] -pub struct TaskSummary { - /// Identity. - pub id: String, - /// What was asked for. - pub objective: String, - /// Aggregate status. - pub status: String, - /// The agent responsible. - pub agent_name: String, - /// Its identity, for navigation. - pub agent_id: String, - /// When created. - pub created_at: String, - /// When the latest run finished. - pub completed_at: Option, - /// The latest run, if the task has ever run. - pub latest_run: Option, -} - -/// One attempt at a task. -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[ts(export, export_to = "../../src/bindings/")] -pub struct RunSummary { - /// Identity. - pub id: String, - /// 1-based attempt number. - pub attempt: u32, - /// Where it is in the state machine. - pub state: String, - /// Whether it has read untrusted data. - pub tainted: bool, - /// Model turns consumed. - pub steps: u32, - /// The agent's final report. - pub result: Option, - /// Why it failed, if it did. - pub failure: Option, - /// Input tokens, when the provider reports them. - /// - /// Bound as `number`, not `bigint`: serde emits a JSON number and the IPC - /// layer hands JavaScript a `number`, so a `bigint` binding would describe a - /// wire format that does not exist. None of the counts here — tokens, - /// durations, sequence numbers — comes near 2^53. - #[ts(type = "number")] - pub input_tokens: u64, - /// Output tokens, when the provider reports them. - #[ts(type = "number")] - pub output_tokens: u64, - /// When it started. - pub started_at: String, - /// When it finished. - pub completed_at: Option, -} - -impl From<&TaskRun> for RunSummary { - fn from(run: &TaskRun) -> Self { - Self { - id: run.id.to_string(), - attempt: run.attempt, - state: run.state.as_str().to_owned(), - tainted: run.tainted, - steps: run.steps_taken, - result: run.result.clone(), - failure: run.failure.as_ref().map(ToString::to_string), - input_tokens: run.input_tokens, - output_tokens: run.output_tokens, - started_at: at(&run.started_at), - completed_at: maybe_at(run.completed_at.as_ref()), - } - } -} - -/// One entry in a run's trace. -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[ts(export, export_to = "../../src/bindings/")] -pub struct StepView { - /// Position within the run. - pub ordinal: u32, - /// What kind of thing happened. - pub kind: String, - /// The state the run was in. - pub state: String, - /// Human-readable summary. - pub summary: String, - /// The tool execution this refers to, if any. - pub tool_execution_id: Option, - /// When it happened. - pub at: String, -} - -impl From<&TaskStep> for StepView { - fn from(step: &TaskStep) -> Self { - Self { - ordinal: step.ordinal, - kind: format!("{:?}", step.kind).to_lowercase(), - state: step.state.as_str().to_owned(), - summary: step.summary.clone(), - tool_execution_id: step.tool_execution_id.map(|id| id.to_string()), - at: at(&step.at), - } - } -} - -/// A tool invocation, with the decision that governed it. -/// -/// The permission effect and taint state are recorded as they were *at the time -/// of the call*, so this stays truthful after the policy changes. -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[ts(export, export_to = "../../src/bindings/")] -pub struct ExecutionView { - /// Identity. - pub id: String, - /// The tool. - pub tool: String, - /// The model's call identifier. - pub call_id: String, - /// Validated arguments, as JSON text. - pub arguments: String, - /// How it ended. - pub outcome: String, - /// Whether it actually ran. - pub executed: bool, - /// What the policy decided. - pub effect: String, - /// Assessed risk. - pub risk: String, - /// Whether the run was tainted at the time. - pub tainted: bool, - /// The approval that gated it, if any. - pub approval_id: Option, - /// How long it took. - #[ts(type = "number")] - pub duration_ms: u64, - /// Error text, when it failed or was refused. - pub error: Option, - /// When it started. - pub started_at: String, -} - -impl From<&ToolExecutionRecord> for ExecutionView { - fn from(record: &ToolExecutionRecord) -> Self { - Self { - id: record.id.to_string(), - tool: record.tool.clone(), - call_id: record.call_id.clone(), - arguments: record.arguments.to_string(), - outcome: record.outcome.as_str().to_owned(), - executed: record.outcome.executed(), - effect: record.effect.as_str().to_owned(), - risk: record.risk.as_str().to_owned(), - tainted: record.tainted, - approval_id: record.approval_id.map(|id| id.to_string()), - duration_ms: record.duration_ms, - error: record.error.clone(), - started_at: at(&record.started_at), - } - } -} - -/// An approval request, carrying everything the card needs to show. -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[ts(export, export_to = "../../src/bindings/")] -pub struct ApprovalView { - /// Identity. - pub id: String, - /// The agent asking. - pub agent_name: String, - /// The task it is working on. - pub task_id: String, - /// The run. - pub run_id: String, - /// The objective, for context. - pub objective: String, - /// The tool it wants to invoke. - pub tool: String, - /// The validated arguments, as JSON text. - pub arguments: String, - /// Assessed risk. - pub risk: String, - /// Why the runtime is asking. - pub reason: String, - /// Plain-language description of what will happen. - pub explanation: String, - /// Resources the action touches. - pub affected_resources: Vec, - /// Whether the agent has read untrusted data during this run. - pub tainted: bool, - /// Where that data came from. - pub taint_sources: Vec, - /// Current status. - pub status: String, - /// When it was raised. - pub requested_at: String, - /// When it was answered. - pub decided_at: Option, - /// The human's note. - pub note: Option, -} - -impl ApprovalView { - /// Build a view, given the objective the run is pursuing. - #[must_use] - pub fn new(request: &ApprovalRequest, objective: String) -> Self { - Self { - id: request.id.to_string(), - agent_name: request.agent_name.clone(), - task_id: request.task_id.to_string(), - run_id: request.run_id.to_string(), - objective, - tool: request.tool.clone(), - arguments: serde_json::to_string_pretty(&request.arguments) - .unwrap_or_else(|_| request.arguments.to_string()), - risk: request.risk.as_str().to_owned(), - reason: request.reason.clone(), - explanation: request.explanation.clone(), - affected_resources: request.affected_resources.clone(), - tainted: request.tainted, - taint_sources: request.taint_sources.clone(), - status: request.status.as_str().to_owned(), - requested_at: at(&request.requested_at), - decided_at: maybe_at(request.decided_at.as_ref()), - note: request.decision_note.clone(), - } - } -} - -/// Everything recorded about one run. -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[ts(export, export_to = "../../src/bindings/")] -pub struct TraceView { - /// The run. - pub run: RunSummary, - /// The task's identity. - pub task_id: String, - /// The agent that performed it. - pub agent_name: String, - /// What it was asked to do. - pub objective: String, - /// The ordered trace. - pub steps: Vec, - /// Tool invocations and their decisions. - pub executions: Vec, - /// Approvals raised during the run. - pub approvals: Vec, -} - -/// A tool, as the catalogue and the agent editor show it. -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[ts(export, export_to = "../../src/bindings/")] -pub struct ToolView { - /// Fully-qualified name. - pub name: String, - /// Domain half of the name. - pub domain: String, - /// What it does, in the words the model sees. - pub description: String, - /// Baseline risk. - pub risk: String, - /// Whether its output can be attacker-controlled. - /// - /// The single most useful thing to know when deciding what to grant, so it - /// is surfaced rather than buried in the description. - pub returns_untrusted_data: bool, -} - -impl From<&ToolMetadata> for ToolView { - fn from(metadata: &ToolMetadata) -> Self { - Self { - name: metadata.name.clone(), - domain: metadata.domain().to_owned(), - description: metadata.description.clone(), - risk: metadata.risk.as_str().to_owned(), - returns_untrusted_data: metadata.returns_untrusted_data, - } - } -} - -/// One audit event, flattened for the activity feed. -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[ts(export, export_to = "../../src/bindings/")] -pub struct EventView { - /// Identity. - pub id: String, - /// Position in the audit chain, when it came from storage. - #[ts(type = "number | null")] - pub sequence: Option, - /// When it happened. - pub at: String, - /// The dotted event name. - pub kind: String, - /// The run it belongs to. - pub run_id: Option, - /// The task it belongs to. - pub task_id: Option, - /// A one-line description. - pub summary: String, - /// Whether this records a refusal, an escalation or a rejection. - pub security_relevant: bool, -} - -/// The dashboard. -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[ts(export, export_to = "../../src/bindings/")] -pub struct DashboardView { - /// Every configured agent. - pub agents: Vec, - /// Tasks whose latest run is still going. - pub running_tasks: Vec, - /// Approvals waiting on a human. - pub pending_approvals: Vec, - /// The most recent activity. - pub recent_events: Vec, - /// Recent tool calls that were refused. - pub recent_refusals: Vec, - /// How many events the audit log holds. - #[ts(type = "number")] - pub audit_events: i64, - /// Whether the audit chain verifies. - pub audit_intact: bool, -} - -/// A model provider and whether it can be used. -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[ts(export, export_to = "../../src/bindings/")] -pub struct ProviderView { - /// Identifier. - pub id: String, - /// Whether a credential is available. - pub configured: bool, - /// A redacted hint, never the key. - pub hint: Option, - /// Which store supplied it. - pub source: Option, - /// Guidance when it is not configured. - pub note: String, -} - -/// The settings screen. -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[ts(export, export_to = "../../src/bindings/")] -pub struct SettingsView { - /// Where AgentOS keeps its state. - pub data_dir: String, - /// Agent workspaces. - pub workspace: String, - /// The database file. - pub database: String, - /// Whether the OS keychain is usable here. - pub keychain_available: bool, - /// Why not, when it is not. - pub keychain_reason: Option, - /// Providers and their credential status. - pub providers: Vec, - /// The browser executable that will be driven, if one was found. - pub browser_path: Option, - /// Guidance when no browser is installed. - pub browser_hint: Option, - /// Every tool an agent can be granted. - pub tools: Vec, -} - -/// The result of starting a task. -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[ts(export, export_to = "../../src/bindings/")] -pub struct StartedTask { - /// The task that was created. - pub task_id: String, - /// The run that is executing it. - pub run_id: String, -} - -/// What the interface sends back when a human answers an approval. -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[ts(export, export_to = "../../src/bindings/")] -pub struct ApprovalDecisionInput { - /// The request being answered. - pub approval_id: String, - /// Yes or no. - pub approved: bool, - /// An optional note, recorded in the audit log. - pub note: Option, -} - -/// What the interface sends to create an agent. -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[ts(export, export_to = "../../src/bindings/")] -pub struct CreateAgentInput { - /// Unique name. - pub name: String, - /// System instructions. - pub instructions: String, - /// Provider identifier. - pub provider: String, - /// Model identifier. - pub model: String, - /// Base URL override, for OpenAI-compatible endpoints. - pub base_url: Option, - /// Whether this model can be shown images. `null` takes the provider's - /// default, which is off for a local endpoint whose model is unknown. - pub vision: Option, - /// Tools to grant. - pub tools: Vec, -} - -/// The outcome of checking a policy document without installing it. -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -#[ts(export, export_to = "../../src/bindings/")] -pub struct PolicyCheck { - /// Whether it compiles. - pub valid: bool, - /// Why not, when it does not. - pub error: Option, - /// What it grants, when it does. - pub summary: Option, -} - -/// Build a one-line summary of an audit event payload. -/// -/// Mirrors what the CLI shows, so the two clients describe the same event the -/// same way. -#[must_use] -pub fn summarise_event(payload: &serde_json::Value) -> String { - if let (Some(from), Some(to)) = ( - payload.get("from").and_then(serde_json::Value::as_str), - payload.get("to").and_then(serde_json::Value::as_str), - ) { - return format!("{from} → {to}"); - } - - for name in ["tool", "objective", "reason", "error", "summary"] { - if let Some(value) = payload.get(name).and_then(serde_json::Value::as_str) - && !value.is_empty() - { - return value.to_owned(); - } - } - - if let Some(model) = payload.get("model").and_then(serde_json::Value::as_str) { - let provider = payload - .get("provider") - .and_then(serde_json::Value::as_str) - .unwrap_or_default(); - return format!("{provider}/{model}"); - } - - String::new() -} - -/// Build a [`TaskSummary`] from its parts. -#[must_use] -pub fn task_summary(task: &Task, agent_name: &str, latest_run: Option<&TaskRun>) -> TaskSummary { - TaskSummary { - id: task.id.to_string(), - objective: task.objective.clone(), - status: task.status.as_str().to_owned(), - agent_name: agent_name.to_owned(), - agent_id: task.agent_id.to_string(), - created_at: at(&task.created_at), - completed_at: maybe_at(task.completed_at.as_ref()), - latest_run: latest_run.map(RunSummary::from), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn event_summaries_prefer_the_most_specific_field() { - let transition = serde_json::json!({"from": "idle", "to": "planning"}); - assert_eq!(summarise_event(&transition), "idle → planning"); - - let tool = serde_json::json!({"tool": "browser.navigate", "risk": "medium"}); - assert_eq!(summarise_event(&tool), "browser.navigate"); - - let model = serde_json::json!({"provider": "anthropic", "model": "claude-opus-5"}); - assert_eq!(summarise_event(&model), "anthropic/claude-opus-5"); - - assert_eq!(summarise_event(&serde_json::json!({})), ""); - } - - #[test] - fn an_execution_view_says_whether_it_actually_ran() { - use agentos_core::ids::{TaskRunId, ToolExecutionId}; - use agentos_core::permission::Effect; - use agentos_core::risk::RiskLevel; - use agentos_core::tool::ToolOutcome; - - let record = ToolExecutionRecord { - id: ToolExecutionId::new(), - run_id: TaskRunId::new(), - tool: "terminal.exec".into(), - call_id: "c1".into(), - arguments: serde_json::json!({"program": "curl"}), - outcome: ToolOutcome::Denied, - effect: Effect::Deny, - risk: RiskLevel::High, - tainted: true, - approval_id: None, - output_bytes: 0, - error: Some("permission denied".into()), - duration_ms: 0, - started_at: agentos_core::now(), - completed_at: None, - }; - - let view = ExecutionView::from(&record); - assert!(!view.executed, "a denied call must not read as executed"); - assert_eq!(view.outcome, "denied"); - assert_eq!(view.effect, "deny"); - assert!(view.tainted); - } -} diff --git a/apps/desktop/src-tauri/src/dto/agent.rs b/apps/desktop/src-tauri/src/dto/agent.rs new file mode 100644 index 0000000..2ebf712 --- /dev/null +++ b/apps/desktop/src-tauri/src/dto/agent.rs @@ -0,0 +1,115 @@ +//! Agents, their policies, and the input that creates one. + +use agentos_core::agent::Agent; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use super::{TaskSummary, at}; + +/// An agent, as the list and dashboard show it. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../src/bindings/")] +pub struct AgentSummary { + /// Identity. + pub id: String, + /// Display name. + pub name: String, + /// Provider identifier. + pub provider: String, + /// Model identifier. + pub model: String, + /// `enabled` or `disabled`. + pub status: String, + /// Tools this agent has been given. + pub tools: Vec, + /// Model turns allowed per run. + pub max_steps: u32, + /// When it was created. + pub created_at: String, +} + +impl From<&Agent> for AgentSummary { + fn from(agent: &Agent) -> Self { + Self { + id: agent.id.to_string(), + name: agent.name.clone(), + provider: agent.model.provider.clone(), + model: agent.model.model.clone(), + status: agent.status.as_str().to_owned(), + tools: agent.enabled_tools.clone(), + max_steps: agent.max_steps, + created_at: at(&agent.created_at), + } + } +} + +/// An agent with everything the detail screen needs. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../src/bindings/")] +pub struct AgentDetail { + /// The summary fields. + pub summary: AgentSummary, + /// The agent's system instructions. + pub instructions: String, + /// Its policy, if one is stored. + pub policy: Option, + /// Recent tasks, newest first. + pub recent_tasks: Vec, + /// Where relative paths resolve for this agent. + pub workspace: String, +} + +/// A policy, summarised so the interface can show what it grants without +/// reimplementing the policy engine. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../src/bindings/")] +pub struct PolicyView { + /// The YAML source, exactly as the operator wrote it. + pub document: String, + /// Incremented on every save. + #[ts(type = "number")] + pub version: i64, + /// What happens when no rule matches. + pub default_effect: String, + /// Global risk ceiling, if set. + pub max_risk: Option, + /// Whether reading untrusted data raises the approval bar. + pub taint_enabled: bool, + /// The risk level at which that escalation begins. + pub taint_threshold: String, + /// One line per rule, in the engine's own words. + pub rules: Vec, +} + +/// What the interface sends to create an agent. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../src/bindings/")] +pub struct CreateAgentInput { + /// Unique name. + pub name: String, + /// System instructions. + pub instructions: String, + /// Provider identifier. + pub provider: String, + /// Model identifier. + pub model: String, + /// Base URL override, for OpenAI-compatible endpoints. + pub base_url: Option, + /// Whether this model can be shown images. `null` takes the provider's + /// default, which is off for a local endpoint whose model is unknown. + pub vision: Option, + /// Tools to grant. + pub tools: Vec, +} + +/// The outcome of checking a policy document without installing it. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../src/bindings/")] +pub struct PolicyCheck { + /// Whether it compiles. + pub valid: bool, + /// Why not, when it does not. + pub error: Option, + /// What it grants, when it does. + pub summary: Option, +} diff --git a/apps/desktop/src-tauri/src/dto/approval.rs b/apps/desktop/src-tauri/src/dto/approval.rs new file mode 100644 index 0000000..f5581e0 --- /dev/null +++ b/apps/desktop/src-tauri/src/dto/approval.rs @@ -0,0 +1,86 @@ +//! Approval requests and the answer a human sends back. + +use agentos_core::approval::ApprovalRequest; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use super::{at, maybe_at}; + +/// An approval request, carrying everything the card needs to show. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../src/bindings/")] +pub struct ApprovalView { + /// Identity. + pub id: String, + /// The agent asking. + pub agent_name: String, + /// The task it is working on. + pub task_id: String, + /// The run. + pub run_id: String, + /// The objective, for context. + pub objective: String, + /// The tool it wants to invoke. + pub tool: String, + /// The validated arguments, as JSON text. + pub arguments: String, + /// Assessed risk. + pub risk: String, + /// Why the runtime is asking. + pub reason: String, + /// Plain-language description of what will happen. + pub explanation: String, + /// Resources the action touches. + pub affected_resources: Vec, + /// Whether the agent has read untrusted data during this run. + pub tainted: bool, + /// Where that data came from. + pub taint_sources: Vec, + /// Current status. + pub status: String, + /// When it was raised. + pub requested_at: String, + /// When it was answered. + pub decided_at: Option, + /// The human's note. + pub note: Option, +} + +impl ApprovalView { + /// Build a view, given the objective the run is pursuing. + #[must_use] + pub fn new(request: &ApprovalRequest, objective: String) -> Self { + Self { + id: request.id.to_string(), + agent_name: request.agent_name.clone(), + task_id: request.task_id.to_string(), + run_id: request.run_id.to_string(), + objective, + tool: request.tool.clone(), + arguments: serde_json::to_string_pretty(&request.arguments) + .unwrap_or_else(|_| request.arguments.to_string()), + risk: request.risk.as_str().to_owned(), + reason: request.reason.clone(), + explanation: request.explanation.clone(), + affected_resources: request.affected_resources.clone(), + tainted: request.tainted, + taint_sources: request.taint_sources.clone(), + status: request.status.as_str().to_owned(), + requested_at: at(&request.requested_at), + decided_at: maybe_at(request.decided_at.as_ref()), + note: request.decision_note.clone(), + } + } +} + +/// What the interface sends back when a human answers an approval. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../src/bindings/")] +pub struct ApprovalDecisionInput { + /// The request being answered. + pub approval_id: String, + /// Yes or no. + pub approved: bool, + /// An optional note, recorded in the audit log. + pub note: Option, +} diff --git a/apps/desktop/src-tauri/src/dto/event.rs b/apps/desktop/src-tauri/src/dto/event.rs new file mode 100644 index 0000000..312143d --- /dev/null +++ b/apps/desktop/src-tauri/src/dto/event.rs @@ -0,0 +1,50 @@ +//! Audit events and the dashboard that gathers them. + +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use super::{AgentSummary, ApprovalView, ExecutionView, TaskSummary}; + +/// One audit event, flattened for the activity feed. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../src/bindings/")] +pub struct EventView { + /// Identity. + pub id: String, + /// Position in the audit chain, when it came from storage. + #[ts(type = "number | null")] + pub sequence: Option, + /// When it happened. + pub at: String, + /// The dotted event name. + pub kind: String, + /// The run it belongs to. + pub run_id: Option, + /// The task it belongs to. + pub task_id: Option, + /// A one-line description. + pub summary: String, + /// Whether this records a refusal, an escalation or a rejection. + pub security_relevant: bool, +} + +/// The dashboard. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../src/bindings/")] +pub struct DashboardView { + /// Every configured agent. + pub agents: Vec, + /// Tasks whose latest run is still going. + pub running_tasks: Vec, + /// Approvals waiting on a human. + pub pending_approvals: Vec, + /// The most recent activity. + pub recent_events: Vec, + /// Recent tool calls that were refused. + pub recent_refusals: Vec, + /// How many events the audit log holds. + #[ts(type = "number")] + pub audit_events: i64, + /// Whether the audit chain verifies. + pub audit_intact: bool, +} diff --git a/apps/desktop/src-tauri/src/dto/mod.rs b/apps/desktop/src-tauri/src/dto/mod.rs new file mode 100644 index 0000000..97ea18a --- /dev/null +++ b/apps/desktop/src-tauri/src/dto/mod.rs @@ -0,0 +1,174 @@ +//! The typed contract between the runtime and the user interface. +//! +//! These are view models, not domain types. They exist for three reasons: +//! +//! * The interface wants denormalised, display-ready data — a task carries its +//! agent's name, an execution carries the decision that let it run — and +//! reshaping in the browser means every screen reinventing the same joins. +//! * `agentos-core` should not grow a TypeScript-binding dependency. A frontend +//! concern has no business in the domain crate. +//! * A view model can change to suit a screen without touching anything the +//! permission engine or the audit chain depends on. +//! +//! Every type here derives [`ts_rs::TS`] and exports into `src/bindings`, so the +//! TypeScript definitions are generated from these declarations rather than +//! written by hand. `cargo test -p agentos-desktop` regenerates them; a shape +//! that drifts fails to compile on the other side. +//! +//! Identifiers and timestamps cross as strings. The interface formats them +//! anyway, and it keeps the wire format explicit rather than dependent on how +//! two serialisation libraries happen to agree. + +use agentos_core::task::{Task, TaskRun}; + +mod agent; +mod approval; +mod event; +mod settings; +mod task; + +pub use agent::*; +pub use approval::*; +pub use event::*; +pub use settings::*; +pub use task::*; + +/// Render a timestamp for the interface. +fn at(value: &agentos_core::Timestamp) -> String { + agentos_core::format_timestamp(value) +} + +/// Render an optional timestamp. +fn maybe_at(value: Option<&agentos_core::Timestamp>) -> Option { + value.map(at) +} + +/// Build a one-line summary of an audit event payload. +/// +/// Mirrors what the CLI shows, so the two clients describe the same event the +/// same way. +#[must_use] +pub fn summarise_event(payload: &serde_json::Value) -> String { + if let (Some(from), Some(to)) = ( + payload.get("from").and_then(serde_json::Value::as_str), + payload.get("to").and_then(serde_json::Value::as_str), + ) { + return format!("{from} → {to}"); + } + + // A manifest drift names the tool and what it planned beyond its manifest; + // the tool alone would read like a routine execution. + if let Some(undeclared) = undeclared_capabilities(payload) { + let tool = payload + .get("tool") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + return format!("{tool} planned undeclared {undeclared}"); + } + + for name in ["tool", "objective", "reason", "error", "summary"] { + if let Some(value) = payload.get(name).and_then(serde_json::Value::as_str) + && !value.is_empty() + { + return value.to_owned(); + } + } + + if let Some(model) = payload.get("model").and_then(serde_json::Value::as_str) { + let provider = payload + .get("provider") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + return format!("{provider}/{model}"); + } + + String::new() +} + +/// The `undeclared` list of a `tool.manifest_exceeded` payload, comma-joined. +fn undeclared_capabilities(payload: &serde_json::Value) -> Option { + let names: Vec<&str> = payload + .get("undeclared")? + .as_array()? + .iter() + .filter_map(serde_json::Value::as_str) + .collect(); + (!names.is_empty()).then(|| names.join(", ")) +} + +/// Build a [`TaskSummary`] from its parts. +#[must_use] +pub fn task_summary(task: &Task, agent_name: &str, latest_run: Option<&TaskRun>) -> TaskSummary { + TaskSummary { + id: task.id.to_string(), + objective: task.objective.clone(), + status: task.status.as_str().to_owned(), + agent_name: agent_name.to_owned(), + agent_id: task.agent_id.to_string(), + created_at: at(&task.created_at), + completed_at: maybe_at(task.completed_at.as_ref()), + latest_run: latest_run.map(RunSummary::from), + } +} + +#[cfg(test)] +mod tests { + use agentos_persistence::ToolExecutionRecord; + + use super::*; + + #[test] + fn event_summaries_prefer_the_most_specific_field() { + let transition = serde_json::json!({"from": "idle", "to": "planning"}); + assert_eq!(summarise_event(&transition), "idle → planning"); + + let tool = serde_json::json!({"tool": "browser.navigate", "risk": "medium"}); + assert_eq!(summarise_event(&tool), "browser.navigate"); + + let model = serde_json::json!({"provider": "anthropic", "model": "claude-opus-5"}); + assert_eq!(summarise_event(&model), "anthropic/claude-opus-5"); + + let drift = serde_json::json!({ + "tool": "terminal.exec", + "undeclared": ["filesystem.read", "network.request"], + }); + assert_eq!( + summarise_event(&drift), + "terminal.exec planned undeclared filesystem.read, network.request" + ); + + assert_eq!(summarise_event(&serde_json::json!({})), ""); + } + + #[test] + fn an_execution_view_says_whether_it_actually_ran() { + use agentos_core::ids::{TaskRunId, ToolExecutionId}; + use agentos_core::permission::Effect; + use agentos_core::risk::RiskLevel; + use agentos_core::tool::ToolOutcome; + + let record = ToolExecutionRecord { + id: ToolExecutionId::new(), + run_id: TaskRunId::new(), + tool: "terminal.exec".into(), + call_id: "c1".into(), + arguments: serde_json::json!({"program": "curl"}), + outcome: ToolOutcome::Denied, + effect: Effect::Deny, + risk: RiskLevel::High, + tainted: true, + approval_id: None, + output_bytes: 0, + error: Some("permission denied".into()), + duration_ms: 0, + started_at: agentos_core::now(), + completed_at: None, + }; + + let view = ExecutionView::from(&record); + assert!(!view.executed, "a denied call must not read as executed"); + assert_eq!(view.outcome, "denied"); + assert_eq!(view.effect, "deny"); + assert!(view.tainted); + } +} diff --git a/apps/desktop/src-tauri/src/dto/settings.rs b/apps/desktop/src-tauri/src/dto/settings.rs new file mode 100644 index 0000000..868e62e --- /dev/null +++ b/apps/desktop/src-tauri/src/dto/settings.rs @@ -0,0 +1,76 @@ +//! Tools, providers and the settings screen. + +use agentos_core::tool::ToolMetadata; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +/// A tool, as the catalogue and the agent editor show it. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../src/bindings/")] +pub struct ToolView { + /// Fully-qualified name. + pub name: String, + /// Domain half of the name. + pub domain: String, + /// What it does, in the words the model sees. + pub description: String, + /// Baseline risk. + pub risk: String, + /// Whether its output can be attacker-controlled. + /// + /// The single most useful thing to know when deciding what to grant, so it + /// is surfaced rather than buried in the description. + pub returns_untrusted_data: bool, +} + +impl From<&ToolMetadata> for ToolView { + fn from(metadata: &ToolMetadata) -> Self { + Self { + name: metadata.name.clone(), + domain: metadata.domain().to_owned(), + description: metadata.description.clone(), + risk: metadata.risk.as_str().to_owned(), + returns_untrusted_data: metadata.returns_untrusted_data, + } + } +} + +/// A model provider and whether it can be used. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../src/bindings/")] +pub struct ProviderView { + /// Identifier. + pub id: String, + /// Whether a credential is available. + pub configured: bool, + /// A redacted hint, never the key. + pub hint: Option, + /// Which store supplied it. + pub source: Option, + /// Guidance when it is not configured. + pub note: String, +} + +/// The settings screen. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../src/bindings/")] +pub struct SettingsView { + /// Where AgentOS keeps its state. + pub data_dir: String, + /// Agent workspaces. + pub workspace: String, + /// The database file. + pub database: String, + /// Whether the OS keychain is usable here. + pub keychain_available: bool, + /// Why not, when it is not. + pub keychain_reason: Option, + /// Providers and their credential status. + pub providers: Vec, + /// The browser executable that will be driven, if one was found. + pub browser_path: Option, + /// Guidance when no browser is installed. + pub browser_hint: Option, + /// Every tool an agent can be granted. + pub tools: Vec, +} diff --git a/apps/desktop/src-tauri/src/dto/task.rs b/apps/desktop/src-tauri/src/dto/task.rs new file mode 100644 index 0000000..56887c2 --- /dev/null +++ b/apps/desktop/src-tauri/src/dto/task.rs @@ -0,0 +1,200 @@ +//! Tasks, their runs, and everything a run's trace records. + +use agentos_core::task::{TaskRun, TaskStep}; +use agentos_persistence::ToolExecutionRecord; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use super::{ApprovalView, at, maybe_at}; + +/// A task and the state of its latest attempt. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../src/bindings/")] +pub struct TaskSummary { + /// Identity. + pub id: String, + /// What was asked for. + pub objective: String, + /// Aggregate status. + pub status: String, + /// The agent responsible. + pub agent_name: String, + /// Its identity, for navigation. + pub agent_id: String, + /// When created. + pub created_at: String, + /// When the latest run finished. + pub completed_at: Option, + /// The latest run, if the task has ever run. + pub latest_run: Option, +} + +/// One attempt at a task. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../src/bindings/")] +pub struct RunSummary { + /// Identity. + pub id: String, + /// 1-based attempt number. + pub attempt: u32, + /// Where it is in the state machine. + pub state: String, + /// Whether it has read untrusted data. + pub tainted: bool, + /// Model turns consumed. + pub steps: u32, + /// The agent's final report. + pub result: Option, + /// Why it failed, if it did. + pub failure: Option, + /// Input tokens, when the provider reports them. + /// + /// Bound as `number`, not `bigint`: serde emits a JSON number and the IPC + /// layer hands JavaScript a `number`, so a `bigint` binding would describe a + /// wire format that does not exist. None of the counts here — tokens, + /// durations, sequence numbers — comes near 2^53. + #[ts(type = "number")] + pub input_tokens: u64, + /// Output tokens, when the provider reports them. + #[ts(type = "number")] + pub output_tokens: u64, + /// When it started. + pub started_at: String, + /// When it finished. + pub completed_at: Option, +} + +impl From<&TaskRun> for RunSummary { + fn from(run: &TaskRun) -> Self { + Self { + id: run.id.to_string(), + attempt: run.attempt, + state: run.state.as_str().to_owned(), + tainted: run.tainted, + steps: run.steps_taken, + result: run.result.clone(), + failure: run.failure.as_ref().map(ToString::to_string), + input_tokens: run.input_tokens, + output_tokens: run.output_tokens, + started_at: at(&run.started_at), + completed_at: maybe_at(run.completed_at.as_ref()), + } + } +} + +/// One entry in a run's trace. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../src/bindings/")] +pub struct StepView { + /// Position within the run. + pub ordinal: u32, + /// What kind of thing happened. + pub kind: String, + /// The state the run was in. + pub state: String, + /// Human-readable summary. + pub summary: String, + /// The tool execution this refers to, if any. + pub tool_execution_id: Option, + /// When it happened. + pub at: String, +} + +impl From<&TaskStep> for StepView { + fn from(step: &TaskStep) -> Self { + Self { + ordinal: step.ordinal, + kind: format!("{:?}", step.kind).to_lowercase(), + state: step.state.as_str().to_owned(), + summary: step.summary.clone(), + tool_execution_id: step.tool_execution_id.map(|id| id.to_string()), + at: at(&step.at), + } + } +} + +/// A tool invocation, with the decision that governed it. +/// +/// The permission effect and taint state are recorded as they were *at the time +/// of the call*, so this stays truthful after the policy changes. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../src/bindings/")] +pub struct ExecutionView { + /// Identity. + pub id: String, + /// The tool. + pub tool: String, + /// The model's call identifier. + pub call_id: String, + /// Validated arguments, as JSON text. + pub arguments: String, + /// How it ended. + pub outcome: String, + /// Whether it actually ran. + pub executed: bool, + /// What the policy decided. + pub effect: String, + /// Assessed risk. + pub risk: String, + /// Whether the run was tainted at the time. + pub tainted: bool, + /// The approval that gated it, if any. + pub approval_id: Option, + /// How long it took. + #[ts(type = "number")] + pub duration_ms: u64, + /// Error text, when it failed or was refused. + pub error: Option, + /// When it started. + pub started_at: String, +} + +impl From<&ToolExecutionRecord> for ExecutionView { + fn from(record: &ToolExecutionRecord) -> Self { + Self { + id: record.id.to_string(), + tool: record.tool.clone(), + call_id: record.call_id.clone(), + arguments: record.arguments.to_string(), + outcome: record.outcome.as_str().to_owned(), + executed: record.outcome.executed(), + effect: record.effect.as_str().to_owned(), + risk: record.risk.as_str().to_owned(), + tainted: record.tainted, + approval_id: record.approval_id.map(|id| id.to_string()), + duration_ms: record.duration_ms, + error: record.error.clone(), + started_at: at(&record.started_at), + } + } +} + +/// Everything recorded about one run. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../src/bindings/")] +pub struct TraceView { + /// The run. + pub run: RunSummary, + /// The task's identity. + pub task_id: String, + /// The agent that performed it. + pub agent_name: String, + /// What it was asked to do. + pub objective: String, + /// The ordered trace. + pub steps: Vec, + /// Tool invocations and their decisions. + pub executions: Vec, + /// Approvals raised during the run. + pub approvals: Vec, +} + +/// The result of starting a task. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../src/bindings/")] +pub struct StartedTask { + /// The task that was created. + pub task_id: String, + /// The run that is executing it. + pub run_id: String, +} diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 6514f3b..ae404d6 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -78,9 +78,7 @@ export function App() {
{usingFixtures() ? ( - - Fixture data — not connected to a runtime - + Fixture data — not connected to a runtime ) : ( Local runtime )} @@ -105,12 +103,19 @@ function Mark() { ); } diff --git a/apps/desktop/src/components/common.tsx b/apps/desktop/src/components/common.tsx index b94ce6e..dbcb554 100644 --- a/apps/desktop/src/components/common.tsx +++ b/apps/desktop/src/components/common.tsx @@ -1,87 +1,52 @@ -import type { ReactNode } from "react"; +import type { CSSProperties, ReactNode } from "react"; -import { humanise } from "../sdk/format"; +import { ago } from "../sdk/format"; +import { useNow } from "./hooks"; -/** A risk level, coloured by severity. The one thing on screen that should shout. */ -export function Risk({ level }: { level: string }) { - return {level}; -} - -/** A run state, coloured by disposition. */ -export function State({ state }: { state: string }) { - const tone = - state === "completed" - ? "ok" - : state === "failed" - ? "high" - : state === "cancelled" - ? "medium" - : ["idle"].includes(state) - ? "neutral" - : "live"; - return {humanise(state)}; -} - -/** A task status. */ -export function Status({ status }: { status: string }) { - const tone = - status === "succeeded" - ? "ok" - : status === "failed" - ? "high" - : status === "cancelled" - ? "medium" - : status === "running" - ? "live" - : "neutral"; - return {status}; -} - -/** - * Whether an agent may be given work. - * - * Deliberately not the run-state badge: an *enabled* agent is not a *running* - * one, and showing "running" beside an idle agent would be a lie about what the - * machine is doing. - */ -export function Enabled({ status }: { status: string }) { - const on = status === "enabled"; - return {on ? "enabled" : "disabled"}; -} - -/** - * The marker for a run that has read untrusted data. - * - * Shown wherever such a run appears, not only on the approval card. A person - * scanning a list should be able to see which work was influenced by something - * the operator did not write. - */ -export function Tainted({ label = "read untrusted data" }: { label?: string }) { - return ( - - - {label} - - ); -} +// The status chips live in their own module; re-exported so every screen keeps +// importing its whole vocabulary from one place. +export * from "./status"; export function Panel({ children }: { children: ReactNode }) { return
{children}
; } +/** An emptied list. A result the operator asked for, so it is announced. */ export function Empty({ children }: { children: ReactNode }) { - return
{children}
; + return ( +
+ {children} +
+ ); } export function Loading({ what }: { what: string }) { - return
Loading {what}…
; + return ( +
+ Loading {what}… +
+ ); } +/** + * An async failure. Every one in the app arrives through here, so it is an + * alert: a request that failed while the operator looked elsewhere must not + * appear in silence. + */ export function ErrorBanner({ message }: { message: string }) { - return
{message}
; + return ( +
+ {message} +
+ ); } -/** A labelled number for the dashboard. */ +/** + * A labelled number for the dashboard. + * + * The tone is a class, not a colour. A colour chosen here is one the light + * theme cannot reach. + */ export function Stat({ value, label, @@ -89,32 +54,128 @@ export function Stat({ }: { value: string | number; label: string; - tone?: "ok" | "warn" | "danger"; + tone?: "ok" | "warn" | "danger" | undefined; }) { - const colour = - tone === "ok" - ? "var(--ok)" - : tone === "warn" - ? "var(--warn)" - : tone === "danger" - ? "var(--danger)" - : undefined; return (
-
- {value} -
+
{value}
{label}
); } -/** The section heading used throughout. */ -export function Section({ title, action }: { title: string; action?: ReactNode }) { +/** + * One line of a list. + * + * With `onActivate` it is a real ` + ); + } + return
{children}
; +} + +/** + * The title block of a screen. + * + * `parent` draws a breadcrumb back to the list a detail screen belongs to. It + * takes a callback rather than a route so this component does not need to know + * how the app navigates. + */ +export function PageHeader({ + title, + subtitle, + parent, + actions, +}: { + title: ReactNode; + subtitle?: ReactNode; + parent?: { label: string; onActivate: () => void } | undefined; + actions?: ReactNode; +}) { + return ( + <> + {parent ? ( + + ) : null} +
+

{title}

+ {actions} +
+ {subtitle ?

{subtitle}

: null} + + ); +} + +// Varied so a column of placeholders reads as a list rather than a barcode. +// Fixed rather than random so a re-render does not make the skeleton jitter. +const SKELETON_WIDTHS = [ + [62, 38], + [48, 30], + [70, 44], + [55, 26], +] as const; + +/** + * Placeholder rows in the shape of `.row-title` over `.row-meta`. + * + * Hidden from assistive technology: the `Loading` beside it is what speaks, + * and a list of blank shapes has nothing to say. + */ +export function SkeletonRows({ count }: { count: number }) { return ( -
-

{title}

- {action ?
{action}
: null} +