diff --git a/.agents/skills/create-verification-skill/NOTICE.md b/.agents/skills/create-verification-skill/NOTICE.md new file mode 100644 index 00000000000..68ab4a95a19 --- /dev/null +++ b/.agents/skills/create-verification-skill/NOTICE.md @@ -0,0 +1,47 @@ +# create-verification-skill attribution + +This skill comes from Paperclip's portable pstack fork in +`paperclipai/agent-plugins`, commit +`410257663e0b344df656e604f1601aa1db95f7e4`, under `vendor/pstack/`. +The fork imported pstack 0.15.15 by Lauren Tan (poteto) from +[cursor/plugins](https://github.com/cursor/plugins/tree/ccb5507cec1546dc88135c1139c811e6c59115ba/pstack), +upstream revision `ccb5507cec1546dc88135c1139c811e6c59115ba`. + +Paperclip adapts the workflow to the sibling `maps/feature-map/` and +`maps/agent-access-map/` directories and their shared access contract. +It preserves the invocation policy in `agents/openai.yaml`, removes the +Cursor-specific `disable-model-invocation` frontmatter field, and omits the +separate runtime skill dependency because Paperclip supplies execution. +No upstream Cursor built-in skills are redistributed. + +The imported resources include `SKILL.md`, `agents/openai.yaml`, and all three +files in `references/feature-map-example/`. Keep those examples with the skill +when copying or refreshing it. + +## License + +The original license is reproduced verbatim below. + +```text +MIT License + +Copyright (c) 2026 Lauren Tan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` diff --git a/.agents/skills/create-verification-skill/SKILL.md b/.agents/skills/create-verification-skill/SKILL.md new file mode 100644 index 00000000000..782513a549b --- /dev/null +++ b/.agents/skills/create-verification-skill/SKILL.md @@ -0,0 +1,57 @@ +--- +name: create-verification-skill +description: "Generate a project-local verification skill that drives your app the way a user does — any language, framework, or platform. Use for /create-verification-skill, \"make a control skill for this repo\", or when a project has no scripted way to prove UI/CLI/service behavior." +--- + +# Create a verification skill + +Every serious project needs a scripted way to drive the real app and prove behavior: launch it, exercise a feature the way a user would, and capture evidence. This skill generates that as a project-local skill (`.agents/skills/verify-/`) tailored to the repo. You write the generator's output for the next agent, not for a human: it will be read cold, mid-task, by an agent that has never seen the app. + +## 1. Interview the repo, not the user + +Answer these from the codebase and only ask the user what you cannot observe: + +- **Surface:** what does a user actually touch? A web UI, a CLI/TUI, a desktop app, an API, a mobile app, a library? A repo can have several; pick the primary one and note the rest. +- **Run:** how does the app start locally? Prefer the repo's own documented dev command (package scripts, Makefile, README quickstart). Note ports, env vars, seed data, auth. +- **Drive:** how can an agent interact with it programmatically? Existing harnesses first — Playwright/Cypress specs, expect scripts, PTY helpers, curl-able endpoints, a debug port. Only then pick a generic recipe: browser/CDP for web and Electron, a tmux/PTY harness for CLI/TUI, plain HTTP for services. +- **Observe:** what evidence can be captured? Screenshots, terminal transcripts, response bodies, logs, exit codes, DB state. +- **Isolate:** can two instances run side by side (ports, data dirs, profiles)? If not, say so in the generated skill: refusing to double-drive a shared instance beats corrupting the user's session. + +If the checkout doesn't build or start as-is, fix that first (or report it precisely) before generating; a skill written against a broken base teaches wrong steps. When an irrelevant missing asset blocks startup (a static dir the API never serves, a sample config), the generated skill may create it, clearly marked as verification scaffolding, and remove it in cleanup. + +## 2. Generate the skill + +Write `.agents/skills/verify-/SKILL.md` with YAML frontmatter (`name: verify-` and a `description` that names the app, the surface, and when to reach for it — without frontmatter the skill never registers) and these sections, each grounded in what the interview actually found (no placeholders left): + +- **Launch:** the exact command that starts the app for verification, and how to tell it's ready (a log line, a port answering, a prompt). Include teardown. For a short-lived CLI or TUI there is no server to keep alive: launch means build the binary (or install deps) once, then start each drive in its own isolated PTY or tmux session. +- **Doctor:** one read-only check that answers "is this instance worth driving?" — process up, right version/build, port owned by us, auth valid. An agent runs this first whenever anything looks off. +- **Drive:** the harness recipe with real selectors/commands from this repo, not examples. Prefer stable handles (ARIA labels, data attributes, prompt strings, route paths) over coordinates and tab order. +- **Evidence:** what to capture for a proof and where it goes. State the proof standards: exercise the real user path, not internal setters or test-only endpoints; capture the action and the resulting state, not just the final screen; verify side effects (files written, rows inserted, messages sent) alongside what's visible; mocks only where a production boundary already isolates the external system. When the safe path is a dry-run or test mode, verify what it actually skips by observing (files, network, git refs) rather than trusting its name: some dry-runs still touch the network or open a browser. +- **Cleanup:** how to tear down instances the run created. Never kill by process name; kill what you started. Cleanup removes instances and scratch state, never the evidence: proof artifacts survive the teardown, in a location the skill names. +- **Helpers:** any script the skill ships is executable and its invocation is shown in the skill body. A helper the reader has to reverse-engineer is not a helper. + +## 3. Seed the feature map + +For Paperclip, read the [map contract](../../../maps/README.md) and use the sibling +`maps/feature-map/` and `maps/agent-access-map/` directories. The generated +verification skill consumes both maps and reports drift; maintenance owns their +updates. Reuse existing feature files and preserve Poteto's product format. Every +new feature file needs a same-named agent access companion with matching stable +sub-feature IDs. Record APIs, actual agent entry points, policy, evidence and gaps +using the contract. Mark representative source inventory as `baseline` / +`source-only`; enumerate operations before claiming full access coverage. Reuse +Paperclip's APIs, admitted tools and existing verification infrastructure. Run +`node scripts/check-maps.mjs` after map changes. Do not create maps inside a skill +directory or generate a second runtime. + +For other projects, create `.agents/skills/verify-/features/README.md` plus one file per user-facing feature you can identify (aim for the top 3-5 to start, from routes, commands, menus, or docs). Follow the shape in [`references/feature-map-example/`](references/feature-map-example/), with a README index and one file per feature. Each file answers, from the user's point of view: what the feature is, how to reach it, how to drive it with the harness, and what observable end state proves it works. The four H2s are `Sub-features`, `How to get to it (user POV)`, `Driving it with `, and `Gotchas`. The map is the repo's maintained verification source; a proof that drives one convenient entry point is incomplete when the map lists others. + +## 4. Prove the generated skill before handing it over + +Run its own instructions end to end once: launch, doctor, drive ONE mapped feature (one is enough; the map exists so later runs can cover the rest), capture evidence, clean up. After cleanup, confirm the evidence still exists at the named location — a cleanup that eats the proof fails this step. Fix what fails, and run the generated cleanup after every failed iteration too, so broken attempts don't strand processes and ports. A generated skill that was never executed is a draft, not a deliverable. + +## 5. Offer the maintenance loop + +Point the user at `/maintain-verification-skill` for keeping the map honest as the app changes. Suggest a cadence only if they ask. + +Origin and license: [NOTICE.md](NOTICE.md). diff --git a/.agents/skills/create-verification-skill/agents/openai.yaml b/.agents/skills/create-verification-skill/agents/openai.yaml new file mode 100644 index 00000000000..5b1f887a913 --- /dev/null +++ b/.agents/skills/create-verification-skill/agents/openai.yaml @@ -0,0 +1,2 @@ +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/create-verification-skill/references/feature-map-example/README.md b/.agents/skills/create-verification-skill/references/feature-map-example/README.md new file mode 100644 index 00000000000..fb64570cfab --- /dev/null +++ b/.agents/skills/create-verification-skill/references/feature-map-example/README.md @@ -0,0 +1,47 @@ +# Notes verification map + +This directory is the maintained source for verifying the user-facing behavior of Notes. Read the index before driving the app, then use the matching feature file as the recipe. + +## Baseline preconditions + +- Launch Notes at `http://127.0.0.1:4173` with a disposable data directory. +- Set `NOTES_DATA_DIR=/tmp/notes-verify-$RUN_ID` so concurrent runs do not share state. +- Seed notes titled `Quarterly plan` and `Grocery list`. +- Put `control-notes` and the `notes` CLI on `PATH`. +- Run `control-notes doctor` and require the expected URL, data directory, and build revision. +- Never drive an instance that was not started by this verification run. + +## Driving conventions + +- Start every recipe from the baseline state unless its preconditions say otherwise. +- Prefer ARIA roles and accessible names over CSS selectors or DOM position. +- Treat every command as literal. Keep quoted names and flags unchanged. +- Run browser actions through `control-notes browser`. +- Run terminal actions through `control-notes cli -- `. +- Restore seeded data after a mutation. Do not remove proof artifacts during cleanup. + +## Proof and skip reporting + +- Capture the user action and the resulting state, not only the final screen. +- UI proof includes an ARIA snapshot and a screenshot with the app identity visible. +- CLI proof includes the command, stdout, stderr, and exit code. +- Mutation proof includes a read-only second view of the stored value. +- Record the feature ID and entry point used with every artifact. +- Report an unreachable path with the attempted command and the unmet precondition. +- Do not report a skipped entry point as verified through a different path. + +## Feature entry contract + +Each feature file starts with an H1 title and one paragraph describing the user-visible behavior. It then uses exactly four H2 sections in this order. + +1. `Sub-features` lists short IDs with one line for each behavior. +2. `How to get to it (user POV)` lists every user entry point. +3. `Driving it with ` starts with `Preconditions:` and uses labeled bullets that pair each user action with an exact command and observable result. +4. `Gotchas` lists traps that can waste or invalidate a verification run. + +Keep implementation details out of the map. Name only user paths, stable handles, required state, commands, and observable proof. + +## Features + +- [Create a note](./create-note.md) covers browser and CLI creation, cancellation, persistence, and cleanup. +- [Search notes](./search.md) covers toolbar, keyboard, and CLI search with matching, empty, and clear states. diff --git a/.agents/skills/create-verification-skill/references/feature-map-example/create-note.md b/.agents/skills/create-verification-skill/references/feature-map-example/create-note.md new file mode 100644 index 00000000000..21357566b69 --- /dev/null +++ b/.agents/skills/create-verification-skill/references/feature-map-example/create-note.md @@ -0,0 +1,39 @@ +# Create a note + +Create note lets a user save a titled note from the browser or CLI, cancel an unfinished draft, and confirm the saved note from a second user-facing view. + +## Sub-features + +- `create-open` opens a blank editor from each browser entry point. +- `create-save` persists a title and body. +- `create-cancel` discards an unfinished browser draft. +- `create-cli` creates the same note shape from the terminal. + +## How to get to it (user POV) + +- Choose the `New note` button in the browser toolbar. +- Press `n` in the browser while focus is outside an editable field. +- Run `notes create --title --body <body>` in a terminal. + +## Driving it with control-notes + +Preconditions: + +- Notes is healthy at `http://127.0.0.1:4173`. +- No note is titled `Release checklist`. +- `control-notes doctor` reports the expected URL and disposable data directory. + +- **Open editor.** Choose `New note`. Run `control-notes browser click --role button --name "New note"`. A form named `Note editor` appears with focus in the `Title` textbox. +- **Enter content.** Type the title and body. Run `control-notes browser fill --role textbox --name "Title" --value "Release checklist"` and `control-notes browser fill --role textbox --name "Body" --value "Tag and publish"`. The `Save note` button becomes enabled. +- **Save note.** Choose `Save note`. Run `control-notes browser click --role button --name "Save note"`. A status named `Note saved` appears and the heading reads `Release checklist`. +- **Confirm persistence.** Return to the note list and reopen the note. Run `control-notes browser click --role link --name "All notes"` and `control-notes browser click --role link --name "Release checklist"`. The editor shows both saved values. +- **Cancel draft.** Open a new note, enter `Discard me`, and choose `Cancel`. Run `control-notes browser click --role button --name "New note"`, `control-notes browser fill --role textbox --name "Title" --value "Discard me"`, and `control-notes browser click --role button --name "Cancel"`. The note list returns and has no `Discard me` link. +- **CLI entry.** Create a second note. Run `control-notes cli -- notes create --title "CLI note" --body "Created from terminal" --format json`. Exit code `0` and stdout contain the new note ID and title. +- **Proof.** Reopen both saved notes from `All notes`. Run `control-notes browser snapshot --aria --path artifacts/create-note/list.aria.txt` and `control-notes browser screenshot --path artifacts/create-note/list.png`. The artifacts show `Release checklist` and `CLI note`. + +## Gotchas + +- Pressing `n` while a textbox has focus types the character instead of opening a new editor. +- Titles are trimmed on save. Assert the rendered title, not the draft input value. +- A save status alone is insufficient proof. Reopen the note from the list. +- Remove `Release checklist` and `CLI note` during fixture cleanup, but retain their proof artifacts. diff --git a/.agents/skills/create-verification-skill/references/feature-map-example/search.md b/.agents/skills/create-verification-skill/references/feature-map-example/search.md new file mode 100644 index 00000000000..1f8e57d3aaf --- /dev/null +++ b/.agents/skills/create-verification-skill/references/feature-map-example/search.md @@ -0,0 +1,45 @@ +# Search notes + +Search lets a user find notes by title or body text, inspect a matching note, and distinguish no matches from an unavailable search. + +## Sub-features + +- `search-open` opens search from each supported browser entry point. +- `search-match` returns title and body matches without changing note data. +- `search-open-result` opens a result in the note editor. +- `search-empty` shows a complete empty state for a query with no matches. +- `search-clear` removes the query and restores the recent-notes view. +- `search-cli` returns the same matching notes from the terminal. + +## How to get to it (user POV) + +- Choose the `Search` button in the browser toolbar. +- Press `/` in the browser while focus is outside an editable field. +- Run `notes search <query>` in a terminal. + +## Driving it with control-notes + +Preconditions: + +- Notes is healthy at `http://127.0.0.1:4173`. +- The disposable data directory contains `Quarterly plan` with body text `Draft budget`. +- `control-notes doctor` reports the expected URL and data directory. + +- **Toolbar entry.** Choose the `Search` button. Run `control-notes browser click --role button --name "Search"`. A dialog named `Search notes` appears with focus in its searchbox. +- **Keyboard entry.** Close the dialog, focus the page, and press `/`. Run `control-notes browser press --key "/"`. The same dialog appears and the page does not insert a slash. +- **Title match.** Type `quarterly`. Run `control-notes browser fill --role searchbox --name "Search notes" --value "quarterly"`. The `Search results` list contains `Quarterly plan` and does not contain `Grocery list`. +- **Body match.** Replace the query with `budget`. Run `control-notes browser fill --role searchbox --name "Search notes" --value "budget"`. The result `Quarterly plan` remains visible with a body-match excerpt. +- **Open result.** Choose `Quarterly plan`. Run `control-notes browser click --role link --name "Quarterly plan"`. The dialog closes and the editor heading reads `Quarterly plan`. +- **Empty state.** Reopen search and enter `volcano`. Run `control-notes browser fill --role searchbox --name "Search notes" --value "volcano"`. A status named `No matching notes` appears after search completes. +- **Clear query.** Choose `Clear search`. Run `control-notes browser click --role button --name "Clear search"`. The searchbox is empty and the `Recent notes` region replaces the result list. +- **CLI match.** Search from the terminal. Run `control-notes cli -- notes search "quarterly" --format json`. Exit code `0` and stdout contain one object whose title is `Quarterly plan`. +- **CLI miss.** Search for an absent value. Run `control-notes cli -- notes search "volcano" --format json`. Exit code `0` and stdout are `[]`. +- **Proof.** Capture the populated result state. Run `control-notes browser snapshot --aria --path artifacts/search/results.aria.txt` and `control-notes browser screenshot --path artifacts/search/results.png`. Both artifacts identify Notes, the query, and `Quarterly plan`. + +## Gotchas + +- Pressing `/` while the editor or searchbox has focus inserts text instead of opening search. +- Results update after a short debounce. Wait for the results list or empty status, not a fixed sleep. +- Archived notes are excluded unless the user enables `Include archived`. +- The CLI defaults to human-readable output. Use `--format json` for stable assertions. +- Opening a result changes browser state. Reopen search before proving another query. diff --git a/.agents/skills/maintain-verification-skill/NOTICE.md b/.agents/skills/maintain-verification-skill/NOTICE.md new file mode 100644 index 00000000000..120888c9db5 --- /dev/null +++ b/.agents/skills/maintain-verification-skill/NOTICE.md @@ -0,0 +1,47 @@ +# maintain-verification-skill attribution + +This skill comes from Paperclip's portable pstack fork in +`paperclipai/agent-plugins`, commit +`410257663e0b344df656e604f1601aa1db95f7e4`, under `vendor/pstack/`. +The fork imported pstack 0.15.15 by Lauren Tan (poteto) from +[cursor/plugins](https://github.com/cursor/plugins/tree/ccb5507cec1546dc88135c1139c811e6c59115ba/pstack), +upstream revision `ccb5507cec1546dc88135c1139c811e6c59115ba`. + +Paperclip adapts the workflow to the sibling `maps/feature-map/` and +`maps/agent-access-map/` directories and their shared access contract. +It preserves the invocation policy in `agents/openai.yaml`, removes the +Cursor-specific `disable-model-invocation` frontmatter field, and omits the +separate runtime skill dependency because Paperclip supplies execution. +No upstream Cursor built-in skills are redistributed. + +The imported resources include `SKILL.md` and `agents/openai.yaml`. +The workflow uses the examples shipped with +[`create-verification-skill`](../create-verification-skill/references/feature-map-example/). + +## License + +The original license is reproduced verbatim below. + +```text +MIT License + +Copyright (c) 2026 Lauren Tan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` diff --git a/.agents/skills/maintain-verification-skill/SKILL.md b/.agents/skills/maintain-verification-skill/SKILL.md new file mode 100644 index 00000000000..8d261ef3fb4 --- /dev/null +++ b/.agents/skills/maintain-verification-skill/SKILL.md @@ -0,0 +1,124 @@ +--- +name: maintain-verification-skill +description: "Keep a project's verification skill and product/agent access maps aligned with current source and real evidence. Use for /maintain-verification-skill, auditing the verify skill, or reconciling feature and agent access coverage." +--- + +# Maintain a verification skill and its maps + +Keep user journeys and agent access aligned with the app. For Paperclip, read the +[map contract](../../../maps/README.md), the +[feature index](../../../maps/feature-map/README.md), and the +[agent access index](../../../maps/agent-access-map/README.md). These are sibling +maps under `maps/`, with the same area filenames and capability IDs. Preserve +[Poteto's product format](../create-verification-skill/references/feature-map-example/). +Agent companions follow the shared contract; they do not duplicate user recipes. + +An API endpoint, an OpenAPI actor label, a declared scenario tool and an admitted +production agent path are different evidence. Verify which permitted agent +surface can actually express an operation. Track source review, operation review +and executed proof separately. A source-only baseline is useful, but cannot +certify complete coverage or live behavior. + +## Outcomes + +Pick one and report source scope, live scope and remaining gaps separately: + +- **clean** — the requested scope got source and live coverage, with no corrections. +- **changed** — proven skill/map/check corrections are ready to review; name any + access gaps or unexecuted coverage that remains. +- **blocked** — requested coverage or a proven correction could not finish; name + the unmet prerequisite and evidence captured so far. + +A targeted audit covers its named areas; do not present it as a full pass. A full +pass reads every area from source and exercises every reachable feature, plus +relevant permitted-agent and denial paths. Report unreachable prerequisites and +not-run operations rather than silently reducing the scope. + +## Edit scope + +For Paperclip, edit the target verification skill, these maintenance instructions +when needed, `maps/`, and the map-specific structural check +`scripts/check-maps.mjs` / `scripts/check-maps.test.mjs`. Fix references to those +maps in contributor docs or skill instructions when their locations/IDs change. +Keep the maps in `maps/{feature-map,agent-access-map}/`, outside skill directories. + +For other projects, maintain their existing verification skill and map locations; +the Paperclip directory convention does not authorize relocating another repo. +Never edit product APIs, permission enforcement, tool bindings or UI behavior +in a maintenance run. Record product/access gaps for implementation separately; +a permission denial is not authorization to widen access. + +## Pass + +0. **Locate the target and scope.** Find the project-local verification skill + (usually `.agents/skills/verify-*/`). Several candidates: ask which; none: + point to `/create-verification-skill`. Honor a requested narrower audit. + Record the source commit and the last review for affected map entries. + +1. **Check both indexes.** Pair same-named feature and agent files, then match + stable sub-feature IDs. Fix orphan/missing entries and broken references. + For Paperclip run `node scripts/check-maps.mjs`. This checks structure, + not API authorization, real tool bindings or live behavior. + +2. **Source wave.** Use read-only subagents per area, with each reader owning + the feature file and its access companion; schedule within available slots. + Readers never edit or drive the app. Each returns the user capability/source + entry points, likely drift with citations, meaningful operations, their APIs, + actor/grant/resource/run/mode boundaries, real agent entry points by surface, + missing access/discovery/policy decisions, and a concise verification recipe. + Follow recent changes to routes, contracts, authorization, native tool + admission, CLI and MCP as well as UI. Inspect actual route/service policy; + compare it with OpenAPI and production bindings, not only scenario catalogs. + +3. **Reconcile both maps.** Spot-check cited drift and merge recipes into as + few app states as practical. Every affected business operation needs an API + and a permitted agent path, or an explicit gap. A dedicated tool is optional + when existing API/CLI/MCP fallback suffices. Distinguish native/remote access + from legacy REST and personal assistant OAuth. Record field/scope limits, + unavailable clients, protocol requirements and native fallback restrictions. + Keep justified local/developer/operator exceptions, but split shared data + operations from presentation. A board-only route records a restriction and + any existing request/result handoff; an unresolved delegation question stays + visible. Split broad baselines into per-operation records before marking + them operation-reviewed. Add newly discovered capabilities only with concrete + source; add their companion at the same time. + +4. **Live pass.** Required for a verification maintenance pass even if source + looks clean. The coordinator owns serial driving, using the target skill's + existing launch/doctor/evidence/cleanup workflow and Paperclip's existing + API/runner/MCP fixtures. Exercise the real user path and the relevant actual + agent path separately. For access checks use the permitted agent identity, + run, mode and target; assert results and relevant denials. A board request or + board test-as-agent does not replace agent-credential proof. Use disposable + fixtures for mutation/denial tests and record simulated providers honestly. + Never assume an optional client is admitted to a native run. + + Keep three invariants: health-check before driving and after surprises; + verify captured evidence survives cleanup; clean up everything the drive + owned once no longer needed. Reset a wedged UI even if process health passes. + Follow the target skill's session model: one long-lived owned server/UI + instance, or isolated short-lived CLI sessions. After a skill-induced failure, + fix within edit scope and retry once, restarting only what was invalidated. + Record missing auth/provider/environment prerequisites and attempted paths. + Re-drive changed harness helpers before shipping them. Final teardown follows + the last proof attempt and preserves evidence. + +5. **Triage and record.** User description/recipe drift updates the product map; + endpoint/path/permission/discovery drift updates its companion. Missing or + broken product behavior stays a product gap, with evidence. Never rewrite a + recipe or mark an access exception to bless a failure. Map evidence states + remain source-only/not-run unless an actual proof supports the named operation + and surface. Keep run artifacts/results in the evidence location or issue; + `maps/feature-map/coverage.json` remains documentation scope. + +6. **Review corrections.** Re-read changed files, check paired IDs/references and + run the smallest relevant validation. Report outcome and source/live coverage + honestly. If shipping is requested or already authorized, update the existing + task PR where appropriate, or create at most one PR of proven corrections. + A clean pass needs no branch or PR. Do not implement access-policy changes + merely to make the maintenance report green. + +Keep concise run notes and proof artifacts in the target skill's named evidence +location; do not commit ephemeral run logs as map content. + +Origin and license: [NOTICE.md](NOTICE.md). diff --git a/.agents/skills/maintain-verification-skill/agents/openai.yaml b/.agents/skills/maintain-verification-skill/agents/openai.yaml new file mode 100644 index 00000000000..5b1f887a913 --- /dev/null +++ b/.agents/skills/maintain-verification-skill/agents/openai.yaml @@ -0,0 +1,2 @@ +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/verify-paperclip/SKILL.md b/.agents/skills/verify-paperclip/SKILL.md new file mode 100644 index 00000000000..b16467140a0 --- /dev/null +++ b/.agents/skills/verify-paperclip/SKILL.md @@ -0,0 +1,170 @@ +--- +name: verify-paperclip +description: Launch an isolated Paperclip instance, drive its real web UI or CLI using the existing feature-map recipes, capture evidence, and clean up owned processes. Use to verify user-facing Paperclip behavior. +--- + +# Verify Paperclip + +Read the [map contract](../../../maps/README.md), then use the +[feature map](../../../maps/feature-map/README.md) to select user verification +recipes. For API/agent access, read the same-named +[agent access companion](../../../maps/agent-access-map/README.md) and use the +actual permitted agent identity, target and work mode. Follow existing API/runner/MCP +fixtures for access proof; a board request does not prove agent access. Report +recipe or access drift with evidence; map updates belong to +[maintain-verification-skill](../maintain-verification-skill/SKILL.md). +Read [development instructions](../../../doc/DEVELOPING.md) for current commands. + +## Launch + +Run from the repository root with Node 24.11+, Python 3, `lsof`, and dependencies +installed with `pnpm install --frozen-lockfile`. Browser driving uses the existing +`@playwright/test` dependency. If Chromium is unavailable, run +`node node_modules/@playwright/test/cli.js install chromium`, or select an installed +browser with `PAPERCLIP_PLAYWRIGHT_CHANNEL=chrome`. + +Create separate, unique data and evidence directories. Keep the evidence outside +the data directory: + +```sh +VERIFY_DATA_DIR=$(mktemp -d "${TMPDIR:-/tmp}/paperclip-verify-XXXXXX") +VERIFY_EVIDENCE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/paperclip-proof-XXXXXX") +python3 .agents/skills/verify-paperclip/scripts/launch.py \ + --data-dir "$VERIFY_DATA_DIR" --evidence-dir "$VERIFY_EVIDENCE_DIR" +``` + +Keep this command in a foreground terminal/session. The helper runs the supported +`onboard --yes --bind loopback --no-install-service --run` command, with isolated +embedded PostgreSQL, a free loopback port, no inherited instance routing or cwd +`.env`, and no automatic browser opening. It records `launch.json` in the evidence +directory. Source startup builds its required shared/plugin packages and migrates +the empty database; readiness can take longer on the first run. + +This path starts an empty instance without model credentials. Create company and +agent state through the journey being checked. A UI check that stops before model +execution does not prove an agent run. For a preconfigured company/CEO and live +model work, use the documented [test-drive](../../../doc/DEVELOPING.md#one-command-isolated-manual-test-drive) +command with an authorized environment-backed credential and `--no-browser`. +Record its printed data directory, URL, runtime PID and commit before driving; +never use a fake credential to claim provider verification. + +For Paperclip-managed reusable services, follow +[dev-workspace run/verify/fix](../paperclip-dev-workspace-run-verify-fix/SKILL.md) +instead of launching a second server. Its cloned-data/login requirements differ +from this empty local instance. + +## Doctor + +After startup, and again after any surprising behavior, run: + +```sh +python3 .agents/skills/verify-paperclip/scripts/doctor.py \ + --evidence-dir "$VERIFY_EVIDENCE_DIR" +``` + +This read-only check compares the recorded launcher, runtime PID, port owner, +process cwd, configuration, served Git commit, health/bootstrap readiness, root +HTML response, and company-list access. It writes `doctor.json` or exits nonzero. +Do not trust a root 200 or CLI doctor output alone. The CLI doctor checks whether +the port is available before startup; it is not proof of a running server's +ownership. Browser readiness still requires the expected visible page. Reset or +relaunch a wedged browser even when the process health passes. + +## Drive + +Use accessible roles, labels, and placeholders from the latest page state. +Existing Playwright specs in `tests/e2e/` supply recipes and selectors; their +configured servers and fixtures are separate from this live instance. Run a named +suite through its own configuration only when that is the evidence needed. + +The executable onboarding smoke drives `onboarding.md` / `web-wizard`: open +`/onboarding` in the disposable self-hosted instance, +enter a unique organization name, choose Continue, reach the agent-name field, +reopen `/<company-prefix>/onboarding`, reload, and corroborate the saved company +through read-only API checks: + +```sh +node .agents/skills/verify-paperclip/scripts/onboarding-smoke.mjs \ + --evidence-dir "$VERIFY_EVIDENCE_DIR" +``` + +This covers company creation and setup resumption, with no mocked responses or +provider calls. Hiring, first task execution, and the alternate CLI entry remain +separate journeys. The script requires an empty instance and refuses a non-owned +or non-loopback target. For other features, reuse the owned URL and follow the +map's entry points. Company routes normally require the selected company's +`issuePrefix`, for example `/<PREFIX>/goals`; use current navigation rather than +guessing resource URLs. The unprefixed `/onboarding` route starts naming; use the +company-prefixed onboarding route to resume its agent setup. Check relevant +experimental gates and actor permissions. + +For CLI checks, discover exact flags with +`node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts <command> --help`, then +select the disposable data directory/API/company explicitly. API reads can +corroborate UI writes; API-created state does not prove a creation UI. + +## Evidence + +Record feature file, sub-feature and entry-point ID, commit/dirty state, owned URL, +company/user, flags, runtime/harness, live versus simulated dependencies, command, +exit code, expected and observed behavior, and evidence links. Report each entry +as passed, failed, blocked with its prerequisite, or not run. + +The smoke saves before/after/reload screenshots, visible-page text, a Playwright +trace, and `result.json` with company ID and API corroboration. On failure it +retains the trace, visible state, and failure result; rerun doctor before retrying. +Preserve launch output and relevant application logs without secrets. Reopen or +reload changed state and verify persistence/continuation, not just a success toast. +One passing sub-feature does not certify the entire feature family or other +surfaces. A full maintenance pass must exercise every mapped feature or report +incomplete coverage. + +For Paperclip-assigned work, attach important proof through +[the artifact workflow](../../../doc/AGENT-ARTIFACTS.md). Keep runtime results in +the evidence directory or issue, not in the map; `coverage.json` describes +documentation coverage rather than live test health. + +## Cleanup + +Close the browser context you created (the smoke does this in `finally`). Stop the owned runtime with: + +```sh +python3 .agents/skills/verify-paperclip/scripts/stop.py \ + --evidence-dir "$VERIFY_EVIDENCE_DIR" +``` + +The helper verifies the runtime's port, cwd, and launch ancestry before sending +SIGTERM to that PID, then waits for runtime exit and checks that its HTTP +listener and embedded database have stopped. Signaling the runtime PID lets the server drain before stopping +PostgreSQL; a terminal group interrupt can reach the database first. Do this after +failed drives as well as successful runs. If startup fails before a runtime record +exists, stop only the foreground session you created and inspect its recorded data +directory for residue. Never kill by process name or stop another checkout's server. + +Verify the recorded HTTP port has no listener and the owned runtime PID has +exited. Embedded PostgreSQL should stop with the server; inspect only the recorded +data directory if shutdown is incomplete. Retain the data directory for diagnosis +until no owned process uses it; remove only that exact temporary directory when +it is no longer useful. Never delete the evidence directory during teardown. +Confirm screenshots, trace, `result.json`, `launch.json`, and `doctor.json` still +exist after shutdown. A failed launch leaves its evidence intact. + +## Helpers + +All helper invocations are shown above. `launch.py`, `doctor.py`, and `stop.py` use Python's +standard library and the repo CLI; `onboarding-smoke.mjs` uses the repo's existing +Playwright dependency. They introduce no new package dependency or test framework. + +## Helper safety check + +Run the standard-library ownership test before changing launch/doctor/stop helpers: + +```sh +python3 -m unittest discover -s .agents/skills/verify-paperclip/tests -v +``` + +It starts an owned fixture in another temporary checkout and confirms that both +doctor and stop reject its claimed port/PID without stopping the server. A second +fixture closes its listener before exiting and proves that cleanup waits for +runtime exit. These checks do +not qualify Paperclip startup, database readiness, or onboarding. diff --git a/.agents/skills/verify-paperclip/scripts/doctor.py b/.agents/skills/verify-paperclip/scripts/doctor.py new file mode 100755 index 00000000000..35a7a8a366a --- /dev/null +++ b/.agents/skills/verify-paperclip/scripts/doctor.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Read-only identity and readiness check for an owned verification launch.""" +import argparse +import json +import os +from pathlib import Path +import subprocess +import sys +import urllib.request + +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument('--evidence-dir', required=True, type=Path) +args = parser.parse_args() +evidence = args.evidence_dir.resolve() +try: + launch = json.loads((evidence / 'launch.json').read_text()) + repo = Path(launch['repo']).resolve() + assert repo == Path(__file__).resolve().parents[4], 'Launch belongs to another checkout.' + runtime = json.loads((Path(launch['dataDir']) / 'instances/default/runtime-info.json').read_text()) + assert runtime['dashboardUrl'] == launch['url'], 'Runtime URL changed.' + assert runtime['port'] == launch['port'], 'Runtime port changed.' + assert runtime['host'] == '127.0.0.1', 'Expected trusted loopback.' + pid = runtime['pid'] + os.kill(pid, 0) + listeners = subprocess.check_output(['lsof', '-nP', f'-iTCP:{launch["port"]}', + '-sTCP:LISTEN', '-t'], text=True).split() + assert set(listeners) == {str(pid)}, 'Port owner does not match runtime PID.' + if sys.platform == 'darwin': + rows = subprocess.check_output(['lsof', '-a', '-p', str(pid), '-d', 'cwd', '-Fn'], text=True).splitlines() + cwd = Path(next(row[1:] for row in rows if row.startswith('n'))).resolve() + else: + cwd = Path(f'/proc/{pid}/cwd').resolve(strict=True) + assert cwd == repo or repo in cwd.parents, 'Port owner cwd belongs to another checkout.' + ancestor = pid + seen = set() + while ancestor != launch['launcherPid']: + assert ancestor > 1 and ancestor not in seen, 'Runtime is not descended from this launch.' + seen.add(ancestor) + ancestor = int(subprocess.check_output(['ps', '-p', str(ancestor), '-o', 'ppid='], text=True).strip()) + config = json.loads((Path(launch['dataDir']) / 'instances/default/config.json').read_text()) + assert config['database']['mode'] == 'embedded-postgres', 'Expected isolated embedded database.' + assert Path(config['database']['embeddedPostgresDataDir']).resolve().is_relative_to(Path(launch['dataDir'])), 'Database path escaped isolation.' + with urllib.request.urlopen(launch['url'] + '/api/health', timeout=15) as response: + health = json.load(response) + assert health['status'] == 'ok' and health['bootstrapStatus'] == 'ready', 'Instance is not ready.' + assert health['deploymentMode'] == 'local_trusted' and health['authReady'], 'Unexpected auth mode.' + commit = subprocess.check_output(['git', '-C', str(repo), 'rev-parse', 'HEAD'], text=True).strip() + assert health['commit'] == launch['commit'] == commit, 'Served commit differs from checkout/launch.' + with urllib.request.urlopen(launch['url'], timeout=15) as response: + assert response.status == 200 and 'text/html' in response.headers.get('Content-Type', ''), 'Root UI is unavailable.' + with urllib.request.urlopen(launch['url'] + '/api/companies', timeout=15) as response: + companies = json.load(response) + assert isinstance(companies, list), 'Board company access failed.' + result = {'status': 'passed', 'pid': pid, 'cwd': str(cwd), 'url': launch['url'], + 'health': health, 'companyIds': [company['id'] for company in companies]} + (evidence / 'doctor.json').write_text(json.dumps(result, indent=2) + '\n') + print(f'PASS: owned PID {pid}, {launch["url"]}, commit {commit}, ready board UI/API.') +except Exception as error: + (evidence / 'doctor-failure.json').write_text(json.dumps({'status': 'failed', 'error': str(error)}, indent=2) + '\n') + raise SystemExit(f'Doctor failed: {error}') diff --git a/.agents/skills/verify-paperclip/scripts/launch.py b/.agents/skills/verify-paperclip/scripts/launch.py new file mode 100755 index 00000000000..a7c0e575165 --- /dev/null +++ b/.agents/skills/verify-paperclip/scripts/launch.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Run the repository's credential-free isolated foreground onboarding path.""" +import argparse +import json +import os +from pathlib import Path +import socket +import subprocess + +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument('--data-dir', required=True, type=Path) +parser.add_argument('--evidence-dir', required=True, type=Path) +args = parser.parse_args() +repo = Path(__file__).resolve().parents[4] +data = args.data_dir.resolve() +evidence = args.evidence_dir.resolve() +if data == evidence or data in evidence.parents or evidence in data.parents: + parser.error('Data and evidence directories must be separate.') +data.mkdir(parents=True, exist_ok=True) +evidence.mkdir(parents=True, exist_ok=True) +if any(data.iterdir()) or (evidence / 'launch.json').exists(): + parser.error('Use a fresh empty data directory and a new evidence directory.') +cli = repo / 'cli/node_modules/tsx/dist/cli.mjs' +if not cli.is_file(): + parser.error('Install checkout dependencies first: pnpm install --frozen-lockfile') +for port in range(3100, 4100): + try: + with socket.socket() as probe: + probe.bind(('127.0.0.1', port)) + break + except OSError: + continue +else: + raise RuntimeError('No available verification port between 3100 and 4099.') +excluded = {'DATABASE_URL', 'DATABASE_MIGRATION_URL', 'HOST', 'PORT', 'SERVE_UI', + 'BETTER_AUTH_URL', 'BETTER_AUTH_BASE_URL', 'NODE_OPTIONS'} +env = {key: value for key, value in os.environ.items() + if not key.startswith('PAPERCLIP_') and key not in excluded} +env.update({'PAPERCLIP_HOME': str(data), 'PAPERCLIP_INSTANCE_ID': 'default', + 'PAPERCLIP_DISABLE_CWD_ENV_FILE': 'true', 'PAPERCLIP_NO_BROWSER': 'true', + 'PAPERCLIP_OPEN_ON_LISTEN': 'false', 'PAPERCLIP_UPDATE_CHECK': '0', + 'PAPERCLIP_TELEMETRY_DISABLED': '1', 'PAPERCLIP_DEPLOYMENT_MODE': 'local_trusted', + 'PAPERCLIP_DEPLOYMENT_EXPOSURE': 'private', 'PAPERCLIP_BIND': 'loopback', + 'HOST': '127.0.0.1', 'PORT': str(port)}) +record = {'dataDir': str(data), 'evidenceDir': str(evidence), 'repo': str(repo), + 'launcherPid': os.getpid(), 'port': port, 'url': f'http://127.0.0.1:{port}', + 'commit': subprocess.check_output(['git', '-C', str(repo), 'rev-parse', 'HEAD'], text=True).strip()} +(evidence / 'launch.json').write_text(json.dumps(record, indent=2) + '\n') +print(json.dumps(record), flush=True) +os.chdir(repo) +os.execvpe('node', ['node', str(cli), 'cli/src/index.ts', 'onboard', '--data-dir', + str(data), '--yes', '--bind', 'loopback', '--no-install-service', '--run'], env) diff --git a/.agents/skills/verify-paperclip/scripts/onboarding-smoke.mjs b/.agents/skills/verify-paperclip/scripts/onboarding-smoke.mjs new file mode 100755 index 00000000000..8ab6a471262 --- /dev/null +++ b/.agents/skills/verify-paperclip/scripts/onboarding-smoke.mjs @@ -0,0 +1,79 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { execFileSync } from 'node:child_process'; + +const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..'); +const require = createRequire(path.join(repo, 'package.json')); +const { chromium, expect } = require('@playwright/test'); +assert.equal(process.argv[2], '--evidence-dir', 'Usage: onboarding-smoke.mjs --evidence-dir <path>'); +assert.ok(process.argv[3]); +const evidence = path.resolve(process.argv[3]); +execFileSync('python3', [path.join(repo, '.agents/skills/verify-paperclip/scripts/doctor.py'), '--evidence-dir', evidence], { stdio: 'inherit' }); +const launch = JSON.parse(await fs.readFile(path.join(evidence, 'launch.json'), 'utf8')); +assert.equal(new URL(launch.url).hostname, '127.0.0.1'); +const browser = await chromium.launch({ headless: true, ...(process.env.PAPERCLIP_PLAYWRIGHT_CHANNEL ? { channel: process.env.PAPERCLIP_PLAYWRIGHT_CHANNEL } : {}) }); +const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, serviceWorkers: 'block' }); +const page = await context.newPage(); +const errors = []; +page.on('pageerror', error => errors.push(error.message)); +const companyName = `Verification ${Date.now()}`; +const result = { feature: 'onboarding.md', subFeatures: ['company', 'resume'], entryPoint: 'web-wizard', commit: launch.commit, url: launch.url, actor: 'local_trusted board', providerCalls: false, mockedResponses: false, companyName, status: 'failed' }; +await context.tracing.start({ screenshots: true, snapshots: true, sources: true }); +async function capture(name) { + await page.screenshot({ path: path.join(evidence, `${name}.png`), fullPage: true }); + await fs.writeFile(path.join(evidence, `${name}.txt`), await page.locator('body').innerText()); +} +try { + const before = await page.request.get(`${launch.url}/api/companies`); + assert.equal(before.ok(), true); + assert.deepEqual(await before.json(), [], 'Smoke requires a fresh instance.'); + await page.goto(`${launch.url}/onboarding`); + const start = page.getByRole('button', { name: /Start Onboarding|New Organization|Add Agent/ }); + if (await start.count()) await start.first().click(); + const create = page.getByRole('button', { name: /Build a new organization/ }); + if (await create.count()) await create.first().click(); + await expect(page.getByRole('heading', { name: 'What is the name of your organization?' })).toBeVisible({ timeout: 60_000 }); + await page.getByPlaceholder('e.g. Northwind Labs').fill(companyName); + await capture('before-create'); + await page.getByRole('button', { name: /^Continue/ }).click(); + await expect(page.locator('#onboarding-agent-name')).toBeVisible({ timeout: 30_000 }); + await capture('after-create'); + const companiesResponse = await page.request.get(`${launch.url}/api/companies`); + assert.equal(companiesResponse.ok(), true); + const companies = await companiesResponse.json(); + const company = companies.find(item => item.name === companyName); + assert.ok(company, 'UI-created company must be persisted.'); + result.companyId = company.id; + const goalsResponse = await page.request.get(`${launch.url}/api/companies/${company.id}/goals`); + assert.equal(goalsResponse.ok(), true); + assert.deepEqual(await goalsResponse.json(), [], 'Naming must not invent a mission.'); + await page.goto(`${launch.url}/${company.issuePrefix}/onboarding`); + await expect(page.locator('#onboarding-agent-name')).toBeVisible({ timeout: 30_000 }); + await capture('after-reopen'); + await page.reload(); + await expect(page.locator('#onboarding-agent-name')).toBeVisible({ timeout: 30_000 }); + await capture('after-reload'); + const persistedResponse = await page.request.get(`${launch.url}/api/companies`); + assert.equal(persistedResponse.ok(), true); + const persisted = await persistedResponse.json(); + assert.equal(persisted.length, 1, 'Resume must not create another company.'); + assert.equal(persisted[0].id, company.id); + assert.deepEqual(errors, [], 'No browser exceptions.'); + result.status = 'passed'; + result.observed = 'Company persisted; onboarding resumed at the agent step after reload; no mission invented.'; + result.notRun = ['agent hiring', 'first task/model execution', 'cli-setup entry point']; +} catch (error) { + result.error = error.message; + await capture('failure').catch(() => {}); + process.exitCode = 1; +} finally { + result.browserErrors = errors; + await context.tracing.stop({ path: path.join(evidence, 'trace.zip') }); + await browser.close(); + await fs.writeFile(path.join(evidence, 'result.json'), `${JSON.stringify(result, null, 2)}\n`); + console.log(JSON.stringify(result)); +} diff --git a/.agents/skills/verify-paperclip/scripts/stop.py b/.agents/skills/verify-paperclip/scripts/stop.py new file mode 100755 index 00000000000..cafc20f5a40 --- /dev/null +++ b/.agents/skills/verify-paperclip/scripts/stop.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Stop only an owned verification runtime and check that its database exits.""" +import argparse +import json +import os +from pathlib import Path +import signal +import subprocess +import sys +import time + +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument('--evidence-dir', required=True, type=Path) +args = parser.parse_args() +evidence = args.evidence_dir.resolve() +launch = json.loads((evidence / 'launch.json').read_text()) +repo = Path(launch['repo']).resolve() +assert repo == Path(__file__).resolve().parents[4], 'Launch belongs to another checkout.' +instance = Path(launch['dataDir']) / 'instances/default' +runtime_file = instance / 'runtime-info.json' +runtime = json.loads(runtime_file.read_text()) if runtime_file.exists() else None +pid = None +if runtime: + assert runtime['port'] == launch['port'] and runtime['dashboardUrl'] == launch['url'] + pid = runtime['pid'] + owners = subprocess.run(['lsof', '-nP', f'-iTCP:{launch["port"]}', '-sTCP:LISTEN', '-t'], capture_output=True, text=True).stdout.split() + assert set(owners) == {str(pid)}, 'Refusing to signal a different or unverified port owner.' + if sys.platform == 'darwin': + rows = subprocess.check_output(['lsof', '-a', '-p', str(pid), '-d', 'cwd', '-Fn'], text=True).splitlines() + cwd = Path(next(row[1:] for row in rows if row.startswith('n'))).resolve() + else: + cwd = Path(f'/proc/{pid}/cwd').resolve(strict=True) + assert cwd == repo or repo in cwd.parents, 'Refusing to signal another checkout.' + ancestor = pid + seen = set() + while ancestor != launch['launcherPid']: + assert ancestor > 1 and ancestor not in seen, 'Runtime is not descended from this launch.' + seen.add(ancestor) + ancestor = int(subprocess.check_output(['ps', '-p', str(ancestor), '-o', 'ppid='], text=True).strip()) + os.kill(pid, signal.SIGTERM) +def runtime_exited(): + if pid is None: + return True + try: + os.kill(pid, 0) + except ProcessLookupError: + return True + # An exited child can remain a zombie until its launcher reaps it. + state = subprocess.run(['ps', '-p', str(pid), '-o', 'stat='], + capture_output=True, text=True) + return state.returncode == 0 and state.stdout.strip().startswith('Z') + + +deadline = time.monotonic() + 45 +while time.monotonic() < deadline: + owners = subprocess.run(['lsof', '-nP', f'-iTCP:{launch["port"]}', '-sTCP:LISTEN', '-t'], capture_output=True, text=True).stdout.split() + if not owners and not (instance / 'db/postmaster.pid').exists() and runtime_exited(): + result = {'status': 'passed', 'port': launch['port'], 'serverStopped': True, + 'databaseStopped': True, 'runtimeExited': True, 'evidenceRetained': sorted(p.name for p in evidence.iterdir())} + (evidence / 'cleanup.json').write_text(json.dumps(result, indent=2) + '\n') + print(f'PASS: owned runtime/database stopped; evidence retained at {evidence}.') + break + time.sleep(0.5) +else: + raise SystemExit('Shutdown incomplete; retain data and evidence and inspect only this owned instance.') diff --git a/.agents/skills/verify-paperclip/tests/test_ownership.py b/.agents/skills/verify-paperclip/tests/test_ownership.py new file mode 100644 index 00000000000..2091bd31f6f --- /dev/null +++ b/.agents/skills/verify-paperclip/tests/test_ownership.py @@ -0,0 +1,124 @@ +"""Check that verification helpers cannot take ownership of another checkout.""" +import json +import os +import time +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +import urllib.request + + +class OwnershipSafety(unittest.TestCase): + def test_foreign_checkout_server_remains_alive(self): + repo = Path(__file__).resolve().parents[4] + helpers = repo / '.agents/skills/verify-paperclip/scripts' + with tempfile.TemporaryDirectory(prefix='paperclip-ownership-test-') as temp: + root = Path(temp) + foreign = root / 'another-checkout' + foreign.mkdir() + server = subprocess.Popen( + [sys.executable, '-u', '-c', + "from http.server import HTTPServer, BaseHTTPRequestHandler\n" + "class Handler(BaseHTTPRequestHandler):\n" + " def do_GET(self):\n" + " self.send_response(200); self.end_headers(); self.wfile.write(b'foreign fixture alive')\n" + " def log_message(self, *args): pass\n" + "server = HTTPServer(('127.0.0.1', 0), Handler)\n" + "print(server.server_port, flush=True)\n" + "server.serve_forever()\n"], + cwd=foreign, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, + ) + try: + port = int(server.stdout.readline().strip()) + url = f'http://127.0.0.1:{port}' + data = root / 'verification-data' + instance = data / 'instances/default' + instance.mkdir(parents=True) + evidence = root / 'evidence' + evidence.mkdir() + # The claimed PID really owns this port, but its cwd is foreign. + (evidence / 'launch.json').write_text(json.dumps({ + 'repo': str(repo), 'dataDir': str(data), 'port': port, + 'url': url, 'launcherPid': server.pid, + })) + (instance / 'runtime-info.json').write_text(json.dumps({ + 'pid': server.pid, 'port': port, 'dashboardUrl': url, + 'host': '127.0.0.1', + })) + for helper in ['doctor.py', 'stop.py']: + with self.subTest(helper=helper): + result = subprocess.run( + [sys.executable, str(helpers / helper), + '--evidence-dir', str(evidence)], + capture_output=True, text=True, timeout=10, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn('checkout', result.stdout + result.stderr) + self.assertIsNone(server.poll(), 'Helper killed foreign server') + with urllib.request.urlopen(url, timeout=5) as response: + self.assertEqual(response.read(), b'foreign fixture alive') + self.assertFalse((evidence / 'cleanup.json').exists()) + finally: + server.terminate() + try: + server.communicate(timeout=5) + except subprocess.TimeoutExpired: + server.kill() + server.communicate() + + + def test_cleanup_waits_for_exit_after_listener_closes(self): + repo = Path(__file__).resolve().parents[4] + with tempfile.TemporaryDirectory(prefix='paperclip-delayed-exit-test-') as temp: + root = Path(temp) + instance = root / 'data/instances/default' + instance.mkdir(parents=True) + evidence = root / 'evidence' + evidence.mkdir() + marker = root / 'listener-closed' + server = subprocess.Popen( + [sys.executable, '-u', '-c', + "import signal, socket, sys, time\n" + "from pathlib import Path\n" + "listener = socket.socket(); listener.bind(('127.0.0.1', 0)); listener.listen()\n" + "def stop(signum, frame):\n" + " listener.close(); Path(sys.argv[1]).write_text('closed')\n" + " time.sleep(2); sys.exit(0)\n" + "signal.signal(signal.SIGTERM, stop)\n" + "print(listener.getsockname()[1], flush=True)\n" + "signal.pause()\n", str(marker)], + cwd=repo, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + ) + try: + port = int(server.stdout.readline().strip()) + url = f'http://127.0.0.1:{port}' + (evidence / 'launch.json').write_text(json.dumps({ + 'repo': str(repo), 'dataDir': str(root / 'data'), + 'port': port, 'url': url, 'launcherPid': os.getpid(), + })) + (instance / 'runtime-info.json').write_text(json.dumps({ + 'pid': server.pid, 'port': port, 'dashboardUrl': url, + })) + started = time.monotonic() + result = subprocess.run( + [sys.executable, str(repo / '.agents/skills/verify-paperclip/scripts/stop.py'), + '--evidence-dir', str(evidence)], + capture_output=True, text=True, timeout=12, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertTrue(marker.exists(), 'Fixture did not close its listener') + self.assertGreaterEqual(time.monotonic() - started, 2) + self.assertEqual(server.poll(), 0, 'Cleanup returned before runtime exit') + cleanup = json.loads((evidence / 'cleanup.json').read_text()) + self.assertTrue(cleanup['runtimeExited']) + finally: + if server.poll() is None: + server.kill() + server.communicate(timeout=5) + + +if __name__ == '__main__': + unittest.main() diff --git a/AGENTS.md b/AGENTS.md index 53f25d6d514..55ee4734b84 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -152,10 +152,16 @@ Notes: ## 7. Verification Before Hand-off -[feature-map/README.md](feature-map/README.md) is an optional reference for user +[maps/feature-map/README.md](maps/feature-map/README.md) is an optional reference for user entry points, verification recipes, and coverage gaps. The map records coverage scope, not proof that a live journey passed. +The sibling [agent access map](maps/agent-access-map/README.md) records APIs, +agent entry points and permission boundaries using the same capability IDs. +Follow [the map contract](maps/README.md): update affected entries when mapped +behavior or access changes, and run `node scripts/check-maps.mjs` after map edits. +Its structural checks do not prove live behavior or authorization. + Default local/agent test path: ```sh diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 570c252d5ec..8ddfe16cd1e 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -495,11 +495,17 @@ npx paperclipai allowed-hostname dotta-macbook-pro ## Test Commands -The [feature map](../feature-map/README.md) is an optional reference for user +The [feature map](../maps/feature-map/README.md) is an optional reference for user entry points, targeted tests, manual verification recipes, and coverage gaps. Its page inventory is a source snapshot. The documented journeys have separate verification steps and do not run automatically from the map. +The sibling [agent access map](../maps/agent-access-map/README.md) describes API +and agent reachability for the same capabilities. The [map contract](../maps/README.md) +defines synchronization, permissions and evidence. After map edits, run +`node scripts/check-maps.mjs` for structural/reference checks; it does not start an +instance or qualify access as an agent. + Use the cheap local default unless you are specifically working on browser flows: ```sh diff --git a/doc/observability.md b/doc/observability.md index cb6b75c1af3..807ad61c80a 100644 --- a/doc/observability.md +++ b/doc/observability.md @@ -638,6 +638,17 @@ pending. Recent events do not prove useful progress. These fields contain only numbers and a boolean, never tool names, IDs, arguments, or event content, and do not change the execution timeout, cancellation, or recovery policy. +A stopped turn (wall-clock timeout or operator stop) also records +`acpLastEventAgeAtStopMs` and `acpOldestPendingToolAgeAtStopMs`, measured at the +moment the stop fired. A cancel can make the agent emit closing events, so the +finalization age alone cannot separate a turn that went silent long before its +deadline from one that was still working. For a stopped turn, pending tools +means the tools pending when the stop fired, so a tool the cancel closes still +counts; `acpPendingToolCount` keeps its finalization-time meaning. When tools are +pending, `acpPendingToolKinds` lists their ACP tool kinds as a sorted, comma-separated +string drawn only from the closed ACP `ToolKind` enum plus `unknown`; the +server drops the field if any other value appears. + ACP and legacy OpenCode results retain the adapter's resolved wall-clock timeout as `adapterExecutionTimeout` in the instance run result. Finalization uses it for `effectiveTimeoutSec`, `timeoutSource`, and `timeoutConfigured`. Sources are diff --git a/doc/plans/2026-10-06-default-agent-runner.md b/doc/plans/2026-10-06-default-agent-runner.md index 0ff7292e611..beaa9f95d72 100644 --- a/doc/plans/2026-10-06-default-agent-runner.md +++ b/doc/plans/2026-10-06-default-agent-runner.md @@ -2,9 +2,62 @@ Updated 2026-10-12. -## Latest checkpoint: ARM qualification and master integration +## Latest checkpoint: runtime prerequisite and authorized merges -The native runtime, protocol, Codex adapter/driver, selection code, and provider +The user authorizes preparing and merging ready PRs in dependency order. +Production deployment and provisioning-default promotion remain separate actions. +The Codex-default candidate is preserved at +`bb7591dfd0c4c8569e2a0b4303ff06de8ca3e9e2`; its running test-drive remains at +[3125](http://127.0.0.1:3125/COD/agents/all). No existing agent changes runner. + +Its qualification work grew the review to 135 files. Extract the independent +runtime, installed-package and hosted-build changes onto +`codex/runner-native-platform-prerequisites`, based on master +`7d9b93aee2b79b2f035c5838741ce0b94168ec83`. This prerequisite contains 39 source +files and this record. Creation policy and experimental gates remain identical +to that master. README activation text and eight existing default-selection +controls stay in the dependent [Codex PR #15683](https://github.com/paperclipai/paperclip/pull/15683), +whose expected review is 98 files. Preserve all work and both branches. + +The isolated extraction passes 276 existing focused controls. Runner no-emit +passes. The standard server no-emit initially finds a linked dependency-output +skew: the new master's ACP constants are absent from the old checkout's compiled +adapter-utils. A source-aware diagnostic passes; canonical verification remains +pending until its matching JavaScript dependency is prepared. Full hosted CI, +native compilation and fresh review are still required for the extracted head. +Do not count the diagnostic as a passing standard command. + +Retained qualification keeps literal identities: four producers and Linux x64 +and both Mac consumers pass at `a200495`; installed ARM native startup passes +with scoped stock Ubuntu prerequisites in +[38291535914](https://github.com/paperclipai/paperclip/actions/runs/38291535914). +Those artifacts are not relabeled as the extracted source. The Linux baseline +is GNU libc 2.39+, on x64/AArch64. Windows and Alpine remain deferred. +Cargo caches and overlapping JavaScript/native preparation use existing hosted +workflows. No local Docker/Rust builds or bundled Codex payloads are introduced. + +Merge order is runtime prerequisite, Codex defaults, then the existing Claude +[protocol #15994](https://github.com/paperclipai/paperclip/pull/15994), +[default #15993](https://github.com/paperclipai/paperclip/pull/15993), and +[platform #15997](https://github.com/paperclipai/paperclip/pull/15997) PRs. +The authorized Claude chat owns its verification and fixes; this task coordinates +normal squash merges after applicable gates. The older Grok Cloud PR is an +independent lane, with its own missing snapshot-cleanup access and live proof. + +The extracted [runtime prerequisite #16028](https://github.com/paperclipai/paperclip/pull/16028) +is open at `ddcd746eb6a01f2bbe9b4adeec238ba35bde5adb`. The default branch is +composed on it with all 134 owned implementation/test files identical to +`bb7591dfd0`; this record retains every historical checkpoint. +Next action: require current-source CI/review and remotely prepare the exact +composed Codex staging image and DB/shared pair. The existing QA tenant remains pinned to +old `2dbe43a4`. Managed Codex retains its $2.50 setup/task envelope and $2 cleanup; +historical unknown holds remain within the original $250 ceiling. Claude has +its separately authorized $50 ceiling. Missing live proof remains unresolved. + + +## Retained checkpoint: ARM qualification and master integration + +The Rust daemon, protocol, Codex driver, creation policy, and provider requirements remain unchanged from qualified source `a200495cc7488f4d59da570407e516cf702e5af8`. Its verified 18-package archive retains that literal identity. All four hosted daemon producers, Linux x64 clean @@ -53,7 +106,51 @@ The last verified preview restart serves `2026.1005.0+352.git.0690801f13` at configurations and both historical native runs. The downloaded Apple Silicon daemon requires no source build or binary override. -Next action: require fresh CI/review on the integrated candidate, refresh the +Fresh review at `665c155` reports three valid findings. Remote sandbox setup and +execution now share the existing actual-target OS/architecture resolver rather +than selecting the Mac controller's daemon. Thirteen new baseline failures +reproduce the issue; 124 target/setup/backend and retained-artifact/version +controls pass after the fix, with server typecheck. Header, digest, preinstalled +artifact, and remote build-metadata verification stay strict. Foreign artifacts +are read/hashed on the controller and executed through the remote launcher. +Local and non-Codex paths remain unchanged. Receipt: +`/private/tmp/codex-default-sandbox-target-fix-receipt.json`. + +Unchanged saved setup choices now bypass only new-selection resolution, retaining +later secret, company/environment, authentication, and runtime validation. The +actual disabled legacy baseline returned 422 before its probe. Treatment passes +175 existing controls plus a native-provider switch negative, with server +typecheck. New, explicit, draft, and changed-harness choices keep the disabled +adapter policy. Receipt: `/private/tmp/pr2-disabled-saved-codex-owned-receipt.json`. + +The actual served Codex guide reproduces stale Mac/ARM legacy text at `665c155`. +It now uses the shared GNU libc floor, lists qualified Linux/Mac architectures, +distinguishes legacy fields, and preserves existing-runner and failure semantics. +Adapter typecheck passes; the updated served guide needs final-preview proof. +These corrections update server setup/execution and guide source. The qualified +`a200495` package startup and historical task evidence retain their literal +component boundaries; no new managed live proof or paid turn is claimed. + +Fresh review at `e79254b` verifies the preceding fixes and reports one valid +development override edge. The common remote resolver now preserves +`PAPERCLIP_RUNNER_BINARY` only when the measured target OS/CPU matches the +controller. Explicit remote overrides retain priority; every selected artifact +still requires a matching native header. Cross-target selection ignores the +controller override. Seven new baseline failures reproduce the defect; 133 +target/setup/backend/retained/version controls and server typecheck pass after +the correction. Receipt: +`/private/tmp/codex-default-same-host-override-fix-receipt.json`. + +The `e79254b` preview restart preserves all four configurations and both +historical native runs. Its actual served guide now reports the shared GNU floor +and qualified Mac/ARM targets. No native build, Docker build, or provider call +runs locally. Main CI's selector remains unallocated; other PRs share this queue. +No environment approval, owned concurrency conflict, or routing failure was +found. Org capacity versus GitHub allocation is unconfirmed. Receipt: +`/private/tmp/pr2-e792-selector-queue-investigation.json`. Required final CI +aggregates and fresh review remain pending, not passed. + +Next action: require fresh CI/review on the final corrected candidate, refresh the matched preview, and publish final conclusions in the canonical PR's evidence index. Human code-owner approval, managed staging onboarding, and actual post-merge Cloud composition remain outstanding. No merge or production diff --git a/doc/plans/2026-10-12-chat-channel-transports-refactor.md b/doc/plans/2026-10-12-chat-channel-transports-refactor.md new file mode 100644 index 00000000000..fdef6082391 --- /dev/null +++ b/doc/plans/2026-10-12-chat-channel-transports-refactor.md @@ -0,0 +1,124 @@ +# Chat channel transport workflows, batch three + +Base: `a2ac5c27157ab033a2e0e2731b1b8e35bf20e7b9`. The preceding stack +(#15976, #15979, #15981, #15985, #15987) is merged. The original service is +29,897 lines / 1,170,080 bytes. This batch creates five stacked PRs; merging is +separate work. + +| Order / boundary | Destination | Approximate lines moved / net reduction | Inputs and outputs | State and effects | Verification | +| --- | --- | --- | --- | --- | --- | +| 1. Slack session status | `chat-channels/slack/session-status.ts` | 342 / 330 | Endpoint lookup, credential lease, runtime fence, publication run identity, credentials and fetch; return processed count | Claim and settle existing durable status actions. Keep selected revision, attempt ownership and live test barrier. No worker or new state. | Existing public-service status, restart, rate-limit, concurrent selection, stop and Idle cases before/after. | +| 2. Slack accepted-file receipts | `chat-channels/slack/file-receipts.ts` | 900 / 840 | Runtime/credential authorization, publication eligibility and endpoint lookup; record/recover accepted upload receipts | Preserve same-attempt leases, exact receipt identities, publication links and transaction boundaries. Keep generic publication transport shared. | Existing upload recovery, competing retry deadline, revoked reach and no-duplicate-upload cases before/after. | +| 3. Telegram publication drafts | `chat-channels/telegram/publication-drafts.ts` | 394 / 370 | Current runtime and callback fence, source publication authority; reserve draft or handle stopped generation | Preserve runtime map identity, stop scope, draft revision and transactional invalidation. Keep maintenance and shared publication admission in their existing owners. | Existing durable private draft Stop, stale generation and publication recovery cases before/after. | +| 4. Photon incoming events and responses | `chat-channels/imessage-photon/events.ts` | 820 / 780 | Runtime callbacks, principal authorization, question delivery and shared inbound admission | Preserve receiver sequence/lease checks, native interaction binding, transaction ordering and deferred publication. No receiver/startup or credential-inspection ownership move. | Existing Photon public-service integration suite before/after. | +| 5. Teams file-transfer authority | `chat-channels/microsoft-teams/file-transfer-authority.ts` | 940 / 900 | Source proof, runtime/credential fence, principal authorization; return current recipient and immutable file authority | Keep source, recipient, Board intent and byte identity proofs together. Preserve map lifetimes and no-wait locks. Transport, maintenance cooldowns and cleanup stay with the publication worker. | Existing Teams current file authority, consent, composed Board transfer and stale-runtime cases before/after. | + +Use named, typed contracts and channel folders. Keep shared message admission, +provider startup/shutdown, generic publication transport and shared authorization +in the service. Do not introduce a mutable context bag or a runtime import back +to the service. Reconsider a candidate if its actual dependency contract proves +too broad. + +For each slice, inspect its execution path and callers, run baseline tests, move +mechanically, compare normalized syntax, rerun the same public entry-point tests, +and check server types and module boundaries. Finish CI and current-head review +before opening the next PR. Run broad local gates once at the cumulative handoff. +Record failures and skipped groups accurately; a retry does not erase a failed +run. Fixtures exercise assembled services and persistence, not live providers. + +The starting service function is 27,835 lines. Its 212 nested functions span +27,146 lines; 51 wiring/state/API statements span 435 lines. Imports span 423 +lines. The largest shared function remains `processMessage` at 2,166 lines. +Remap these measurements after the fifth extraction and stop at five PRs. + +## Slice 1: Slack session status + +- Service: 29,897 lines / 1,170,080 bytes → 29,553 lines / 1,156,944 bytes. +- Destination: 408 lines / 15,730 bytes. The durable claim, provider status call + and conditional settlement remain in one function. No state or worker moves. +- All six selected public-service cases passed before and after. They exercise + restart/rate-limit recovery, stale selection, Stop and publication-driven Idle. + Server typecheck, module boundaries, whitespace and normalized syntax parity + passed. The selection hook remains live through an options getter. +- Next: accepted-file receipt recovery. Keep the generic outbound publication + link writer shared and pass it as a typed callback to the Slack module. + +## Slice 2: Slack accepted-file receipts + +- Service: 29,553 lines / 1,156,944 bytes → 28,620 lines / 1,122,992 bytes. +- Destination: 1,000 lines / 36,807 bytes. Thirteen declarations move together: + receipt validation, recording, authorization, recovery, settlement and due-work + selection. The generic outbound-link writer remains shared. +- All nine selected public-service cases passed before and after. They cover + accepted upload recovery without reposting, original-attempt ownership, + competing retry deadlines, revoked reach and selected file publication. + Server typecheck, module boundaries and syntax parity passed. No state or + worker is added. Credential lease identity and all transactions are unchanged. +- Next: Telegram private draft reservation and stopped-generation callbacks, + retaining shared runtime-map identity and source-publication authorization. + +## Slice 3: Telegram publication drafts + +- Service: 28,620 lines / 1,122,992 bytes → 28,219 lines / 1,108,711 bytes. +- Destination: 461 lines / 16,860 bytes. Both functions preserve their original + bodies. Runtime maps remain the same per-service objects; the source authority + callback and credential guard are unchanged. +- All 34 durable private-draft Stop cases passed before and after, including + restart, bot-global identity allocation, concurrent Stop, transaction failure, + uncertain final delivery and retained stale callbacks. Server typecheck, + boundaries, whitespace and normalized syntax parity passed. +- Reassessment: Photon event handling can move without moving its receiver or + setup inspection. For Teams, extract source/recipient/file authority as a + coherent transaction-level API; keep transport protocol and scheduler lifetime + in the service to avoid passing unrelated runtime/worker dependencies. + +## Slice 4: Photon events + +- Service: 28,219 lines / 1,108,711 bytes → 27,398 lines / 1,079,439 bytes. +- Destination: 903 lines / 33,080 bytes. Provider failure, incoming event and + native response handling preserve all three function bodies. The receiver, + lease ownership, checkpoint callbacks and setup inspection remain in the + service; question delivery uses the original service object. +- All 18 Photon channel integration cases passed before and after. They use the + assembled Chat SDK runtime, provider fixture streams and a disposable database + to cover poll/confirmation responses, separate participant drafts, stale + controls, receiver lease fencing, groups, echoes, restart and continuation. + Server typecheck, boundaries, whitespace and syntax parity passed. +- Next: Teams file-transfer authority, with no transport or timer ownership move. + +## Slice 5: Teams file-transfer authority + +- Service: 27,398 lines / 1,079,439 bytes → 26,461 lines / 1,046,711 bytes. +- Destination: 1,041 lines / 36,532 bytes. Eleven declarations move into a + transaction-level authority factory exposing Board intent staging and fresh + authority derivation. It takes no database instance and starts no work. +- The original per-service runtime maps and retry-proof callbacks are passed + after the retry factory initializes. No-wait row locks, identity advisory + locks, source ordering and byte/recipient proof hashes preserve their bodies. +- All 50 selected Teams public-service cases passed before and after, including + exact recipient admission, retained source continuation, composed Board file + transfers, stale runtime callbacks and consent/file receipt recovery. Server + typecheck, module boundaries, whitespace and normalized syntax parity passed. + +## Reassessment after batch three + +The service falls from 29,897 lines / 1,170,080 bytes to 26,461 lines / +1,046,711 bytes: 3,436 lines (11.5%) and 123,369 bytes removed. The five new +modules total 3,813 lines / 139,009 bytes. All 117 selected cases passed before +and after their respective extractions. + +The service function spans 24,484 lines. Its 191 nested functions span 23,829 +lines; 54 state/wiring/API statements span 425 lines. Imports span 412 lines. +The largest remaining functions are `processMessage` (2,166 lines), +`handleAction` (1,074), `configureWithCredentialLease` (868), +`processSelectedPublication` (729), `handleModalSubmit` (708), `handleWebhook` +(685), `processLifecycleDelivery` (657), `applyProviderLifecycleEffect` (593), +and `ingestAttachments` (574). + +Future candidates include receipt-reaction recovery, provider-effect dispatch, +attachment ingestion and inbound delivery persistence/drain ownership. Teams +transport protocol and scheduling can be reconsidered after their lifetime +contract is explicit; this batch deliberately moved only authority proofs. +Do not move a large shared admission function wholesale merely to reduce LOC. +Stop this batch at five PRs. Cumulative local gates and current-head CI/review +results are recorded in the final PR; per-slice PRs retain their own evidence. diff --git a/feature-map/documents-artifacts.md b/feature-map/documents-artifacts.md deleted file mode 100644 index 39d96ade8bd..00000000000 --- a/feature-map/documents-artifacts.md +++ /dev/null @@ -1,55 +0,0 @@ -# Documents, attachments, and work products - -People read and revise task documents, annotate specific content, open attached files, and inspect the outputs an agent hands back. The artifact library provides another route to the same work. - -Implementation: [task documents](../ui/src/components/IssueDocumentsSection.tsx), [artifact library](../ui/src/pages/Artifacts.tsx), [document panel](../ui/src/components/task-side-panel/TaskDocumentPanel.tsx). - -## Sub-features - -- `documents`: edit versioned task documents and inspect or restore revisions. -- `annotations`: attach feedback to document content and inspect resolution state. -- `attachments`: upload, preview, and download allowed files with company access checks. -- `work-products`: link deliverables to their source task, resource, and review context. -- `library`: search, filter, group, and open artifact stacks. - -## How to get to it (user POV) - -### `task-document` - -Open a document from the task thread or document side panel. - -### `task-output` - -Open an attachment, output card, or file link from a task. - -### `artifact-library` - -Open `/artifacts` and its grouping, search, and media controls. - -## Driving it - -Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use a task with a text document, a small attachment, and a generated work product. Use nonsensitive fixture files. - -### `task-document` - -Automated: [documents service](../server/src/__tests__/documents-service.test.ts), [annotations](../server/src/__tests__/document-annotations-service.test.ts), and [revision restore](../server/src/__tests__/issue-document-restore-routes.test.ts) cover server contracts. - -Manual: Edit a paragraph, save, reload, and compare revisions. Add an annotation to a specific selection and inspect it after another edit. Resolve it through the supported action. Restore a disposable revision and confirm the new current document and history agree. - -### `task-output` - -Automated: [attachment routes](../server/src/__tests__/issue-attachment-routes.test.ts) and [work products](../server/src/__tests__/work-products.test.ts) cover data/access behavior; [artifact arrival browser suite](../tests/e2e/artifact-tab-arrival.spec.ts) targets the arrival experience. - -Manual: Upload a fixture and have the test task produce an output. Preview and download each, compare the contents, and follow the source task link. Reload and test an inaccessible user/company; verify missing workspace-only resources have an explicit state. - -### `artifact-library` - -Automated: [artifact page](../ui/src/pages/Artifacts.test.tsx) covers grouping, search requests, and pagination using mocks. - -Manual: Find the task’s output through search and grouping, open its stack, page through results, and reload a direct stack link. Confirm the same file/content appears as from the task. - -## Gotchas - -- A local path is not automatically an uploaded artifact accessible to another user. -- A preview screenshot is not proof that download bytes or revision references are correct. -- Version conflicts and stale annotations must stay visible rather than silently targeting different content. diff --git a/feature-map/onboarding.md b/feature-map/onboarding.md deleted file mode 100644 index 4a47e0a9fd4..00000000000 --- a/feature-map/onboarding.md +++ /dev/null @@ -1,45 +0,0 @@ -# Onboarding and first work - -An operator can start an instance, create a company, configure its first agent, and reach a real first task. Instance setup and company onboarding are separate steps. - -Implementation: [web wizard](../ui/src/components/OnboardingWizard.tsx), [CLI setup](../cli/src/commands/onboard.ts). - -## Sub-features - -- `instance`: choose local instance configuration and start the server. -- `company`: create a company with its identity and initial agent. -- `adapter`: choose an available harness, model, account, and execution environment. -- `first-task`: leave setup with a task whose execution or missing prerequisite is visible. -- `resume`: return to an incomplete wizard without losing saved state. - -## How to get to it (user POV) - -### `web-wizard` - -Open `/onboarding` or the onboarding launcher in an empty company context. - -### `cli-setup` - -Use `paperclipai onboard --help` or `paperclipai run --help` to select an isolated data directory before setup. - -## Driving it - -Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use a new isolated data directory and record deployment mode. A configured adapter may still need account authentication. - -### `web-wizard` - -Automated: [wizard tests](../ui/src/components/OnboardingWizard.test.tsx) cover mocked wizard behavior; [onboarding browser suite](../tests/e2e/onboarding.spec.ts) supplies its own test environment. Live model execution is a separate check. - -Manual: Create a disposable company and first agent. Save and leave midway, reopen, and finish. Follow the created first task, verify the selected agent and company, and inspect its output or explicit authentication gate. Reload both company and task. - -### `cli-setup` - -Automated: [CLI onboarding](../cli/src/__tests__/onboard.test.ts) and [isolated test drive](../cli/src/__tests__/test-drive.test.ts) cover their setup contracts; they do not establish that every model account is usable. - -Manual: Run setup in the disposable directory, open the URL it prints, and confirm the instance identity and company. Restart using that same configuration and verify saved state. Exercise an unavailable adapter and confirm an actionable error. - -## Gotchas - -- An empty company, an empty instance, and an unauthenticated browser are different states. -- Creating an agent does not prove its provider login or first execution succeeded. -- See [access](./access.md) for authenticated deployments and [agents](./agents.md) for later hiring. diff --git a/maps/README.md b/maps/README.md new file mode 100644 index 00000000000..5946460d2d4 --- /dev/null +++ b/maps/README.md @@ -0,0 +1,135 @@ +# Paperclip maps + +The maps describe the same capabilities from two perspectives: + +- [Feature map](feature-map/README.md): what a person can do, where to reach it, + and how to verify the user journey. Preserve its Poteto format. +- [Agent access map](agent-access-map/README.md): the APIs and agent entry points + for those capabilities, their permission boundaries, and access verification. + +These are sibling directories under `maps/`. Each feature file has a same-named +agent access companion. Reuse the product map's sub-feature IDs, so `tasks/create` +identifies the same capability in both maps. Product behavior remains governed by +[the implementation spec](../doc/SPEC-implementation.md); a map cannot grant +authority or redefine a permission denial as success. + +## Access contract + +Every business operation needs both an API and a usable agent entry point for an +actor authorized to perform it. A dedicated tool is optional when REST, the CLI, +an assigned MCP client, or native `search_api` / `call_api` already provides the +operation. Record which agent surfaces actually have that path; an installed CLI +or optional MCP package cannot be assumed available to every native/remote run. + +Track these independently: + +| Dimension | Values and meaning | +| --- | --- | +| API | `present`, `missing`, `not-applicable`, or `unreviewed`. Use exact methods/paths; distinguish protocols from REST. | +| Agent access | `available`, `partial`, `missing`, `restricted`, `operator-only`, `not-applicable`, or `unreviewed`. Name the supported surface and actual entry point. | +| Policy | Actor, action/grant, company/resource/ownership scope, responsible-user intersection, run requirements, work modes, and gates relevant to the operation. | +| Evidence | `source-only`, `verified`, `failed`, `blocked`, or `not-run`. Source presence and successful board calls are not agent verification. | +| Review depth | `baseline` records representative operations and known gaps; `operation-reviewed` enumerates the capability's meaningful operations and their individual access contracts. | + +Broad capabilities such as task properties must be split into operations before +they count as operation-reviewed: renaming, metadata edits, assignment, +dependencies, and lifecycle changes can use different tools and permission rules. +An available read path cannot certify a write path. List/get/create/update/delete, +execution/control, credential handling, and resolution are separate where their +authority or transport differs. Do not duplicate request schemas from OpenAPI. + +### Exceptions and restrictions + +Presentation-only state, static developer fixtures, and pre-start/local operator +work may use `not-applicable` for a domain API, with a concrete reason and the +existing local path. When a capability combines shared data with presentation, +split the operations: browser recents do not exempt resource lookup from API +coverage. Promoting local state to a shared feature requires revisiting the entry. + +A board-only endpoint is `restricted` for agent identity, not an automatic +exception or a missing API. Cite the enforced policy and record whether the +agent can request the authorized human's action and observe its result. An +unresolved delegation question remains in **Gaps**. Company access, a job title, +a personal assistant's OAuth grant, or an accepted interaction does not grant +board identity. Keep budget, approval, secret and execution authority intact. + +Check actual route/service authorization and production tool admission. OpenAPI +actor labels and scenario/evaluation tool declarations are supporting metadata; +record disagreements rather than using them as proof of access. Native API +fallback restrictions, tool field/scope limits, and rollout settings are part of +the agent path, even when a legacy agent can call the REST endpoint directly. + +## Companion file format + +Each `agent-access-map/<area>.md` has an H1, a relative link to its product file, +review date and source commit, followed by one H2 backticked ID per product +sub-feature. Each capability contains these fields: + +- **Review:** `baseline` or `operation-reviewed`. +- **API:** presence state and exact operation IDs, or a reasoned exception. +- **Agent access:** availability state and concrete entry points by surface. +- **Policy:** relevant permission and runtime boundaries, with source references. +- **Evidence:** evidence state, reviewed commit and sources or executed proof. +- **Verification:** allowed/denied assertions and observable result; record what + has actually run separately from the proposed recipe. +- **Gaps:** missing APIs/paths, discovery drift, unreviewed operations or policy + decisions; use `none` only for the enumerated scope that was reviewed. + +For an operation-reviewed capability, use H3 backticked operation IDs and repeat +API, Agent access, Policy, Evidence, Verification and Gaps for each operation. +Use small operation names such as `create`, `update`, `read`, or `run-now`. +The enclosing capability has `Review: operation-reviewed` only after its +meaningful operations have been enumerated. The following excerpt shows one +operation; include the others before using that review label: + +```markdown +## `definition` + +Review: `operation-reviewed`. + +### `create` + +API: `present` — POST /api/companies/{companyId}/routines. +Agent access: `partial` — agent REST and CLI routine create; default native +mutation binding missing, and call_api blocks routine mutations. +Policy: same company; assigneeAgentId must be the calling agent; cite the +routine authorization and native API policy sources. +Evidence: `source-only` — reviewed commit and source links; no live proof. +Verification: `not-run` — create an owned routine; reject assignment to another +agent; inspect saved configuration, attribution and retry behavior. +Gaps: default native create path; verify using the real permitted agent context. +``` + +## Keeping the maps synchronized + +When a change adds/removes a capability or changes its behavior, API, permission, +tool binding, CLI support, or work mode, update the affected companion and product +recipe as appropriate in the same change. Preserve IDs across renames where the +capability is unchanged; update both indexes and cross-links together. An API-only +change may update the companion without rewriting unchanged user instructions. + +[maintain-verification-skill](../.agents/skills/maintain-verification-skill/SKILL.md) +owns reconciliation of both maps. It follows changed source and authorization, +checks real agent reachability, and records product gaps without implementing +them during a maintenance pass. The creation skill uses this structure. +[verify-paperclip](../.agents/skills/verify-paperclip/SKILL.md) consumes the relevant +recipes and reports evidence/drift; map editing belongs to maintenance. + +Run the structural check after map changes: + +```sh +node scripts/check-maps.mjs +``` + +It checks paired areas/IDs, indexes, required fields, and local references. It +does not certify endpoint authorization, production bindings, operation +completeness, or live behavior. Source audits must check those against the +existing API and tool contracts; avoid a second schema catalog or runtime. + +For live access proof, use Paperclip's existing API/runner/MCP fixtures or an owned +isolated instance. Use the actual agent identity, target and work mode. Assert the +allowed result and relevant denial (another company/private resource, wrong +owner, missing grant, invalid run, restricted mode or resolver). Mutations also +need persisted state/attribution and retry behavior. Capture evidence using the +[existing artifact workflow](../doc/AGENT-ARTIFACTS.md); keep executed run results +separate from product-map `coverage.json`, which records documentation scope. diff --git a/maps/agent-access-map/README.md b/maps/agent-access-map/README.md new file mode 100644 index 00000000000..9b8a94ab47e --- /dev/null +++ b/maps/agent-access-map/README.md @@ -0,0 +1,69 @@ +# Paperclip agent access map + +This map accompanies the [feature map](../feature-map/README.md). It records APIs, +agent entry points, permission boundaries and verification for the same capability IDs. +The [shared contract](../README.md) defines the format and maintenance rules. + +Initial inventory: **35 areas and 171 sub-features**, source-reviewed on 2026-10-12 +at `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. All entries are `baseline` and `source-only`; none +claims complete operation enumeration or freshly verified agent access. Existing +API paths, native gaps, actor restrictions and local/operator exceptions are +recorded separately. These statuses do not represent a live pass rate. + +## Areas + +| Area | Product map | Capabilities | +| --- | --- | --- | +| [Login, invitations, and access](access.md) | [access](../feature-map/access.md) | 5 | +| [Dashboards, activity, and audit trails](activity.md) | [activity](../feature-map/activity.md) | 5 | +| [Agent conversations and project handoff](agent-chat.md) | [agent-chat](../feature-map/agent-chat.md) | 4 | +| [Hiring, agent configuration, and organization](agents.md) | [agents](../feature-map/agents.md) | 5 | +| [App access, action permissions, and testing](app-permissions.md) | [app-permissions](../feature-map/app-permissions.md) | 5 | +| [Costs, budgets, and spending controls](budgets-costs.md) | [budgets-costs](../feature-map/budgets-costs.md) | 4 | +| [Cases and structured work records](cases.md) | [cases](../feature-map/cases.md) | 4 | +| [External chat and email conversations](chat-channels.md) | [chat-channels](../feature-map/chat-channels.md) | 5 | +| [CLI, API clients, and local worktrees](cli-operations.md) | [cli-operations](../feature-map/cli-operations.md) | 5 | +| [Companies and portability](companies.md) | [companies](../feature-map/companies.md) | 4 | +| [Connection setup](connection-setup.md) | [connection-setup](../feature-map/connection-setup.md) | 8 | +| [Developer previews and diagnostic labs](developer-labs.md) | [developer-labs](../feature-map/developer-labs.md) | 4 | +| [Documents, attachments, and work products](documents-artifacts.md) | [documents-artifacts](../feature-map/documents-artifacts.md) | 5 | +| [Local, SSH, and sandbox environments](execution-environments.md) | [execution-environments](../feature-map/execution-environments.md) | 5 | +| [Tool gateways and access profiles](gateways-profiles.md) | [gateways-profiles](../feature-map/gateways-profiles.md) | 5 | +| [Goals and work alignment](goals.md) | [goals](../feature-map/goals.md) | 3 | +| [Inbox, decisions, and search](inbox-search.md) | [inbox-search](../feature-map/inbox-search.md) | 4 | +| [Installation, configuration, updates, and backups](instance-operations.md) | [instance-operations](../feature-map/instance-operations.md) | 5 | +| [Navigation, profile, and announcements](navigation-preferences.md) | [navigation-preferences](../feature-map/navigation-preferences.md) | 5 | +| [Onboarding and first work](onboarding.md) | [onboarding](../feature-map/onboarding.md) | 5 | +| [Pipelines, review queues, and learnings](pipelines.md) | [pipelines](../feature-map/pipelines.md) | 4 | +| [Plugins and contributed product surfaces](plugins.md) | [plugins](../feature-map/plugins.md) | 4 | +| [Projects and repositories](projects.md) | [projects](../feature-map/projects.md) | 4 | +| [Questions and approvals](questions-and-approvals.md) | [questions-and-approvals](../feature-map/questions-and-approvals.md) | 8 | +| [Recovery](recovery.md) | [recovery](../feature-map/recovery.md) | 8 | +| [Routines, schedules, and triggers](routines.md) | [routines](../feature-map/routines.md) | 5 | +| [Runs, harnesses, and model accounts](runs-adapters.md) | [runs-adapters](../feature-map/runs-adapters.md) | 4 | +| [Secrets, personal credentials, and proposals](secrets.md) | [secrets](../feature-map/secrets.md) | 5 | +| [Skill discovery, authoring, and policy](skills.md) | [skills](../feature-map/skills.md) | 5 | +| [Status cards and refreshed summaries](status-cards.md) | [status-cards](../feature-map/status-cards.md) | 4 | +| [Steering and queued messages](steering.md) | [steering](../feature-map/steering.md) | 7 | +| [Delegation, dependencies, and signoff](task-coordination.md) | [task-coordination](../feature-map/task-coordination.md) | 4 | +| [Task creation and lifecycle](tasks.md) | [tasks](../feature-map/tasks.md) | 5 | +| [Team packages and catalog installation](teams.md) | [teams](../feature-map/teams.md) | 4 | +| [Execution workspaces, services, and files](workspaces.md) | [workspaces](../feature-map/workspaces.md) | 5 | + +## Using an entry + +Read its Policy and Gaps before selecting an agent path. Native fallback, legacy +REST/CLI, stdio MCP and a personal assistant connection can have different +authority and availability. Use a real admitted production binding; a scenario +declaration is not an installed tool. A `restricted` entry records current +enforcement and leaves any unresolved delegation decision visible. + +For API catalog disagreements, follow the route/service authorization and record +discovery drift. For `not-applicable`, retain the concrete exception and local +path. Split shared business operations from client-only presentation. + +Use `maintain-verification-skill` to reconcile changed source and both maps. +Use `verify-paperclip` and existing API/runner/MCP fixtures to collect the +permitted-agent and denial evidence; report blocked/not-run coverage honestly. +Run `node scripts/check-maps.mjs` after editing. The structural check does not +certify permissions, API catalog coverage, tool admission or live behavior. diff --git a/maps/agent-access-map/access.md b/maps/agent-access-map/access.md new file mode 100644 index 00000000000..4382be3416c --- /dev/null +++ b/maps/agent-access-map/access.md @@ -0,0 +1,136 @@ +# Login, invitations, and access — agent access + +Product map: [Login, invitations, and access](../feature-map/access.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `login` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/auth/get-session`. +- `POST /api/bootstrap/claim`. + +Agent access: `restricted` — Agent authenticates with its own issued bearer; board login +uses human/client auth. + +Policy: Company access plus action-specific grants; instance administration requires a +board administrator. + +Evidence: `source-only` — [implementation](../../server/src/routes/access.ts#L3018) at +the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: An agent identity is not a board login or board claim; explicit delegation is a +product-policy decision. + +## `invitations` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/invites`. +- `POST /api/invites/{token}/accept`. + +Agent access: `partial` — REST with invite permission; CLI access; native call_api +blocks invite credentials. + +Policy: Company access plus action-specific grants; instance administration requires a +board administrator. + +Evidence: `source-only` — [implementation](../../server/src/routes/access.ts#L3018) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Invite creation has an agent permission branch. Human joining and agent onboarding +are different operations. + +## `membership` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/members`. +- `PATCH /api/companies/{companyId}/members/{memberId}/permissions`. + +Agent access: `partial` — REST with the specific company permission; native fallback for +unrestricted operations. + +Policy: Company access plus action-specific grants; instance administration requires a +board administrator. + +Evidence: `source-only` — [implementation](../../server/src/routes/access.ts#L3018) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Company membership APIs include agent grants; instance-admin user operations stay +board-only. + +## `resources` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/issues/{id}`. +- `GET /api/projects/{id}`. + +Agent access: `available` — REST or scoped task/project tools. + +Policy: Company access plus action-specific grants; instance administration requires a +board administrator. + +Evidence: `source-only` — [implementation](../../server/src/routes/access.ts#L3018) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Company/resource authorization is implemented; a negative test must cover private +resources and another company. + +## `cli-auth` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/cli-auth/challenges`. +- `POST /api/cli-auth/revoke-current`. + +Agent access: `restricted` — Board CLI authorization; agents use issued agent +credentials. + +Policy: Company access plus action-specific grants; instance administration requires a +board administrator. + +Evidence: `source-only` — [implementation](../../server/src/routes/access.ts#L3018) at +the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: CLI board login/revocation cannot be counted as agent self-authentication. diff --git a/maps/agent-access-map/activity.md b/maps/agent-access-map/activity.md new file mode 100644 index 00000000000..b5d2041289c --- /dev/null +++ b/maps/agent-access-map/activity.md @@ -0,0 +1,129 @@ +# Dashboards, activity, and audit trails — agent access + +Product map: [Dashboards, activity, and audit trails](../feature-map/activity.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `overview` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/dashboard`. + +Agent access: `available` — REST / native call_api / CLI dashboard. + +Policy: Same company plus visibility/audit/cost/run permissions where the route requires +them. + +Evidence: `source-only` — [implementation](../../server/src/routes/activity.ts#L224) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Dashboard data is accessible; live-work rendering and event streaming are separate +surfaces. + +## `activity` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/activity`. + +Agent access: `available` — REST / native call_api / CLI activity. + +Policy: Same company plus visibility/audit/cost/run permissions where the route requires +them. + +Evidence: `source-only` — [implementation](../../server/src/routes/activity.ts#L224) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Audit export and raw activity creation have extra restrictions; do not equate them +with activity reading. + +## `runs` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/heartbeat-runs`. +- `GET /api/heartbeat-runs/{runId}/events`. + +Agent access: `available` — REST / native call_api / CLI run. + +Policy: Same company plus visibility/audit/cost/run permissions where the route requires +them. + +Evidence: `source-only` — [implementation](../../server/src/routes/activity.ts#L224) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Telemetry/run visibility is checked independently from seeing the source task. + +## `routine-audit` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/routines/{id}/runs`. + +Agent access: `available` — REST / native call_api / CLI routine runs. + +Policy: Same company plus visibility/audit/cost/run permissions where the route requires +them. + +Evidence: `source-only` — [implementation](../../server/src/routes/activity.ts#L224) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Routine execution history is distinct from configuration revisions and scheduling +changes. + +## `timeline` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/timeline`. + +Agent access: `available` — REST / native call_api. + +Policy: Same company plus visibility/audit/cost/run permissions where the route requires +them. + +Evidence: `source-only` — [implementation](../../server/src/routes/activity.ts#L224) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: API exists; no dedicated timeline tool is necessary for this requirement. diff --git a/maps/agent-access-map/agent-chat.md b/maps/agent-access-map/agent-chat.md new file mode 100644 index 00000000000..e38bd25bee6 --- /dev/null +++ b/maps/agent-access-map/agent-chat.md @@ -0,0 +1,113 @@ +# Agent conversations and project handoff — agent access + +Product map: [Agent conversations and project handoff](../feature-map/agent-chat.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `discovery` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/chats`. +- `GET /api/companies/{companyId}/chats/{agentRef}`. + +Agent access: `restricted` — Board-user REST; the responding agent gets its assigned +conversation context. + +Policy: Conversation discovery/creation requires a board user and the Agent Chat gate; +task access follows task visibility. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L18077) at +the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Agents cannot enumerate or create another person's standing chats. Detail route is +missing from the generated API catalog. + +## `history` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/issues/{id}/comments`. +- `POST /api/issues/{id}/comments`. + +Agent access: `partial` — Assigned conversation task tools / REST for eligible task +history and replies. + +Policy: Conversation discovery/creation requires a board user and the Agent Chat gate; +task access follows task visibility. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L18077) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Agent responses are supported; creating the person's chat and impersonating their +messages are different actions. + +## `handoff` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/projects`. +- `POST /api/companies/{companyId}/issues`. + +Agent access: `available` — Native list_projects/create_project/create_task; project MCP +tools; REST. + +Policy: Conversation discovery/creation requires a board user and the Agent Chat gate; +task access follows task visibility. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L18077) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Agent handoff is implemented; execution readiness, assignment permission, and +repository access still apply. + +## `conference-room` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/board/chat/stream`. + +Agent access: `partial` — Existing SSE client in local_trusted mode with the +experimental gate. + +Policy: Conversation discovery/creation requires a board user and the Agent Chat gate; +task access follows task visibility. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L18077) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Not callable through generic REST tooling: protocol and deployment constraints +apply. This route is not guarded by assertBoard, so its intended agent policy needs +clarification. diff --git a/maps/agent-access-map/agents.md b/maps/agent-access-map/agents.md new file mode 100644 index 00000000000..46c3ee462a2 --- /dev/null +++ b/maps/agent-access-map/agents.md @@ -0,0 +1,140 @@ +# Hiring, agent configuration, and organization — agent access + +Product map: [Hiring, agent configuration, and organization](../feature-map/agents.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `hire` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/agent-hires`. +- `POST /api/companies/{companyId}/agents`. + +Agent access: `partial` — Native hire_agent; REST / CLI agent; runtime inheritance for +native hires. + +Policy: Agent creation/configuration uses action grants, resource policies and change +consent; some controls require a board actor. + +Evidence: `source-only` — [implementation](../../server/src/routes/agents.ts#L1395) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Identity/persona hiring works. Operator account login and sensitive +adapter/environment configuration are separate guarded actions. + +## `instructions` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/agents/{id}/instructions-bundle/file`. +- `PUT /api/agents/{id}/instructions-bundle/file`. + +Agent access: `available` — Native read/update/history/restore instruction tools; REST / +CLI. + +Policy: Agent creation/configuration uses action grants, resource policies and change +consent; some controls require a board actor. + +Evidence: `source-only` — [implementation](../../server/src/routes/agents.ts#L1395) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Canonical entry file, revision guard, company boundary, and protected-change +policy apply. + +## `organization` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/org`. +- `PATCH /api/agents/{id}`. + +Agent access: `available` — list_agents/get_agent; native call_api for permitted +configuration fields; REST / CLI. + +Policy: Agent creation/configuration uses action grants, resource policies and change +consent; some controls require a board actor. + +Evidence: `source-only` — [implementation](../../server/src/routes/agents.ts#L1395) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Reporting-line changes need configuration authorization; a title alone gives no +authority. + +## `lifecycle` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/agents/{id}/pause`. +- `POST /api/agents/{id}/resume`. +- `POST /api/agents/{id}/terminate`. + +Agent access: `partial` — REST resume under change grant; pause/terminate require board; +native call_api blocks all three. + +Policy: Agent creation/configuration uses action grants, resource policies and change +consent; some controls require a board actor. + +Evidence: `source-only` — [implementation](../../server/src/routes/agents.ts#L1395) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Resume is agent-eligible but lacks a default native binding. Pause and termination +are actor-boundary decisions. + +## `built-in` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/built-in-agents/{key}/provision`. + +Agent access: `partial` — REST / native fallback for eligible provisioning; built-in +routine controls require board. + +Policy: Agent creation/configuration uses action grants, resource policies and change +consent; some controls require a board actor. + +Evidence: `source-only` — [implementation](../../server/src/routes/agents.ts#L1395) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Provisioning checks agents:create. Enabling/running its built-in routine is not +the same permission path. diff --git a/maps/agent-access-map/app-permissions.md b/maps/agent-access-map/app-permissions.md new file mode 100644 index 00000000000..e6d7f106a87 --- /dev/null +++ b/maps/agent-access-map/app-permissions.md @@ -0,0 +1,132 @@ +# App access, action permissions, and testing — agent access + +Product map: [App access, action permissions, and +testing](../feature-map/app-permissions.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `agent-access` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `PUT /api/tool-connections/{connectionId}/installs`. + +Agent access: `restricted` — Board management API; agents use their resulting assigned +app tools. + +Policy: Connection/profile/policy management routes require a board user; tool use +separately evaluates agent grants and action policies. + +Evidence: `source-only` — [implementation](../../server/src/routes/tool-access.ts#L517) +at the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Granting access is distinct from using access. No equivalent agent management path +exists in these routes. + +## `action-policy` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/tool-profiles/{profileId}/entries`. + +Agent access: `restricted` — Board management API; agent execution follows +off/ask/allowed. + +Policy: Connection/profile/policy management routes require a board user; tool use +separately evaluates agent grants and action policies. + +Evidence: `source-only` — [implementation](../../server/src/routes/tool-access.ts#L517) +at the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: An agent cannot authorize itself by editing the policy. Decide whether any +explicitly delegated administration should be supported. + +## `catalog-change` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/tool-profiles/{profileId}/new-tools/review`. + +Agent access: `restricted` — Board catalog-review API; agents can discover authorized +tools. + +Policy: Connection/profile/policy management routes require a board user; tool use +separately evaluates agent grants and action policies. + +Evidence: `source-only` — [implementation](../../server/src/routes/tool-access.ts#L517) +at the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Catalog refresh/review and quarantined-action enablement are management +operations. + +## `test-action` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/tool-connections/{connectionId}/test-calls`. + +Agent access: `restricted` — Board test-as-agent API; agent executes its own assigned +tools normally. + +Policy: Connection/profile/policy management routes require a board user; tool use +separately evaluates agent grants and action policies. + +Evidence: `source-only` — [implementation](../../server/src/routes/tool-access.ts#L517) +at the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: A board test performed as an agent is not proof the agent can manage or invoke the +testing endpoint. + +## `account-maintenance` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/tool-connections/{connectionId}/reconnect`. +- `GET /api/tool-connections/{connectionId}/activity`. + +Agent access: `restricted` — Board account management; native +connections_search/connection_request can request human setup. + +Policy: Connection/profile/policy management routes require a board user; tool use +separately evaluates agent grants and action policies. + +Evidence: `source-only` — [implementation](../../server/src/routes/tool-access.ts#L517) +at the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Agent requests and continued tool use exist; reconnect/revoke/audit management are +human paths. diff --git a/maps/agent-access-map/budgets-costs.md b/maps/agent-access-map/budgets-costs.md new file mode 100644 index 00000000000..e716ebc9e5b --- /dev/null +++ b/maps/agent-access-map/budgets-costs.md @@ -0,0 +1,108 @@ +# Costs, budgets, and spending controls — agent access + +Product map: [Costs, budgets, and spending controls](../feature-map/budgets-costs.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `cost-reporting` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/costs/summary`. +- `GET /api/companies/{companyId}/costs/by-agent`. + +Agent access: `available` — REST / native call_api / CLI cost. + +Policy: Reading costs uses company/resource cost policy. Budget policies and incident +resolution require a board actor. + +Evidence: `source-only` — [implementation](../../server/src/routes/costs.ts#L346) at the +reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Cost visibility applies. Provider accounting imports and quota-window inspection +have additional board gates. + +## `budgets` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/budgets/overview`. +- `POST /api/companies/{companyId}/budgets/policies`. + +Agent access: `restricted` — Agent-readable overview where allowed; board-only +configuration. + +Policy: Reading costs uses company/resource cost policy. Budget policies and incident +resolution require a board actor. + +Evidence: `source-only` — [implementation](../../server/src/routes/costs.ts#L346) at the +reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Read API exists; no agent-write path for budget policies, including agents with +ordinary company access. + +## `incidents` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/budgets/overview`. + +Agent access: `available` — REST / native call_api for allowed visibility. + +Policy: Reading costs uses company/resource cost policy. Budget policies and incident +resolution require a board actor. + +Evidence: `source-only` — [implementation](../../server/src/routes/costs.ts#L346) at the +reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Inspection is supported; hard-stop or pause relief does not follow from seeing the +incident. + +## `resolution` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/budget-incidents/{incidentId}/resolve`. + +Agent access: `restricted` — Board resolution API; agent requests the authorized +person's action. + +Policy: Reading costs uses company/resource cost policy. Budget policies and incident +resolution require a board actor. + +Evidence: `source-only` — [implementation](../../server/src/routes/costs.ts#L346) at the +reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: A governance-policy decision is needed before delegating budget increases or +incident resolution. diff --git a/maps/agent-access-map/cases.md b/maps/agent-access-map/cases.md new file mode 100644 index 00000000000..b0dc7215b95 --- /dev/null +++ b/maps/agent-access-map/cases.md @@ -0,0 +1,108 @@ +# Cases and structured work records — agent access + +Product map: [Cases and structured work records](../feature-map/cases.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `browse` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/cases`. + +Agent access: `available` — REST / native call_api; agent cases reference. + +Policy: enableCases gate; company/case/task boundaries, active run where required, +source trust, and checked-out mutation ownership. + +Evidence: `source-only` — [implementation](../../server/src/routes/cases.ts#L592) at the +reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: No dedicated production list_cases binding is required because the REST fallback +exists. + +## `structure` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/cases`. +- `PATCH /api/cases/{id}`. + +Agent access: `available` — REST / native call_api; parentCaseId and JSON fields. + +Policy: enableCases gate; company/case/task boundaries, active run where required, +source trust, and checked-out mutation ownership. + +Evidence: `source-only` — [implementation](../../server/src/routes/cases.ts#L592) at the +reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Structured case data is supported; grouping/rendering is presentation, not a new +endpoint requirement. + +## `history` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/cases/{id}/events`. +- `GET /api/cases/{id}/documents/{key}/revisions`. + +Agent access: `available` — REST / native call_api. + +Policy: enableCases gate; company/case/task boundaries, active run where required, +source trust, and checked-out mutation ownership. + +Evidence: `source-only` — [implementation](../../server/src/routes/cases.ts#L592) at the +reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Source-backed access only; case visibility and document locks still apply. + +## `task-links` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/cases/{id}/links`. +- `GET /api/issues/{issueId}/cases`. + +Agent access: `available` — REST / native call_api. + +Policy: enableCases gate; company/case/task boundaries, active run where required, +source trust, and checked-out mutation ownership. + +Evidence: `source-only` — [implementation](../../server/src/routes/cases.ts#L592) at the +reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Case/task association exists; source trust and issue visibility must be tested +with agent credentials. diff --git a/maps/agent-access-map/chat-channels.md b/maps/agent-access-map/chat-channels.md new file mode 100644 index 00000000000..4aeeccdcd8a --- /dev/null +++ b/maps/agent-access-map/chat-channels.md @@ -0,0 +1,132 @@ +# External chat and email conversations — agent access + +Product map: [External chat and email conversations](../feature-map/chat-channels.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `providers` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/chat-endpoints`. + +Agent access: `restricted` — Board setup APIs; native connection request can ask for +setup. + +Policy: Endpoint management/identity linking requires a board user; delivery is +server-owned and agent tools use scoped assignments. + +Evidence: `source-only` — [implementation](../../server/src/routes/chat-channels.ts#L75) +at the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Provider installation and account enrollment are not available to an ordinary +agent actor. + +## `identity` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/chat-identity-links/confirm`. + +Agent access: `restricted` — Human identity-link flow. + +Policy: Endpoint management/identity linking requires a board user; delivery is +server-owned and agent tools use scoped assignments. + +Evidence: `source-only` — [implementation](../../server/src/routes/chat-channels.ts#L75) +at the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: No impersonation path; native call_api additionally blocks identity-link +operations. + +## `conversation` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/chat-endpoints/{endpointId}/conversations/{conversationId}/publications`. + +Agent access: `partial` — Assigned-agent reply pipeline; native completion; provider +tools where assigned. + +Policy: Endpoint management/identity linking requires a board user; delivery is +server-owned and agent tools use scoped assignments. + +Evidence: `source-only` — [implementation](../../server/src/routes/chat-channels.ts#L75) +at the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Normal agent replies work through server delivery. Explicit endpoint publication +APIs are board management paths. + +## `attachments` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/issues/{id}/attachments`. +- `POST /api/companies/{companyId}/issues/{issueId}/attachments`. + +Agent access: `partial` — Native register_deliverable / attachment tools when admitted; +agent REST; server-owned delivery. + +Policy: Endpoint management/identity linking requires a board user; delivery is +server-owned and agent tools use scoped assignments. + +Evidence: `source-only` — [implementation](../../server/src/routes/chat-channels.ts#L75) +at the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Uploading/registering is supported. Provider delivery status and board replay are +separate operations. + +## `lifecycle` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/chat-endpoints/{endpointId}/activity`. +- `PATCH /api/chat-endpoints/{endpointId}`. + +Agent access: `restricted` — Board lifecycle APIs; agent requests repair when blocked. + +Policy: Endpoint management/identity linking requires a board user; delivery is +server-owned and agent tools use scoped assignments. + +Evidence: `source-only` — [implementation](../../server/src/routes/chat-channels.ts#L75) +at the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Activity/repair/replay/disable use board endpoint access, not the agent's ability +to reply. diff --git a/maps/agent-access-map/cli-operations.md b/maps/agent-access-map/cli-operations.md new file mode 100644 index 00000000000..3513fe1593d --- /dev/null +++ b/maps/agent-access-map/cli-operations.md @@ -0,0 +1,134 @@ +# CLI, API clients, and local worktrees — agent access + +Product map: [CLI, API clients, and local worktrees](../feature-map/cli-operations.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `context` + +Review: `baseline`. + +API: `not-applicable` — Local CLI configuration has no matching domain REST resource; +explicitly classify local operator configuration. + +Agent access: `operator-only` — CLI context/profile options and issued agent +environment. + +Policy: CLI preserves the selected bearer identity and run header; installing a CLI does +not grant board rights. + +Evidence: `source-only` — [implementation](../../cli/src/commands/client/common.ts#L49) +at the reviewed commit above. + +Verification: `not-run` — exercise the listed local workflow in an authorized, owned +operator environment. Verify its isolation and result; do not count local machine +authority as company-agent API access. + +Gaps: Local CLI configuration has no matching domain REST resource; explicitly classify +local operator configuration. + +## `domain-commands` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/agents/me`. +- `GET /api/companies/{companyId}/issues`. + +Agent access: `available` — CLI client commands with agent bearer; REST and native +tools. + +Policy: CLI preserves the selected bearer identity and run header; installing a CLI does +not grant board rights. + +Evidence: `source-only` — [implementation](../../cli/src/commands/client/common.ts#L49) +at the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Each command reuses API authorization; a command listed in help is not necessarily +allowed to this agent. + +## `outputs` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `PUT /api/issues/{id}/documents/{key}`. +- `POST /api/issues/{id}/work-products`. + +Agent access: `available` — CLI issue/asset/skill commands; REST; native +document/deliverable tools. + +Policy: CLI preserves the selected bearer identity and run header; installing a CLI does +not grant board rights. + +Evidence: `source-only` — [implementation](../../cli/src/commands/client/common.ts#L49) +at the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Binary transfers, document versions and work-product registration need distinct +assertions. + +## `run-controls` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/heartbeat-runs/{runId}`. +- `POST /api/agents/{id}/heartbeat/invoke`. + +Agent access: `partial` — CLI run/agent commands; read API; board-only +invocation/control routes. + +Policy: CLI preserves the selected bearer identity and run header; installing a CLI does +not grant board rights. + +Evidence: `source-only` — [implementation](../../cli/src/commands/client/common.ts#L49) +at the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Agent credentials can read eligible runs. Operator invocation/cancellation must +not be counted as ordinary agent access. + +## `worktree-instances` + +Review: `baseline`. + +API: `not-applicable` — Local checkout and pre-start provisioning use local tools, not +company REST authorization. No new runtime is needed. + +Agent access: `operator-only` — CLI worktree/test-drive/dev service commands on an +authorized owned checkout. + +Policy: CLI preserves the selected bearer identity and run header; installing a CLI does +not grant board rights. + +Evidence: `source-only` — [implementation](../../cli/src/commands/client/common.ts#L49) +at the reviewed commit above. + +Verification: `not-run` — exercise the listed local workflow in an authorized, owned +operator environment. Verify its isolation and result; do not count local machine +authority as company-agent API access. + +Gaps: Local checkout and pre-start provisioning use local tools, not company REST +authorization. No new runtime is needed. diff --git a/maps/agent-access-map/companies.md b/maps/agent-access-map/companies.md new file mode 100644 index 00000000000..0366a4cbf4e --- /dev/null +++ b/maps/agent-access-map/companies.md @@ -0,0 +1,115 @@ +# Companies and portability — agent access + +Product map: [Companies and portability](../feature-map/companies.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `selection` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}`. +- `GET /api/companies`. +- `POST /api/companies`. + +Agent access: `partial` — Agent reads its bound company; CLI context chooses it; board +lists/creates companies. + +Policy: Same-company CEO or board for identity/import operations; list/create/archive +convenience endpoint is board-gated. + +Evidence: `source-only` — [implementation](../../server/src/routes/companies.ts#L1245) +at the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: A worker cannot enumerate/create companies via its company agent key. Browser +selection is local state. + +## `identity` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `PATCH /api/companies/{companyId}`. +- `PATCH /api/companies/{companyId}/branding`. + +Agent access: `available` — Same-company CEO REST / native call_api / CLI company. + +Policy: Same-company CEO or board for identity/import operations; list/create/archive +convenience endpoint is board-gated. + +Evidence: `source-only` — [implementation](../../server/src/routes/companies.ts#L1245) +at the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: The CEO restriction is a real policy, not a missing API; ordinary employees need +denial evidence. + +## `archive` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/archive`. +- `PATCH /api/companies/{companyId}`. + +Agent access: `partial` — Board archive endpoint; CEO company PATCH can express status +changes. + +Policy: Same-company CEO or board for identity/import operations; list/create/archive +convenience endpoint is board-gated. + +Evidence: `source-only` — [implementation](../../server/src/routes/companies.ts#L1245) +at the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Convenience archive and general company PATCH have different authorization. Review +equivalence rather than declaring all archival human-only. + +## `portability` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/exports`. +- `POST /api/companies/{companyId}/imports/preview`. +- `POST /api/companies/{companyId}/imports/apply`. + +Agent access: `partial` — CEO REST / CLI company; native export restricted, eligible +same-company import uses fallback. + +Policy: Same-company CEO or board for identity/import operations; list/create/archive +convenience endpoint is board-gated. + +Evidence: `source-only` — [implementation](../../server/src/routes/companies.ts#L1245) +at the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Secure exports lack a default native binding; new-company/ZIP import flows have +different board and transport constraints. diff --git a/maps/agent-access-map/connection-setup.md b/maps/agent-access-map/connection-setup.md new file mode 100644 index 00000000000..99e430698db --- /dev/null +++ b/maps/agent-access-map/connection-setup.md @@ -0,0 +1,207 @@ +# Connection setup — agent access + +Product map: [Connection setup](../feature-map/connection-setup.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `catalog` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /runtime-tools/connections/search`. + +Agent access: `available` — Native connections_search / run-bound runtime-tools MCP. + +Policy: Setup and credentials belong to the addressed authorized human; active-run tools +can search/request access. + +Evidence: `source-only` — +[implementation](../../server/src/routes/connection-intents.ts#L130) at the reviewed +commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Runtime tool broker searches available services without granting connection +administration. + +## `identity-and-access` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/tools/connections`. + +Agent access: `restricted` — Board setup API plus agent connection_request. + +Policy: Setup and credentials belong to the addressed authorized human; active-run tools +can search/request access. + +Evidence: `source-only` — +[implementation](../../server/src/routes/connection-intents.ts#L130) at the reviewed +commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Selecting account ownership/eligible agents is management; an agent can request +the intended connection. + +## `credentials` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/agents/me/connections/{connectionId}/start-authorization`. + +Agent access: `partial` — Scoped start-authorization/request flow; human provider login. + +Policy: Setup and credentials belong to the addressed authorized human; active-run tools +can search/request access. + +Evidence: `source-only` — +[implementation](../../server/src/routes/connection-intents.ts#L130) at the reviewed +commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: An initiation endpoint exists; it is not authorization to complete the person's +credential flow. + +## `handoff` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/connection-intents/{interactionId}/complete`. + +Agent access: `restricted` — Addressed-user completion; server wakes the agent with +updated access. + +Policy: Setup and credentials belong to the addressed authorized human; active-run tools +can search/request access. + +Evidence: `source-only` — +[implementation](../../server/src/routes/connection-intents.ts#L130) at the reviewed +commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: OAuth/callback and addressed-user checks are deliberate separate boundaries. + +## `draft-recovery` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/connection-intents/{interactionId}/phase`. +- `POST /api/tool-connections/{connectionId}/reconnect`. + +Agent access: `restricted` — Human setup/resume APIs; agent request state and +continuation. + +Policy: Setup and credentials belong to the addressed authorized human; active-run tools +can search/request access. + +Evidence: `source-only` — +[implementation](../../server/src/routes/connection-intents.ts#L130) at the reviewed +commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Board setup recovery exists. Browser-only navigation state is not automatically an +agent API gap. + +## `task-request` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /runtime-tools/connections/request`. + +Agent access: `available` — Native connection_request / run-bound runtime-tools MCP. + +Policy: Setup and credentials belong to the addressed authorized human; active-run tools +can search/request access. + +Evidence: `source-only` — +[implementation](../../server/src/routes/connection-intents.ts#L130) at the reviewed +commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: This is the existing agent bridge for human-required setup; do not invent a second +approval system. + +## `channel-setup` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/chat-endpoints`. + +Agent access: `restricted` — Board channel management; agent requests required setup. + +Policy: Setup and credentials belong to the addressed authorized human; active-run tools +can search/request access. + +Evidence: `source-only` — +[implementation](../../server/src/routes/connection-intents.ts#L130) at the reviewed +commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Provider endpoint, represented agent and linked sender are operator choices today. + +## `failure` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/tool-connections/{connectionId}/health-check`. + +Agent access: `partial` — Agent observes connection tool errors; board repair APIs. + +Policy: Setup and credentials belong to the addressed authorized human; active-run tools +can search/request access. + +Evidence: `source-only` — +[implementation](../../server/src/routes/connection-intents.ts#L130) at the reviewed +commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Error reporting and human repair exist; no generic agent permission to reconnect +accounts. diff --git a/maps/agent-access-map/developer-labs.md b/maps/agent-access-map/developer-labs.md new file mode 100644 index 00000000000..c8034e1d574 --- /dev/null +++ b/maps/agent-access-map/developer-labs.md @@ -0,0 +1,99 @@ +# Developer previews and diagnostic labs — agent access + +Product map: [Developer previews and diagnostic labs](../feature-map/developer-labs.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `design` + +Review: `baseline`. + +API: `not-applicable` — No domain API required for static component examples; label this +as an explicit exception. + +Agent access: `not-applicable` — Browser/Storybook / existing UI tooling. + +Policy: Synthetic developer fixtures; no persisted company feature or agent authority +represented by the fixture. + +Evidence: `source-only` — [implementation](../../ui/src/App.tsx) at the reviewed commit +above. + +Verification: `not-run` — use the product recipe for the local/developer surface. Verify +listed shared-resource reads separately; local presentation alone is not agent API +evidence. + +Gaps: No domain API required for static component examples; label this as an explicit +exception. + +## `interaction-labs` + +Review: `baseline`. + +API: `not-applicable` — A synthetic card's API parity belongs to the real feature, not +to a new lab API. + +Agent access: `not-applicable` — Browser fixture plus the corresponding real interaction +API in its owning area. + +Policy: Synthetic developer fixtures; no persisted company feature or agent authority +represented by the fixture. + +Evidence: `source-only` — [implementation](../../ui/src/App.tsx) at the reviewed commit +above. + +Verification: `not-run` — use the product recipe for the local/developer surface. Verify +listed shared-resource reads separately; local presentation alone is not agent API +evidence. + +Gaps: A synthetic card's API parity belongs to the real feature, not to a new lab API. + +## `performance` + +Review: `baseline`. + +API: `not-applicable` — There is no domain REST action for scrolling a static fixture; +capture browser evidence separately. + +Agent access: `not-applicable` — Browser performance fixture and measurement tooling. + +Policy: Synthetic developer fixtures; no persisted company feature or agent authority +represented by the fixture. + +Evidence: `source-only` — [implementation](../../ui/src/App.tsx) at the reviewed commit +above. + +Verification: `not-run` — use the product recipe for the local/developer surface. Verify +listed shared-resource reads separately; local presentation alone is not agent API +evidence. + +Gaps: There is no domain REST action for scrolling a static fixture; capture browser +evidence separately. + +## `route-gates` + +Review: `baseline`. + +API: `not-applicable` — Route registration and feature gates are not interchangeable; no +universal developer-only gate exists. + +Agent access: `not-applicable` — Build/route inspection; relevant instance +feature-setting API for actual gates. + +Policy: Synthetic developer fixtures; no persisted company feature or agent authority +represented by the fixture. + +Evidence: `source-only` — [implementation](../../ui/src/App.tsx) at the reviewed commit +above. + +Verification: `not-run` — use the product recipe for the local/developer surface. Verify +listed shared-resource reads separately; local presentation alone is not agent API +evidence. + +Gaps: Route registration and feature gates are not interchangeable; no universal +developer-only gate exists. diff --git a/maps/agent-access-map/documents-artifacts.md b/maps/agent-access-map/documents-artifacts.md new file mode 100644 index 00000000000..afa3297a1dc --- /dev/null +++ b/maps/agent-access-map/documents-artifacts.md @@ -0,0 +1,139 @@ +# Documents, attachments, and work products — agent access + +Product map: [Documents, attachments, and work +products](../feature-map/documents-artifacts.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `documents` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/issues/{id}/documents`. +- `PUT /api/issues/{id}/documents/{key}`. +- `POST /api/issues/{id}/documents/{key}/revisions/{revisionId}/restore`. + +Agent access: `available` — Native list/read/write documents; other-task bridge; REST / +CLI / MCP. + +Policy: Task/resource visibility, mutation/checkout ownership, revision/lock guards and +permitted deliverable context. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L10299) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Supported. Native helpers focus on the active task; generic API or explicit +other-task tools cover additional scope. + +## `annotations` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/issues/{id}/documents/{key}/annotations`. +- `PATCH /api/issues/{id}/documents/{key}/annotations/{threadId}`. + +Agent access: `available` — REST / native call_api. + +Policy: Task/resource visibility, mutation/checkout ownership, revision/lock guards and +permitted deliverable context. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L10299) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Annotation operations exist without a special annotation tool; check revision +anchors and resolver authority. + +## `attachments` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/issues/{issueId}/attachments`. +- `GET /api/assets/{assetId}/content`. + +Agent access: `available` — Native register_deliverable and API multipart/binary +support; REST / CLI / MCP transfers. + +Policy: Task/resource visibility, mutation/checkout ownership, revision/lock guards and +permitted deliverable context. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L10299) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Native API uploads are bounded; attachment reading has target/provider and +permission conditions. + +## `work-products` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/issues/{id}/work-products`. +- `PATCH /api/work-products/{id}`. + +Agent access: `available` — Native register_deliverable for file outputs; REST / native +call_api for metadata. + +Policy: Task/resource visibility, mutation/checkout ownership, revision/lock guards and +permitted deliverable context. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L10299) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: A registered reference does not prove uploaded bytes, external delivery, or +accepted review. + +## `library` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/artifacts`. + +Agent access: `available` — REST / native call_api. + +Policy: Task/resource visibility, mutation/checkout ownership, revision/lock guards and +permitted deliverable context. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L10299) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Library search/filter uses the artifact API; no new tool needed merely to mirror +the UI. diff --git a/maps/agent-access-map/execution-environments.md b/maps/agent-access-map/execution-environments.md new file mode 100644 index 00000000000..74860af970f --- /dev/null +++ b/maps/agent-access-map/execution-environments.md @@ -0,0 +1,132 @@ +# Local, SSH, and sandbox environments — agent access + +Product map: [Local, SSH, and sandbox +environments](../feature-map/execution-environments.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `configuration` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/environments`. +- `POST /api/companies/{companyId}/environments`. + +Agent access: `restricted` — Board read/admin management REST / CLI environment. + +Policy: Environment management is instance administrator/board; permitted assignment +uses agent/project policy. + +Evidence: `source-only` — [implementation](../../server/src/routes/environments.ts#L353) +at the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: API exists; an agent cannot use instance environment management as an ordinary +company actor. + +## `capabilities` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/environments/capabilities`. + +Agent access: `partial` — Board environment capabilities endpoint; native run gets +server-selected runtime context. + +Policy: Environment management is instance administrator/board; permitted assignment +uses agent/project policy. + +Evidence: `source-only` — [implementation](../../server/src/routes/environments.ts#L353) +at the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Endpoint discovery and already-bound run capabilities differ; decide whether a +safe agent-readable capability listing is desired. + +## `probe` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/environments/{id}/probe`. +- `POST /api/companies/{companyId}/environments/probe-config`. + +Agent access: `restricted` — Board/admin probes / CLI environment. + +Policy: Environment management is instance administrator/board; permitted assignment +uses agent/project policy. + +Evidence: `source-only` — [implementation](../../server/src/routes/environments.ts#L353) +at the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Reachability probes are not granted by an agent's right to execute assigned work. + +## `assignment` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `PATCH /api/agents/{id}`. +- `PATCH /api/projects/{id}`. + +Agent access: `partial` — Permitted agent/project REST / native fallback, subject to +protected configuration policy. + +Policy: Environment management is instance administrator/board; permitted assignment +uses agent/project policy. + +Evidence: `source-only` — [implementation](../../server/src/routes/environments.ts#L353) +at the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Choosing among permitted targets differs from creating/configuring host targets; +test authorized and denied selection. + +## `images` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/environments/{environmentId}/custom-image-setup-sessions`. + +Agent access: `restricted` — Board/admin image APIs and existing terminal protocol. + +Policy: Environment management is instance administrator/board; permitted assignment +uses agent/project policy. + +Evidence: `source-only` — [implementation](../../server/src/routes/environments.ts#L353) +at the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: No default agent custom-image administration/terminal path; this is a policy +decision before adding a tool. diff --git a/maps/agent-access-map/gateways-profiles.md b/maps/agent-access-map/gateways-profiles.md new file mode 100644 index 00000000000..de326b65acf --- /dev/null +++ b/maps/agent-access-map/gateways-profiles.md @@ -0,0 +1,131 @@ +# Tool gateways and access profiles — agent access + +Product map: [Tool gateways and access profiles](../feature-map/gateways-profiles.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `gateway` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/tools/gateways`. + +Agent access: `restricted` — Board tools:admin REST; CLI connections/configuration where +supported. + +Policy: Named gateway/profile administration and credentials are board tools:admin; +agent sessions/tool use use separate scoped authority. + +Evidence: `source-only` — [implementation](../../server/src/routes/tool-gateway.ts#L353) +at the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: No agent named-gateway management path; assigned tool use exists separately. + +## `profiles` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/tools/profiles`. +- `PATCH /api/tool-profiles/{profileId}`. + +Agent access: `restricted` — Board management REST; agent receives effective tool +assignment. + +Policy: Named gateway/profile administration and credentials are board tools:admin; +agent sessions/tool use use separate scoped authority. + +Evidence: `source-only` — [implementation](../../server/src/routes/tool-gateway.ts#L353) +at the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Agent access to tools does not permit profile editing. + +## `client-connect` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/tool-gateway/gateways/{gatewayId}/mcp`. +- `POST /api/tool-gateway/sessions`. + +Agent access: `partial` — Existing scoped MCP client/session flow; assigned native app +tools. + +Policy: Named gateway/profile administration and credentials are board tools:admin; +agent sessions/tool use use separate scoped authority. + +Evidence: `source-only` — [implementation](../../server/src/routes/tool-gateway.ts#L353) +at the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Agent tool consumption exists. Copying/managing the endpoint and its client +credentials remain management operations. + +## `tokens` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/tool-gateway/gateways/{gatewayId}/tokens`. +- `POST /api/tool-gateway/gateway-tokens/{tokenId}/revoke`. + +Agent access: `restricted` — Board tools:admin token management. + +Policy: Named gateway/profile administration and credentials are board tools:admin; +agent sessions/tool use use separate scoped authority. + +Evidence: `source-only` — [implementation](../../server/src/routes/tool-gateway.ts#L353) +at the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Native call_api blocks credential management; a grant to use an app does not +create gateway-token authority. + +## `activity` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/tool-gateway/audit`. + +Agent access: `restricted` — Board tools:view_audit; agent receives its own tool +receipts/errors. + +Policy: Named gateway/profile administration and credentials are board tools:admin; +agent sessions/tool use use separate scoped authority. + +Evidence: `source-only` — [implementation](../../server/src/routes/tool-gateway.ts#L353) +at the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Audit management API exists but broad audit visibility is not an agent use-path +guarantee. diff --git a/maps/agent-access-map/goals.md b/maps/agent-access-map/goals.md new file mode 100644 index 00000000000..2bf3f977bff --- /dev/null +++ b/maps/agent-access-map/goals.md @@ -0,0 +1,87 @@ +# Goals and work alignment — agent access + +Product map: [Goals and work alignment](../feature-map/goals.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `hierarchy` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/goals`. +- `POST /api/companies/{companyId}/goals`. +- `PATCH /api/goals/{id}`. + +Agent access: `available` — REST / native call_api / CLI goal / MCP read helpers. + +Policy: Same-company access; related project/task assignments have their own resource +permissions. + +Evidence: `source-only` — [implementation](../../server/src/routes/goals.ts#L14) at the +reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Real REST path exists. Scenario list_goals declarations are not evidence of a +production native binding. + +## `alignment` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `PATCH /api/projects/{id}`. +- `PATCH /api/issues/{id}`. + +Agent access: `available` — REST / native call_api for permitted non-lifecycle fields. + +Policy: Same-company access; related project/task assignments have their own resource +permissions. + +Evidence: `source-only` — [implementation](../../server/src/routes/goals.ts#L14) at the +reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Goal association is data access, separate from completing or reassigning the task. + +## `navigation` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/goals/{id}`. +- `GET /api/projects/{id}`. +- `GET /api/issues/{id}`. + +Agent access: `available` — REST and task/project/goal read tools; links returned as +context. + +Policy: Same-company access; related project/task assignments have their own resource +permissions. + +Evidence: `source-only` — [implementation](../../server/src/routes/goals.ts#L14) at the +reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Navigation renders records already exposed by APIs; no separate click-navigation +API is required. diff --git a/maps/agent-access-map/inbox-search.md b/maps/agent-access-map/inbox-search.md new file mode 100644 index 00000000000..2c079231dd9 --- /dev/null +++ b/maps/agent-access-map/inbox-search.md @@ -0,0 +1,112 @@ +# Inbox, decisions, and search — agent access + +Product map: [Inbox, decisions, and search](../feature-map/inbox-search.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `views` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/agents/me/inbox-lite`. +- `GET /api/agents/me/inbox/mine`. +- `GET /api/companies/{companyId}/issues`. + +Agent access: `partial` — REST / native call_api; MCP inbox/task search. + +Policy: Own agent context; responsible-user inbox policy and explicit opt-in/grants for +cross-user archive; decision policy separately. + +Evidence: `source-only` — [implementation](../../server/src/routes/agents.ts#L3952) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Agent inbox and Mine support exist; board attention/unread/dismissal aggregation +is a separate user-only surface. + +## `triage` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/issues/{id}/inbox-archive`. +- `DELETE /api/issues/{id}/inbox-archive`. + +Agent access: `partial` — REST / native call_api; existing garden-inbox workflow. + +Policy: Own agent context; responsible-user inbox policy and explicit opt-in/grants for +cross-user archive; decision policy separately. + +Evidence: `source-only` — [implementation](../../server/src/routes/agents.ts#L3952) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Archive has an agent-aware responsible-user policy. Personal read/unread/dismissal +does not have equivalent agent semantics everywhere. + +## `decisions` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/decision-queues`. +- `GET /api/companies/{companyId}/decision-queues/{key}/items`. + +Agent access: `partial` — REST / native call_api under decision queue authorization. + +Policy: Own agent context; responsible-user inbox policy and explicit opt-in/grants for +cross-user archive; decision policy separately. + +Evidence: `source-only` — [implementation](../../server/src/routes/agents.ts#L3952) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Queue browsing/managing may be allowed; governed approval decisions still have +separate board/resolver boundaries. + +## `search` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/search`. +- `GET /api/companies/{companyId}/issues`. + +Agent access: `available` — Native search_tasks for tasks; REST / native call_api for +broader search. + +Policy: Own agent context; responsible-user inbox policy and explicit opt-in/grants for +cross-user archive; decision policy separately. + +Evidence: `source-only` — [implementation](../../server/src/routes/agents.ts#L3952) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: General company search and dedicated task search are different result sets; use +the appropriate one. diff --git a/maps/agent-access-map/instance-operations.md b/maps/agent-access-map/instance-operations.md new file mode 100644 index 00000000000..d51cccf323f --- /dev/null +++ b/maps/agent-access-map/instance-operations.md @@ -0,0 +1,130 @@ +# Installation, configuration, updates, and backups — agent access + +Product map: [Installation, configuration, updates, and +backups](../feature-map/instance-operations.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `install-update` + +Review: `baseline`. + +API: `not-applicable` — No uniform company REST updater API. Treat pre-start +installation as an operator exception or design an explicit delegated operator API. + +Agent access: `operator-only` — Existing CLI install/update/rollback commands. + +Policy: Local machine operator and instance-administrator authority; distinct from +company-scoped agent keys. + +Evidence: `source-only` — [implementation](../../cli/src/index.ts#L91) at the reviewed +commit above. + +Verification: `not-run` — exercise the listed local workflow in an authorized, owned +operator environment. Verify its isolation and result; do not count local machine +authority as company-agent API access. + +Gaps: No uniform company REST updater API. Treat pre-start installation as an operator +exception or design an explicit delegated operator API. + +## `server-service` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/health`. + +Agent access: `operator-only` — Existing CLI service/dev controls; health REST; owned +workspace services have separate agent APIs. + +Policy: Local machine operator and instance-administrator authority; distinct from +company-scoped agent keys. + +Evidence: `source-only` — [implementation](../../cli/src/index.ts#L91) at the reviewed +commit above. + +Verification: `not-run` — exercise the listed local workflow in an authorized, owned +operator environment. Verify its isolation and result; do not count local machine +authority as company-agent API access. + +Gaps: Instance start/stop and task workspace start/stop are different capabilities. +Local service control is not agent company authority. + +## `configuration` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/instance/settings`. +- `PATCH /api/instance/settings`. + +Agent access: `restricted` — Board/admin instance settings; CLI configure/env; local +configuration files. + +Policy: Local machine operator and instance-administrator authority; distinct from +company-scoped agent keys. + +Evidence: `source-only` — [implementation](../../cli/src/index.ts#L91) at the reviewed +commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Settings APIs cover parts of configuration; pre-start database/binding/storage +configuration is local operator tooling. + +## `diagnostics` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/health`. + +Agent access: `partial` — REST health plus local CLI doctor. + +Policy: Local machine operator and instance-administrator authority; distinct from +company-scoped agent keys. + +Evidence: `source-only` — [implementation](../../cli/src/index.ts#L91) at the reviewed +commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Health is an API; full local doctor/repair inspects host configuration and is not +equivalent to that response. + +## `backup` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/instance/database-backups`. + +Agent access: `restricted` — Board/admin backup API; local CLI db:backup; restore into +an authorized separate target. + +Policy: Local machine operator and instance-administrator authority; distinct from +company-scoped agent keys. + +Evidence: `source-only` — [implementation](../../cli/src/index.ts#L91) at the reviewed +commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: API creation exists. Restore/lifecycle are operator workflows rather than ordinary +company agent capabilities. diff --git a/maps/agent-access-map/navigation-preferences.md b/maps/agent-access-map/navigation-preferences.md new file mode 100644 index 00000000000..e5693a33b17 --- /dev/null +++ b/maps/agent-access-map/navigation-preferences.md @@ -0,0 +1,138 @@ +# Navigation, profile, and announcements — agent access + +Product map: [Navigation, profile, and +announcements](../feature-map/navigation-preferences.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `navigation` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/projects`. +- `GET /api/companies/{companyId}/resource-memberships/me`. + +Agent access: `partial` — Resource APIs plus user-specific stars; browser-local recents. + +Policy: Persisted user preferences require board user context; resource reads still +apply company visibility. + +Evidence: `source-only` — +[implementation](../../server/src/routes/sidebar-preferences.ts#L8) at the reviewed +commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Underlying resources have APIs; recents are client-local, and personal starring +needs its own identity policy. + +## `preferences` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/sidebar-preferences/me`. +- `PUT /api/sidebar-preferences/me`. + +Agent access: `restricted` — Board-user preference API; browser section collapse. + +Policy: Persisted user preferences require board user context; resource reads still +apply company visibility. + +Evidence: `source-only` — +[implementation](../../server/src/routes/sidebar-preferences.ts#L8) at the reviewed +commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Catalog metadata says board_or_agent, but requireBoardUserId rejects agents. +Collapse is in-memory; global collapse is disabled. + +## `profile` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/users/{userSlug}/profile`. +- `PATCH /api/auth/profile`. + +Agent access: `partial` — Agent can read eligible company profile; changing personal +identity requires board user. + +Policy: Persisted user preferences require board user context; resource reads still +apply company visibility. + +Evidence: `source-only` — +[implementation](../../server/src/routes/sidebar-preferences.ts#L8) at the reviewed +commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Reading another visible user's profile and editing a person's identity are +distinct permission paths. + +## `announcements` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/announcements/current`. +- `POST /api/announcements/{id}/dismiss`. + +Agent access: `restricted` — Board-user announcement API. + +Policy: Persisted user preferences require board user context; resource reads still +apply company visibility. + +Evidence: `source-only` — +[implementation](../../server/src/routes/sidebar-preferences.ts#L8) at the reviewed +commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: User-specific instance-wide dismissal has no agent delegation path today. + +## `fallbacks` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/issues/{id}`. +- `GET /api/projects/{id}`. + +Agent access: `partial` — API not-found/access semantics; browser router redirects. + +Policy: Persisted user preferences require board user context; resource reads still +apply company visibility. + +Evidence: `source-only` — +[implementation](../../server/src/routes/sidebar-preferences.ts#L8) at the reviewed +commit above. + +Verification: `not-run` — use the product recipe for the local/developer surface. Verify +listed shared-resource reads separately; local presentation alone is not agent API +evidence. + +Gaps: Record lookup exists. Route redirects/fallback presentation do not need a separate +domain API. diff --git a/maps/agent-access-map/onboarding.md b/maps/agent-access-map/onboarding.md new file mode 100644 index 00000000000..f4eb025f50c --- /dev/null +++ b/maps/agent-access-map/onboarding.md @@ -0,0 +1,136 @@ +# Onboarding and first work — agent access + +Product map: [Onboarding and first work](../feature-map/onboarding.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `instance` + +Review: `baseline`. + +API: `not-applicable` — Bootstrapping an instance cannot depend solely on an API served +by that not-yet-running instance. + +Agent access: `operator-only` — Existing CLI onboard/run/doctor / test-drive. + +Policy: Instance setup is operator work; company creation is board; agent creation/task +creation retain their individual permissions. + +Evidence: `source-only` — +[implementation](../../ui/src/components/OnboardingWizard.tsx#L279) at the reviewed +commit above. + +Verification: `not-run` — exercise the listed local workflow in an authorized, owned +operator environment. Verify its isolation and result; do not count local machine +authority as company-agent API access. + +Gaps: Bootstrapping an instance cannot depend solely on an API served by that +not-yet-running instance. + +## `company` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies`. +- `POST /api/companies/{companyId}/agents`. + +Agent access: `partial` — Board company creation; authorized in-company agent hiring +API. + +Policy: Instance setup is operator work; company creation is board; agent creation/task +creation retain their individual permissions. + +Evidence: `source-only` — +[implementation](../../ui/src/components/OnboardingWizard.tsx#L279) at the reviewed +commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: API building blocks exist; an ordinary agent cannot create its parent company with +a company key. + +## `adapter` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/adapters/{type}/models`. +- `POST /api/companies/{companyId}/adapters/{type}/test-environment`. + +Agent access: `partial` — Permitted agent/model API; operator +provider-account/environment setup. + +Policy: Instance setup is operator work; company creation is board; agent creation/task +creation retain their individual permissions. + +Evidence: `source-only` — +[implementation](../../ui/src/components/OnboardingWizard.tsx#L279) at the reviewed +commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Account authorization, environment administration and model selection need +separate checks. + +## `first-task` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/issues`. + +Agent access: `available` — Native create_task / agent REST / CLI issue. + +Policy: Instance setup is operator work; company creation is board; agent creation/task +creation retain their individual permissions. + +Evidence: `source-only` — +[implementation](../../ui/src/components/OnboardingWizard.tsx#L279) at the reviewed +commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Task creation is agent-accessible where permitted; scheduling/readiness does not +follow from successful creation. + +## `resume` + +Review: `baseline`. + +API: `not-applicable` — No server onboarding-draft API for this browser-local state; +classify explicitly or intentionally make it a cross-client product feature. + +Agent access: `not-applicable` — Browser localStorage wizard draft; persisted resources +use their domain APIs. + +Policy: Instance setup is operator work; company creation is board; agent creation/task +creation retain their individual permissions. + +Evidence: `source-only` — +[implementation](../../ui/src/components/OnboardingWizard.tsx#L279) at the reviewed +commit above. + +Verification: `not-run` — use the product recipe for the local/developer surface. Verify +listed shared-resource reads separately; local presentation alone is not agent API +evidence. + +Gaps: No server onboarding-draft API for this browser-local state; classify explicitly +or intentionally make it a cross-client product feature. diff --git a/maps/agent-access-map/pipelines.md b/maps/agent-access-map/pipelines.md new file mode 100644 index 00000000000..b33ca8ce805 --- /dev/null +++ b/maps/agent-access-map/pipelines.md @@ -0,0 +1,109 @@ +# Pipelines, review queues, and learnings — agent access + +Product map: [Pipelines, review queues, and learnings](../feature-map/pipelines.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `stages` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/pipelines/{pipelineId}/stages`. +- `PATCH /api/pipelines/{pipelineId}/stages/{stageId}`. + +Agent access: `available` — REST / native call_api / CLI pipelines where supported. + +Policy: Company access, active agent run for mutations, case/issue source trust and +ownership, and review audience. + +Evidence: `source-only` — [implementation](../../server/src/routes/pipelines.ts#L265) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Configuration paths exist. Generic fallback blocks case automation execution +paths; do not conflate configuration and dispatch. + +## `items` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/pipelines/{pipelineId}/cases`. +- `POST /api/pipelines/{pipelineId}/cases`. + +Agent access: `available` — REST / native call_api / CLI pipelines. + +Policy: Company access, active agent run for mutations, case/issue source trust and +ownership, and review audience. + +Evidence: `source-only` — [implementation](../../server/src/routes/pipelines.ts#L265) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Agents need run ID on mutations; task-linked changes also check issue +mutation/checkout ownership. + +## `review` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/review-cases`. +- `POST /api/cases/{caseId}/review`. + +Agent access: `available` — REST / native call_api / CLI pipeline case review. + +Policy: Company access, active agent run for mutations, case/issue source trust and +ownership, and review audience. + +Evidence: `source-only` — [implementation](../../server/src/routes/pipelines.ts#L265) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Review policy and actor ownership still apply; existence of the endpoint is not +blanket review authority. + +## `learnings` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/case-events`. + +Agent access: `available` — REST / native call_api / CLI case events. + +Policy: Company access, active agent run for mutations, case/issue source trust and +ownership, and review audience. + +Evidence: `source-only` — [implementation](../../server/src/routes/pipelines.ts#L265) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Learning view is a filtered case-event projection; no separate learning API is +necessary. diff --git a/maps/agent-access-map/plugins.md b/maps/agent-access-map/plugins.md new file mode 100644 index 00000000000..2f81f2062e9 --- /dev/null +++ b/maps/agent-access-map/plugins.md @@ -0,0 +1,107 @@ +# Plugins and contributed product surfaces — agent access + +Product map: [Plugins and contributed product surfaces](../feature-map/plugins.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `installation` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/plugins/install`. + +Agent access: `restricted` — Instance-admin REST / CLI plugin. + +Policy: Instance plugin management requires board/admin; declared tools and scoped APIs +apply their own company/agent policy. + +Evidence: `source-only` — [implementation](../../server/src/routes/plugins.ts#L965) at +the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: No ordinary agent install path; installing executable plugin code is an explicit +authority decision. + +## `configuration` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/plugins/{pluginId}/config`. +- `POST /api/plugins/{pluginId}/config`. + +Agent access: `restricted` — Board read/admin write; CLI plugin. + +Policy: Instance plugin management requires board/admin; declared tools and scoped APIs +apply their own company/agent policy. + +Evidence: `source-only` — [implementation](../../server/src/routes/plugins.ts#L965) at +the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Company-scoped configuration value does not turn the instance management route +into an agent route. + +## `contributions` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/plugins/tools`. +- `POST /api/plugins/tools/execute`. + +Agent access: `partial` — Agent REST plus assigned native/gateway plugin tools and +scoped plugin APIs. + +Policy: Instance plugin management requires board/admin; declared tools and scoped APIs +apply their own company/agent policy. + +Evidence: `source-only` — [implementation](../../server/src/routes/plugins.ts#L965) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Actual plugin tool use exists; a contributed UI route alone does not prove that +plugin's API/tool coverage. + +## `lifecycle` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/plugins/{pluginId}/enable`. +- `POST /api/plugins/{pluginId}/disable`. + +Agent access: `restricted` — Instance-admin REST / CLI plugin; dev watcher for reload. + +Policy: Instance plugin management requires board/admin; declared tools and scoped APIs +apply their own company/agent policy. + +Evidence: `source-only` — [implementation](../../server/src/routes/plugins.ts#L965) at +the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Disable/remove and development reload are management workflows with no default +company agent access. diff --git a/maps/agent-access-map/projects.md b/maps/agent-access-map/projects.md new file mode 100644 index 00000000000..bdec3cf2aa3 --- /dev/null +++ b/maps/agent-access-map/projects.md @@ -0,0 +1,113 @@ +# Projects and repositories — agent access + +Product map: [Projects and repositories](../feature-map/projects.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `lifecycle` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/projects`. +- `PATCH /api/projects/{id}`. +- `DELETE /api/projects/{id}`. + +Agent access: `available` — Native create_project/list_projects; REST / CLI / MCP. + +Policy: Company/project visibility, protected configuration/runtime policies; some +repository/privacy management requires board. + +Evidence: `source-only` — [implementation](../../server/src/routes/projects.ts#L278) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Actual data operations exist; project discovery and private-resource permissions +still apply. + +## `task-context` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/issues`. +- `POST /api/companies/{companyId}/issues`. + +Agent access: `available` — Native create_task/search_tasks; REST / CLI / MCP. + +Policy: Company/project visibility, protected configuration/runtime policies; some +repository/privacy management requires board. + +Evidence: `source-only` — [implementation](../../server/src/routes/projects.ts#L278) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Use explicit project context; task creation, assignment, and execution remain +separate checks. + +## `repositories` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/project-repositories`. +- `PUT /api/projects/{id}/repositories`. + +Agent access: `partial` — Native list_project_repositories/create_project for initial +selection; board-only replacement API. + +Policy: Company/project visibility, protected configuration/runtime policies; some +repository/privacy management requires board. + +Evidence: `source-only` — [implementation](../../server/src/routes/projects.ts#L278) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Authorized initial selection works. Replacement of repositories on an existing +project hard-requires board and needs a delegation decision. + +## `defaults` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `PATCH /api/projects/{id}`. +- `GET /api/projects/{id}/workspaces`. + +Agent access: `partial` — REST / native call_api for permitted fields; CLI +project/workspace. + +Policy: Company/project visibility, protected configuration/runtime policies; some +repository/privacy management requires board. + +Evidence: `source-only` — [implementation](../../server/src/routes/projects.ts#L278) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Description/goal/target selection exist; host workspace command mutation is denied +to agents and budget policy is board-managed. diff --git a/maps/agent-access-map/questions-and-approvals.md b/maps/agent-access-map/questions-and-approvals.md new file mode 100644 index 00000000000..7ed7d2de128 --- /dev/null +++ b/maps/agent-access-map/questions-and-approvals.md @@ -0,0 +1,212 @@ +# Questions and approvals — agent access + +Product map: [Questions and approvals](../feature-map/questions-and-approvals.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `questions` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/issues/{id}/interactions`. +- `POST /api/issues/{id}/interactions/{interactionId}/respond`. + +Agent access: `partial` — Native request_human_input; REST / MCP forms; authorized +responder endpoint. + +Policy: Current task/run and named audience/resolver policy; governed approval decisions +require board independently of an ordinary interaction. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L16638) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Agent creates questions. It cannot fabricate a human answer; answering follows +resolver policy. + +## `dismiss-and-reopen` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/issues/{id}/interactions`. + +Agent access: `partial` — Persisted question API plus browser card state. + +Policy: Current task/run and named audience/resolver policy; governed approval decisions +require board independently of an ordinary interaction. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L16638) at +the reviewed commit above. + +Verification: `not-run` — use the product recipe for the local/developer surface. Verify +listed shared-resource reads separately; local presentation alone is not agent API +evidence. + +Gaps: Presentation dismissal/reopening does not resolve the question and does not +require a new mutation API. + +## `confirmations` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/issues/{id}/interactions`. +- `POST /api/issues/{id}/interactions/{interactionId}/resolve-from-comment`. + +Agent access: `partial` — Native request_human_input and call_api resolve-from-comment; +authorized REST resolver. + +Policy: Current task/run and named audience/resolver policy; governed approval decisions +require board independently of an ordinary interaction. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L16638) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Conversational confirmation is supported under resolver policy; generic +acceptance/response controls remain restricted. + +## `plan-review` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `PUT /api/issues/{id}/documents/{key}`. +- `POST /api/issues/{id}/interactions`. + +Agent access: `partial` — Native write_document/request_human_input; explicit review +recipient. + +Policy: Current task/run and named audience/resolver policy; governed approval decisions +require board independently of an ordinary interaction. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L16638) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Plan revision and confirmation work. Accepting as a human is not a generic agent +capability. + +## `audience` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/issues/{id}/interactions`. + +Agent access: `available` — API resolver checks; assigned review context/resolve_review +for a native review run. + +Policy: Current task/run and named audience/resolver policy; governed approval decisions +require board independently of an ordinary interaction. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L16638) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Audience is a permission boundary to test, not an endpoint that creates new +authority. + +## `governance` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/approvals`. +- `POST /api/approvals/{id}/approve`. +- `POST /api/approvals/{id}/reject`. + +Agent access: `restricted` — Agent REST/MCP can request/read/comment/resubmit; formal +approve/reject require board. + +Policy: Current task/run and named audience/resolver policy; governed approval decisions +require board independently of an ordinary interaction. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L16638) at +the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Scenario decide_approval does not establish a production agent approval path. +Preserve explicit approver authority. + +## `tool-review` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/tool-gateway/action-requests/{id}/approve`. +- `POST /api/tool-gateway/action-requests/{id}/decline`. + +Agent access: `restricted` — Agent invokes assigned ask-policy tool; board approval +executes stored request. + +Policy: Current task/run and named audience/resolver policy; governed approval decisions +require board independently of an ordinary interaction. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L16638) at +the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: The request/continuation path exists. Agent cannot grant itself once/always-allow +scope via generic API. + +## `continuation` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/issues/{id}/interactions`. +- `GET /api/heartbeat-runs/{runId}`. + +Agent access: `available` — Server delivery and resumed assigned agent context. + +Policy: Current task/run and named audience/resolver policy; governed approval decisions +require board independently of an ordinary interaction. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L16638) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Continuation is server-owned; prove exactly one wake/no duplicate action after +resolution with existing integration fixtures. diff --git a/maps/agent-access-map/recovery.md b/maps/agent-access-map/recovery.md new file mode 100644 index 00000000000..a6a12a3f1c0 --- /dev/null +++ b/maps/agent-access-map/recovery.md @@ -0,0 +1,207 @@ +# Recovery — agent access + +Product map: [Recovery](../feature-map/recovery.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `diagnosis` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/issues/{id}/diagnostics/blockers`. +- `GET /api/issues/{id}/recovery-actions`. + +Agent access: `available` — REST / native call_api; CLI issue diagnostics. + +Policy: Task/run visibility, run ownership and source/action policy; workspace export +retry and some operator controls require board. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L9478) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Diagnostics expose recorded cause and boundaries; source-backed reading does not +prove successful recovery. + +## `bounded-continuation` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/issues/{id}/recovery-actions/resolve`. + +Agent access: `partial` — Eligible agent REST / CLI; native generic mutation restricted. + +Policy: Task/run visibility, run ownership and source/action policy; workspace export +retry and some operator controls require board. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L9478) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Underlying route has agent checks; no corresponding default native recovery +mutation tool is advertised. + +## `source-action` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/issues/{id}/recovery-actions/resolve`. + +Agent access: `partial` — Eligible agent REST / CLI; source-bound recovery state. + +Policy: Task/run visibility, run ownership and source/action policy; workspace export +retry and some operator controls require board. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L9478) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Need operation-level eligibility and a real native binding for permitted recovery +actions; do not route around lifecycle authority. + +## `exhaustion` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/issues/{id}/recovery-actions`. +- `GET /api/heartbeat-runs/{runId}`. + +Agent access: `partial` — REST / native call_api reads; operator escalation flow. + +Policy: Task/run visibility, run ownership and source/action policy; workspace export +retry and some operator controls require board. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L9478) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Reading exhaustion is supported. Continuing past the recorded gate requires the +specified owner/action. + +## `workspace` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/issues/{id}/recovery-actions/retry-workspace-export`. +- `POST /api/execution-workspaces/{id}/reconcile-branch`. + +Agent access: `restricted` — Board/runtime management APIs; agent reads diagnosis and +performs permitted workspace work. + +Policy: Task/run visibility, run ownership and source/action policy; workspace export +retry and some operator controls require board. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L9478) at +the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Export retry/reconciliation APIs exist but require board. Native generic +execution-control mutations are also blocked. + +## `unknown-outcome` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/chat-endpoints/{endpointId}/publications/{publicationId}/resolve`. + +Agent access: `restricted` — Board provider-outcome resolution; agent may inspect +assigned provider tools. + +Policy: Task/run visibility, run ownership and source/action policy; workspace export +retry and some operator controls require board. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L9478) at +the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Uncertain-side-effect resolution is operator/source-specific; do not replay to +obtain apparent parity. + +## `user-continuation` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/issues/{id}/comments`. + +Agent access: `partial` — User comment API and server-owned wake reconsideration; native +agent consumes bound wake. + +Policy: Task/run visibility, run ownership and source/action policy; workspace export +retry and some operator controls require board. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L9478) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Receiving the continuation is supported; impersonating a saved user message or +retry control is not. + +## `reconnect` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/events/ws`. +- `GET /api/issues/{id}`. + +Agent access: `partial` — Existing event protocol; REST refresh of persisted task state. + +Policy: Task/run visibility, run ownership and source/action policy; workspace export +retry and some operator controls require board. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L9478) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Streaming reconnect needs its protocol client. Polling/read APIs remain available; +replay is not proof of a fresh successful action. diff --git a/maps/agent-access-map/routines.md b/maps/agent-access-map/routines.md new file mode 100644 index 00000000000..bb3ec03599a --- /dev/null +++ b/maps/agent-access-map/routines.md @@ -0,0 +1,135 @@ +# Routines, schedules, and triggers — agent access + +Product map: [Routines, schedules, and triggers](../feature-map/routines.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `definition` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/routines`. +- `PATCH /api/routines/{id}`. + +Agent access: `partial` — Agent REST / CLI routine create/update; optional installed MCP +generic request. + +Policy: Agents may read company routines but only create/manage routines assigned to +themselves; trigger secrets need secure handling. + +Evidence: `source-only` — [implementation](../../server/src/routes/routines.ts#L99) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Native call_api rejects routine mutation; manage_routine is scenario-only/unbound. +Agent REST is implemented. + +## `triggers` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/routines/{id}/triggers`. +- `PATCH /api/routine-triggers/{id}`. + +Agent access: `partial` — Owned-routine agent REST / CLI routine trigger commands. + +Policy: Agents may read company routines but only create/manage routines assigned to +themselves; trigger secrets need secure handling. + +Evidence: `source-only` — [implementation](../../server/src/routes/routines.ts#L99) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: No production default native trigger-management binding. Webhook creation/rotation +also returns credential material. + +## `run-now` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/routines/{id}/run`. + +Agent access: `partial` — Owned-routine agent REST / CLI routine run. + +Policy: Agents may read company routines but only create/manage routines assigned to +themselves; trigger secrets need secure handling. + +Evidence: `source-only` — [implementation](../../server/src/routes/routines.ts#L99) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: A scheduled/waiting native agent cannot use call_api for this eligible operation; +a real control binding is missing. + +## `history` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/routines/{id}/runs`. +- `GET /api/routines/{id}/revisions`. + +Agent access: `available` — REST / native call_api / CLI routine. + +Policy: Agents may read company routines but only create/manage routines assigned to +themselves; trigger secrets need secure handling. + +Evidence: `source-only` — [implementation](../../server/src/routes/routines.ts#L99) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Runs are readable in company scope; configuration revision access checks routine +ownership for agents. + +## `management` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `PATCH /api/routines/{id}`. + +Agent access: `partial` — Owned-routine agent REST / CLI; normal native fallback can +manage separate folder metadata where permitted. + +Policy: Agents may read company routines but only create/manage routines assigned to +themselves; trigger secrets need secure handling. + +Evidence: `source-only` — [implementation](../../server/src/routes/routines.ts#L99) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Pause/archive routine mutation lacks a production native binding. Plugin-managed +routine rules remain separate. diff --git a/maps/agent-access-map/runs-adapters.md b/maps/agent-access-map/runs-adapters.md new file mode 100644 index 00000000000..bd2f509c545 --- /dev/null +++ b/maps/agent-access-map/runs-adapters.md @@ -0,0 +1,111 @@ +# Runs, harnesses, and model accounts — agent access + +Product map: [Runs, harnesses, and model accounts](../feature-map/runs-adapters.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `availability` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/adapters`. +- `GET /api/companies/{companyId}/adapters/{type}/models`. + +Agent access: `partial` — Agent company-model API and bound runtime context; board +adapter/account inventories. + +Policy: Run/cost visibility and agent configuration policy; adapter installation/account +login/session control require board/admin. + +Evidence: `source-only` — [implementation](../../server/src/routes/agents.ts#L6608) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Raw instance adapter listing requires board, while company model discovery exists +for eligible agents. + +## `validation` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/adapters/{type}/test-environment`. + +Agent access: `partial` — Permitted agent-creation validation path / REST / CLI; +operator account login. + +Policy: Run/cost visibility and agent configuration policy; adapter installation/account +login/session control require board/admin. + +Evidence: `source-only` — [implementation](../../server/src/routes/agents.ts#L6608) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Environment test path checks creation/configuration policy; missing provider auth +must use its owner setup flow. + +## `runs` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/heartbeat-runs/{runId}`. +- `GET /api/heartbeat-runs/{runId}/log`. + +Agent access: `available` — REST / native call_api / CLI run. + +Policy: Run/cost visibility and agent configuration policy; adapter installation/account +login/session control require board/admin. + +Evidence: `source-only` — [implementation](../../server/src/routes/agents.ts#L6608) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Provider trace reveals/downloads require instance-admin and cannot be counted as +ordinary run output access. + +## `session` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/agents/{id}/runtime-state/reset-session`. +- `GET /api/agents/{id}/task-sessions`. + +Agent access: `restricted` — Board session controls; native server-owned session +continuation. + +Policy: Run/cost visibility and agent configuration policy; adapter installation/account +login/session control require board/admin. + +Evidence: `source-only` — [implementation](../../server/src/routes/agents.ts#L6608) at +the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Execution context reset is not a permitted generic native API mutation or an +ordinary agent management path. diff --git a/maps/agent-access-map/secrets.md b/maps/agent-access-map/secrets.md new file mode 100644 index 00000000000..083c56da74c --- /dev/null +++ b/maps/agent-access-map/secrets.md @@ -0,0 +1,138 @@ +# Secrets, personal credentials, and proposals — agent access + +Product map: [Secrets, personal credentials, and proposals](../feature-map/secrets.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `company-secrets` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/secrets/catalog`. +- `POST /api/companies/{companyId}/secrets`. + +Agent access: `partial` — Agent metadata catalog; board secret administration; agent +secret proposal API. + +Policy: Board secret administration, active-run/self/report proposal policy, and scoped +secret broker bindings; personal secret values belong to their user. + +Evidence: `source-only` — [implementation](../../server/src/routes/secrets.ts#L194) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Metadata and requesting a change exist; generic native API blocks secret +management/value transport. + +## `user-secrets` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/me/user-secrets`. +- `POST /api/companies/{companyId}/me/user-secrets`. + +Agent access: `restricted` — Current board user's personal-secret API; agent receives +only authorized run binding. + +Policy: Board secret administration, active-run/self/report proposal policy, and scoped +secret broker bindings; personal secret values belong to their user. + +Evidence: `source-only` — [implementation](../../server/src/routes/secrets.ts#L194) at +the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Agent cannot edit a person's private credential value through their ordinary agent +key. + +## `proposals` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/agents/me/secret-proposals`. +- `GET /api/agents/me/secret-proposals`. + +Agent access: `partial` — Active-run agent REST with redaction; board +approval/rejection. + +Policy: Board secret administration, active-run/self/report proposal policy, and scoped +secret broker bindings; personal secret values belong to their user. + +Evidence: `source-only` — [implementation](../../server/src/routes/secrets.ts#L194) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Native call_api blocks secret-proposals and no dedicated production proposal tool +is in its default authority set. + +## `vault-import` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/secrets/remote-import/preview`. +- `POST /api/companies/{companyId}/secrets/remote-import`. + +Agent access: `restricted` — Board authorized vault import API / CLI provider +operations. + +Policy: Board secret administration, active-run/self/report proposal policy, and scoped +secret broker bindings; personal secret values belong to their user. + +Evidence: `source-only` — [implementation](../../server/src/routes/secrets.ts#L194) at +the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Vault import is not implied by having a secret binding or the ability to propose a +secret. + +## `runtime-delivery` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/agents/me/secrets`. +- `POST /api/agents/me/secrets/{key}/value`. + +Agent access: `partial` — Authorized environment/broker delivery; eligible legacy agent +REST access. + +Policy: Board secret administration, active-run/self/report proposal policy, and scoped +secret broker bindings; personal secret values belong to their user. + +Evidence: `source-only` — [implementation](../../server/src/routes/secrets.ts#L194) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Native metadata reads exist; value call is blocked and scenario read_secret_value +is unbound. Do not add raw secret-returning fallback tools. diff --git a/maps/agent-access-map/skills.md b/maps/agent-access-map/skills.md new file mode 100644 index 00000000000..b4ce67184c8 --- /dev/null +++ b/maps/agent-access-map/skills.md @@ -0,0 +1,141 @@ +# Skill discovery, authoring, and policy — agent access + +Product map: [Skill discovery, authoring, and policy](../feature-map/skills.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `discovery` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/skills`. +- `POST /api/companies/{companyId}/skills/import`. + +Agent access: `available` — REST / native call_api / CLI skills and sources. + +Policy: Company skill policy by action/resource plus platform invariants; test execution +additionally requires task-assignment authority. + +Evidence: `source-only` — +[implementation](../../server/src/routes/company-skills.ts#L208) at the reviewed commit +above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Installation/import is governed by company skill policy, not inferred from an +agent's role name. + +## `authoring` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/skills`. +- `PATCH /api/companies/{companyId}/skills/{skillId}/files`. + +Agent access: `available` — Native create_skill/update_skill for single-file operations; +REST for other files/metadata. + +Policy: Company skill policy by action/resource plus platform invariants; test execution +additionally requires task-assignment authority. + +Evidence: `source-only` — +[implementation](../../server/src/routes/company-skills.ts#L208) at the reviewed commit +above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Writable/fork policy and expected version apply. Native helpers intentionally +support a narrower shape than full API. + +## `versions` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/skills/{skillId}/versions`. + +Agent access: `available` — REST / native call_api / CLI skills. + +Policy: Company skill policy by action/resource plus platform invariants; test execution +additionally requires task-assignment authority. + +Evidence: `source-only` — +[implementation](../../server/src/routes/company-skills.ts#L208) at the reviewed commit +above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Revision metadata has an API; pinned execution skill versions and installed latest +are distinct. + +## `policy` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `PUT /api/companies/{companyId}/skill-policy`. +- `POST /api/agents/{id}/skills/sync`. + +Agent access: `partial` — REST / native call_api under users:manage_permissions or +target configuration policy. + +Policy: Company skill policy by action/resource plus platform invariants; test execution +additionally requires task-assignment authority. + +Evidence: `source-only` — +[implementation](../../server/src/routes/company-skills.ts#L208) at the reviewed commit +above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Policy administration has an explicit agent grant branch; assigning agent skills +also checks the target's config authority. + +## `testing` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/skills/{skillId}/test-runs`. + +Agent access: `partial` — Permitted agent REST; native read fallback for history; no +dedicated CLI test command found. + +Policy: Company skill policy by action/resource plus platform invariants; test execution +additionally requires task-assignment authority. + +Evidence: `source-only` — +[implementation](../../server/src/routes/company-skills.ts#L208) at the reviewed commit +above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Native call_api rejects test-run creation/control while the route can authorize +agents. No default native orchestration binding. diff --git a/maps/agent-access-map/status-cards.md b/maps/agent-access-map/status-cards.md new file mode 100644 index 00000000000..ef2dbf42b66 --- /dev/null +++ b/maps/agent-access-map/status-cards.md @@ -0,0 +1,110 @@ +# Status cards and refreshed summaries — agent access + +Product map: [Status cards and refreshed summaries](../feature-map/status-cards.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `authoring` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/status-cards`. +- `PATCH /api/status-cards/{id}`. + +Agent access: `available` — REST / native call_api. + +Policy: Feature gate, tasks:assign decision; agents manage only cards they authored; +compiler/summarizer has extra bound-task checks. + +Evidence: `source-only` — [implementation](../../server/src/routes/status-cards.ts#L32) +at the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Real agent create/update permission branch exists; not inherently a board-only +feature. + +## `generation` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/status-cards/{id}/recompile`. +- `POST /api/status-cards/{id}/refresh`. + +Agent access: `available` — REST / native call_api for authorized own card. + +Policy: Feature gate, tasks:assign decision; agents manage only cards they authored; +compiler/summarizer has extra bound-task checks. + +Evidence: `source-only` — [implementation](../../server/src/routes/status-cards.ts#L32) +at the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Execution result must be checked. Agent-authored prompt length and tasks:assign +limits apply. + +## `inspection` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/status-cards/{id}`. +- `GET /api/status-cards/{id}/summary-revisions`. +- `GET /api/status-cards/{id}/dry-run`. + +Agent access: `available` — REST / native call_api. + +Policy: Feature gate, tasks:assign decision; agents manage only cards they authored; +compiler/summarizer has extra bound-task checks. + +Evidence: `source-only` — [implementation](../../server/src/routes/status-cards.ts#L32) +at the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Read/history/debug APIs exist; a rendered compiling state is not evidence of +successful generation. + +## `archive` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `PATCH /api/status-cards/{id}`. + +Agent access: `available` — REST / native call_api under own-card management policy. + +Policy: Feature gate, tasks:assign decision; agents manage only cards they authored; +compiler/summarizer has extra bound-task checks. + +Evidence: `source-only` — [implementation](../../server/src/routes/status-cards.ts#L32) +at the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Archive/restore is represented by card state, separate from delete; check exact +allowed status transition. diff --git a/maps/agent-access-map/steering.md b/maps/agent-access-map/steering.md new file mode 100644 index 00000000000..0b872acd931 --- /dev/null +++ b/maps/agent-access-map/steering.md @@ -0,0 +1,180 @@ +# Steering and queued messages — agent access + +Product map: [Steering and queued messages](../feature-map/steering.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `queue` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/issues/{id}/queued-comments`. + +Agent access: `partial` — REST / native call_api for visible queue; server-owned queue +admission. + +Policy: Queue inspection follows task visibility; queued user-message mutation and run +cancellation require a board actor/user. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L16166) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Agents can read eligible queue state; sending/steering the person's queued +messages requires separate authority. + +## `edit-order-discard` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `PATCH /api/issues/{id}/queued-comments/{commentId}`. +- `PUT /api/issues/{id}/queued-comments/order`. +- `DELETE /api/issues/{id}/queued-comments/{commentId}`. + +Agent access: `restricted` — Board-user queue mutation API. + +Policy: Queue inspection follows task visibility; queued user-message mutation and run +cancellation require a board actor/user. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L16166) at +the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: No agent user-message edit/reorder/discard path; explicit delegated-user policy +needed before granting one. + +## `steer` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/issues/{id}/queued-comments/{commentId}/steer`. + +Agent access: `restricted` — Board-user execution control API; agent receives eligible +new run/wake. + +Policy: Queue inspection follows task visibility; queued user-message mutation and run +cancellation require a board actor/user. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L16166) at +the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Generic native API blocks this execution control; no authority to impersonate the +steering user. + +## `interrupt` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/issues/{id}/queued-comments/interrupt`. +- `POST /api/heartbeat-runs/{runId}/cancel`. + +Agent access: `restricted` — Board-user interruption/cancel API. + +Policy: Queue inspection follows task visibility; queued user-message mutation and run +cancellation require a board actor/user. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L16166) at +the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Explicit operator action is distinct from an agent yielding/blocking its own turn. + +## `stop-and-pause` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/issues/{id}/tree-holds`. +- `POST /api/agents/{id}/pause`. + +Agent access: `restricted` — Board control APIs; agent paperclip_finish/paperclip_block +for its own turn. + +Policy: Queue inspection follows task visibility; queued user-message mutation and run +cancellation require a board actor/user. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L16166) at +the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Task-tree/agent pause and confirmed stop need policy-preserving authority; no +generic agent management path. + +## `drafts` + +Review: `baseline`. + +API: `not-applicable` — No shared server draft resource for this presentation state; +cross-client drafts would be a deliberate new capability. + +Agent access: `not-applicable` — Browser-local task composer draft / submission state. + +Policy: Queue inspection follows task visibility; queued user-message mutation and run +cancellation require a board actor/user. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L16166) at +the reviewed commit above. + +Verification: `not-run` — use the product recipe for the local/developer surface. Verify +listed shared-resource reads separately; local presentation alone is not agent API +evidence. + +Gaps: No shared server draft resource for this presentation state; cross-client drafts +would be a deliberate new capability. + +## `approval-queue` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/issues/{id}/interactions`. +- `GET /api/issues/{id}/queued-comments`. + +Agent access: `partial` — Agent can inspect authorized persisted interactions; server +continuation/queue delivery. + +Policy: Queue inspection follows task visibility; queued user-message mutation and run +cancellation require a board actor/user. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L16166) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Resolving as a human and controlling queued wakes is separate from seeing the +approval response. diff --git a/maps/agent-access-map/task-coordination.md b/maps/agent-access-map/task-coordination.md new file mode 100644 index 00000000000..c32d5849d2c --- /dev/null +++ b/maps/agent-access-map/task-coordination.md @@ -0,0 +1,114 @@ +# Delegation, dependencies, and signoff — agent access + +Product map: [Delegation, dependencies, and +signoff](../feature-map/task-coordination.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `delegation` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/issues`. + +Agent access: `available` — Native create_task/reassign_task; REST / CLI / MCP. + +Policy: Task visibility/mutation, task-assignment and ownership policy; review audience +independent; tree controls board-only. + +Evidence: `source-only` — +[implementation](../../server/src/routes/issue-tree-control.ts#L95) at the reviewed +commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Parent/child and provenance paths exist; same-company assignment does not remove +protected task/resource policy. + +## `dependencies` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `PATCH /api/issues/{id}`. + +Agent access: `available` — Native set_dependencies for current task; agent REST under +mutation/checkout policy. + +Policy: Task visibility/mutation, task-assignment and ownership policy; review audience +independent; tree controls board-only. + +Evidence: `source-only` — +[implementation](../../server/src/routes/issue-tree-control.ts#L95) at the reviewed +commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Generic native PATCH rejects blockedByIssueIds so use its real dedicated tool. + +## `signoff` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `PATCH /api/issues/{id}`. +- `POST /api/issues/{id}/interactions`. + +Agent access: `partial` — Native paperclip_finish/paperclip_block and assigned +resolve_review; legacy agent REST disposition. + +Policy: Task visibility/mutation, task-assignment and ownership policy; review audience +independent; tree controls board-only. + +Evidence: `source-only` — +[implementation](../../server/src/routes/issue-tree-control.ts#L95) at the reviewed +commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Review policy determines final completion; formal board approval is a different +boundary. + +## `tree-control` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/issues/{id}/tree-control/preview`. +- `POST /api/issues/{id}/tree-holds`. +- `POST /api/issues/{id}/tree-holds/{holdId}/release`. + +Agent access: `restricted` — Board-only task-tree APIs. + +Policy: Task visibility/mutation, task-assignment and ownership policy; review audience +independent; tree controls board-only. + +Evidence: `source-only` — +[implementation](../../server/src/routes/issue-tree-control.ts#L95) at the reviewed +commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Even reads/state/preview require board. There is no agent tree-control path just +from company/task permissions. diff --git a/maps/agent-access-map/tasks.md b/maps/agent-access-map/tasks.md new file mode 100644 index 00000000000..d0ccdbc978c --- /dev/null +++ b/maps/agent-access-map/tasks.md @@ -0,0 +1,136 @@ +# Task creation and lifecycle — agent access + +Product map: [Task creation and lifecycle](../feature-map/tasks.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `create` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/issues`. + +Agent access: `available` — Native create_task; REST / CLI / MCP. + +Policy: Company/task visibility, assignment/change policy, active-run checkout ownership +and lifecycle rules. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts) at the +reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Creation is implemented; agent/user assignment and attachments may need +narrower/different APIs. + +## `organize` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/issues`. + +Agent access: `available` — Native search_tasks; REST / CLI; UI grouping/navigation +state separately. + +Policy: Company/task visibility, assignment/change policy, active-run checkout ownership +and lifecycle rules. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts) at the +reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Search/filter API exists; sorting/grouping/presentation defaults are not +necessarily shared server preferences. + +## `properties` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `PATCH /api/issues/{id}`. +- `PUT /api/issues/{id}/title`. + +Agent access: `partial` — Native set_task_title/reassign_task/set_dependencies; fallback +for permitted non-lifecycle fields; REST. + +Policy: Company/task visibility, assignment/change policy, active-run checkout ownership +and lifecycle rules. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts) at the +reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Native fallback blocks status/assignment/dependency policy fields. Dedicated tools +differ in scope and supported fields. + +## `discussion` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/issues/{id}/comments`. +- `POST /api/companies/{companyId}/issues/{issueId}/attachments`. + +Agent access: `available` — Native report_progress/comment_on_task/register_deliverable; +REST / CLI / MCP. + +Policy: Company/task visibility, assignment/change policy, active-run checkout ownership +and lifecycle rules. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts) at the +reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Other-task comments and active-task progress have separate scopes; explicit +reopen/interrupt is not a generic comment capability. + +## `completion` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `PATCH /api/issues/{id}`. + +Agent access: `available` — Native paperclip_finish/paperclip_block through runner +protocol; legacy agent REST disposition. + +Policy: Company/task visibility, assignment/change policy, active-run checkout ownership +and lifecycle rules. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts) at the +reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Server governs whether a reported result becomes done/review/blocked. A generic +lifecycle PATCH is intentionally rejected for native runs. diff --git a/maps/agent-access-map/teams.md b/maps/agent-access-map/teams.md new file mode 100644 index 00000000000..600e463eca6 --- /dev/null +++ b/maps/agent-access-map/teams.md @@ -0,0 +1,108 @@ +# Team packages and catalog installation — agent access + +Product map: [Team packages and catalog installation](../feature-map/teams.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `discovery` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/teams/catalog`. +- `GET /api/teams/catalog/{catalogId}`. + +Agent access: `available` — REST / native call_api / CLI teams. + +Policy: Authenticated catalog browsing; same-company preview; agent install requires +agents:create grant or canCreateAgents. + +Evidence: `source-only` — [implementation](../../server/src/routes/teams-catalog.ts#L36) +at the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Team browsing exists even though there is no dedicated teams product screen. + +## `preview` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/teams/catalog/{catalogId}/preview`. + +Agent access: `available` — REST / native call_api / CLI teams preview. + +Policy: Authenticated catalog browsing; same-company preview; agent install requires +agents:create grant or canCreateAgents. + +Evidence: `source-only` — [implementation](../../server/src/routes/teams-catalog.ts#L36) +at the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Preview does not install a team; its audit write should not be mistaken for agent +execution proof. + +## `install` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/teams/catalog/{catalogId}/install`. + +Agent access: `available` — Authorized agent REST / native call_api / CLI teams install. + +Policy: Authenticated catalog browsing; same-company preview; agent install requires +agents:create grant or canCreateAgents. + +Evidence: `source-only` — [implementation](../../server/src/routes/teams-catalog.ts#L36) +at the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Actual agent permission branch exists. Resulting agents/accounts/workspaces may +still need operator setup. + +## `configuration` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/teams/catalog/{catalogId}/preview`. +- `POST /api/companies/{companyId}/teams/catalog/{catalogId}/install`. + +Agent access: `partial` — Team package variables/options via REST/CLI; resulting +resources use their own APIs. + +Policy: Authenticated catalog browsing; same-company preview; agent install requires +agents:create grant or canCreateAgents. + +Evidence: `source-only` — [implementation](../../server/src/routes/teams-catalog.ts#L36) +at the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: There is no independent generic team-edit resource; distinguish package options +from installed agent/project configuration. diff --git a/maps/agent-access-map/workspaces.md b/maps/agent-access-map/workspaces.md new file mode 100644 index 00000000000..5804821580c --- /dev/null +++ b/maps/agent-access-map/workspaces.md @@ -0,0 +1,144 @@ +# Execution workspaces, services, and files — agent access + +Product map: [Execution workspaces, services, and files](../feature-map/workspaces.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `binding` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/issues/{id}/heartbeat-context`. +- `GET /api/execution-workspaces/{id}`. + +Agent access: `available` — Native get_task_context / REST / CLI / MCP workspace read. + +Policy: Company/project/workspace visibility and runtime authority; active run, trust +preset and task ownership where required. + +Evidence: `source-only` — +[implementation](../../server/src/routes/workspace-runtime-service-authz.ts#L76) at the +reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Run/task context identifies its actual realized workspace; record existence is not +a running service. + +## `provisioning` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/execution-workspaces/{id}`. +- `GET /api/execution-workspaces/{id}/workspace-operations`. + +Agent access: `partial` — REST / native call_api reads; task/project assignment triggers +server provisioning. + +Policy: Company/project/workspace visibility and runtime authority; active run, trust +preset and task ownership where required. + +Evidence: `source-only` — +[implementation](../../server/src/routes/workspace-runtime-service-authz.ts#L76) at the +reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Observing readiness exists. Creating host commands or arbitrary provision targets +is a separate protected operation. + +## `services` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/execution-workspaces/{id}/runtime-services/{action}`. + +Agent access: `partial` — Authorized agent REST / CLI workspace runtime-service / +existing stdio MCP controls. + +Policy: Company/project/workspace visibility and runtime authority; active run, trust +preset and task ownership where required. + +Evidence: `source-only` — +[implementation](../../server/src/routes/workspace-runtime-service-authz.ts#L76) at the +reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Native call_api blocks runtime-service mutation; control_workspace_service is not +in the production authority set. Optional MCP availability must be recorded. + +## `files-git` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/issues/{issueId}/file-resources/content`. +- `GET /api/issues/{issueId}/file-resources/list`. + +Agent access: `partial` — Authorized workspace filesystem tools; Dot workspace bridge +when enabled; board file-resource HTTP UI. + +Policy: Company/project/workspace visibility and runtime authority; active run, trust +preset and task ownership where required. + +Evidence: `source-only` — +[implementation](../../server/src/routes/workspace-runtime-service-authz.ts#L76) at the +reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Files panel HTTP requires board. Agents can use their admitted workspace file +tools; remote/Dot scope and Git command availability differ. + +## `closure` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `PATCH /api/execution-workspaces/{id}`. +- `GET /api/execution-workspaces/{id}/close-readiness`. +- `POST /api/execution-workspaces/{id}/reconcile-branch`. + +Agent access: `partial` — REST / native fallback for permitted update; CLI workspace; +reconcile-branch requires board. + +Policy: Company/project/workspace visibility and runtime authority; active run, trust +preset and task ownership where required. + +Evidence: `source-only` — +[implementation](../../server/src/routes/workspace-runtime-service-authz.ts#L76) at the +reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Closure/readiness exists; not every workspace can be reopened, and branch +reconciliation has a board boundary. diff --git a/feature-map/README.md b/maps/feature-map/README.md similarity index 83% rename from feature-map/README.md rename to maps/feature-map/README.md index b38d7fb650c..0ac2ab9a5b9 100644 --- a/feature-map/README.md +++ b/maps/feature-map/README.md @@ -3,13 +3,17 @@ Start here when reproducing a user-facing bug, verifying a change, or deciding which surfaces a fix must cover. Each recipe describes what a person can do, where they can do it, the expected result, and the evidence needed to verify it. -Product behavior is still governed by [the implementation spec](../doc/SPEC-implementation.md). +Product behavior is still governed by [the implementation spec](../../doc/SPEC-implementation.md). + +The sibling [agent access map](../agent-access-map/README.md) records APIs, +agent entry points and permission boundaries for the same capability IDs. +Follow the [shared map contract](../README.md) when maintaining either map. ## Features The map is organized by user capability, not by source file. It covers **35 feature families** across the product, CLI, and operator workflows, checked against source -on 2026-10-05. Experimental and developer-only surfaces are labeled explicitly. +on 2026-10-12. Experimental and developer-only surfaces are labeled explicitly. Each recipe includes sub-features, current entry points, automated evidence, manual verification steps, and gotchas. @@ -94,7 +98,7 @@ fixture integration. Record executed results separately from the map. ## Before driving a journey -1. Use this checkout's [isolated test drive](../doc/DEVELOPING.md#one-command-isolated-manual-test-drive) +1. Use this checkout's [isolated test drive](../../doc/DEVELOPING.md#one-command-isolated-manual-test-drive) for manual product checks. It creates a temporary instance and prints its URL and data directory. Do not guess that a server on port 3100 belongs to you. 2. Record the commit, URL, company, login role, relevant experimental settings, @@ -113,9 +117,12 @@ fixture integration. Record executed results separately from the map. persisted state or continuation. Use read-only API checks to corroborate UI evidence; creating state through the API does not prove the creation UI. -For agent-driven acceptance work, the existing -[dev-workspace run/verify skill](../.agents/skills/paperclip-dev-workspace-run-verify-fix/SKILL.md) -and [evaluation skill](../.agents/skills/paperclip-evals/SKILL.md) describe runtime +For agent-driven acceptance work, start with the +[Paperclip verification skill](../../.agents/skills/verify-paperclip/SKILL.md) for an +isolated launch, ownership/readiness checks, browser driving, evidence, and cleanup. +It uses this directory as its feature map. The existing +[dev-workspace run/verify skill](../../.agents/skills/paperclip-dev-workspace-run-verify-fix/SKILL.md) +and [evaluation skill](../../.agents/skills/paperclip-evals/SKILL.md) describe runtime ownership and evidence handling. Reuse them; this map introduces no environment launcher, credentials, scheduled job, or second test framework. @@ -131,7 +138,7 @@ A fix is verified across its affected surfaces only when each has evidence or an explicit reason it does not apply. Shared code alone does not prove parity. Keep product regressions visible; do not change the recipe to bless a failure. For Paperclip-assigned work, attach evidence through the -[artifact workflow](../doc/AGENT-ARTIFACTS.md). +[artifact workflow](../../doc/AGENT-ARTIFACTS.md). ## Inventory and remaining depth @@ -145,8 +152,8 @@ The page inventory is a source snapshot dated 2026-10-05. The map also includes entry points hosted inside other modules (pipeline Review Queue and Learnings, task documents, onboarding), CLI-only operations, and operator work. The team recipe explicitly distinguishes the current CLI/API path from catalog -UI components without a current top-level route. Refer to [route registration](../ui/src/App.tsx) -and the [CLI registry](../cli/src/index.ts) when changing reachability. +UI components without a current top-level route. Refer to [route registration](../../ui/src/App.tsx) +and the [CLI registry](../../cli/src/index.ts) when changing reachability. Remaining depth includes complete provider/auth/attachment matrices, every harness/model/environment capability combination, third-party plugin features, @@ -156,22 +163,29 @@ can include capabilities that do not add a page file. ## Maintaining the reference -The map is documentation only. It adds no CI checks, automatic journey execution, -or required inventory updates for future pull requests. +The map documents user journeys. It does not execute them automatically; +`coverage.json` records documentation scope rather than live test health. When maintaining a recipe, compare its entry points and expected results with the current source. Check that linked tests still exercise the stated behavior and that local references still exist. Refresh the inventory snapshot when it -helps explain the current product. Maintenance is optional and review-based. +helps explain the current product. Keep the same-named agent access companion +and stable sub-feature IDs aligned when behavior or access changes. Source-only +access inventory remains distinct from live verification. Run +`node scripts/check-maps.mjs` after map edits for structural/reference checks; +follow the shared contract for the source and behavioral checks it cannot perform. Each recipe starts with an H1 and a user-visible description, then exactly: 1. **Sub-features** — stable backticked IDs and observable behavior/states. 2. **How to get to it (user POV)** — one H3 backticked entry-point ID per surface. -3. **Driving it** — starts with `Preconditions:`; repeat each entry-point H3, +3. **Driving it with <harness>** — starts with `Preconditions:`; repeat each entry-point H3, with `Automated:` and `Manual:` paragraphs. Name test scope and gaps honestly. 4. **Gotchas** — misleading look-alikes, feature gates, and invalid evidence. -The format is inspired by +New or updated entries follow +[Poteto's feature-map examples](../../.agents/skills/create-verification-skill/references/feature-map-example). +Existing entries with a `Driving it` heading can adopt the named harness when +their recipes are updated. The original format was inspired by [Omnigent's feature map](https://github.com/omnigent-ai/omnigent/tree/91acfbbb59f6fc210ff95a9e9428aadd62e06582/feature-map). Paperclip's recipes and checks follow its own task model and test infrastructure. diff --git a/feature-map/access.md b/maps/feature-map/access.md similarity index 65% rename from feature-map/access.md rename to maps/feature-map/access.md index d7248c8b63a..7bfed7126ee 100644 --- a/feature-map/access.md +++ b/maps/feature-map/access.md @@ -2,7 +2,9 @@ People and agents enter the correct instance and company with the permissions granted to their identity. Login, membership, resource access, and CLI authorization have separate controls. -Implementation: [auth page](../ui/src/pages/Auth.tsx), [company members](../ui/src/pages/CompanyAccess.tsx), [invitation](../ui/src/pages/InviteLanding.tsx). +Implementation: [auth page](../../ui/src/pages/Auth.tsx), [company members](../../ui/src/pages/CompanyAccess.tsx), [invitation](../../ui/src/pages/InviteLanding.tsx). + +Agent access: [companion](../agent-access-map/access.md). ## Sub-features @@ -21,33 +23,40 @@ Open `/auth`; first-owner bootstrap uses `/board-claim/:token` when configured. ### `invite-members` Open `/invite/:token`; operators manage members in company settings and join requests at `/inbox/requests`. +Create/revoke human links at Members → Invites +(`/company/settings/members?tab=invites`). Human invitees receive the chosen +role after sign-in; agent invitations still use join-request approval. ### `cli-authorization` Start the CLI auth/connect flow and approve its `/cli-auth/:id` browser page; instance access is under Settings when visible. -## Driving it +## Driving it with Playwright and the Paperclip CLI Preconditions: follow the [baseline](./README.md#before-driving-a-journey). For login tests use authenticated mode and two test users; local-trusted mode does not prove session enforcement. Use two companies for isolation checks. ### `login` -Automated: [auth UI](../ui/src/pages/Auth.test.tsx) and [session routes](../server/src/__tests__/auth-session-route.test.ts) cover their respective layers. +Automated: [auth UI](../../ui/src/pages/Auth.test.tsx) and [session routes](../../server/src/__tests__/auth-session-route.test.ts) cover their respective layers. Manual: Sign in, reload a company deep link, sign out, and revisit it. Confirm the browser requests authentication rather than showing cached company data. Test an invalid claim separately on disposable state. ### `invite-members` -Automated: [member UI](../ui/src/pages/CompanyAccess.test.tsx) and [invite replay](../server/src/__tests__/invite-accept-replay.test.ts) provide component and route evidence. +Automated: [member UI](../../ui/src/pages/CompanyAccess.test.tsx) and [invite replay](../../server/src/__tests__/invite-accept-replay.test.ts) provide component and route evidence. Manual: Accept an invite as the intended user, inspect the resulting company and permissions, then reuse and expire disposable invites. Change a member permission, reload as that member, and verify a denied operation on another company remains denied. ### `cli-authorization` -Automated: [board auth client](../cli/src/__tests__/board-auth.test.ts) and [token commands](../cli/src/__tests__/token.test.ts) cover the client contracts. +Automated: [board auth client](../../cli/src/__tests__/board-auth.test.ts) and [token commands](../../cli/src/__tests__/token.test.ts) cover the client contracts. Manual: Authorize the intended CLI context, make a read-only company request, then revoke its token and retry. Verify failure is explicit and does not silently fall back to another identity. +Use `auth login`, `auth whoami`, and `auth revoke-current` with explicit context. +Retry with the same bearer to prove revocation; deleting local credentials with +logout alone does not establish that server revocation succeeded. + ## Gotchas - Settings visibility is not authorization; direct requests still need permission checks. diff --git a/feature-map/activity.md b/maps/feature-map/activity.md similarity index 62% rename from feature-map/activity.md rename to maps/feature-map/activity.md index a50206a9fca..0a80901ddfa 100644 --- a/feature-map/activity.md +++ b/maps/feature-map/activity.md @@ -2,7 +2,9 @@ People inspect company health, recent activity, run history, and routine activity, then follow evidence back to the task or agent responsible. -Implementation: [dashboard](../ui/src/pages/Dashboard.tsx), [live dashboard](../ui/src/pages/DashboardLive.tsx), [audit hub](../ui/src/pages/audit/AuditHub.tsx). +Implementation: [dashboard](../../ui/src/pages/Dashboard.tsx), [live dashboard](../../ui/src/pages/DashboardLive.tsx), [audit hub](../../ui/src/pages/audit/AuditHub.tsx). + +Agent access: [companion](../agent-access-map/activity.md). ## Sub-features @@ -22,22 +24,27 @@ Open `/dashboard` or `/dashboard/live`. Open `/activity`, `/activity/runs`, or `/activity/timeline` in the streamlined shell; legacy routes may redirect. -## Driving it +## Driving it with Playwright and the Paperclip CLI Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Prepare known task mutations and completed/failed runs in a disposable company, recording their IDs and actors. ### `dashboard` -Automated: [dashboard service](../server/src/__tests__/dashboard-service.test.ts) covers server aggregation; [dashboard helpers](../ui/src/pages/Dashboard.test.ts) is narrower UI helper coverage. +Automated: [dashboard service](../../server/src/__tests__/dashboard-service.test.ts) covers server aggregation; [dashboard helpers](../../ui/src/pages/Dashboard.test.ts) is narrower UI helper coverage. Manual: Compare the dashboard with the known fixture tasks and runs. Start and finish a small task, refresh/reload, and verify the summary changes and links reach the right records. Exercise empty data and a failed fetch. ### `activity-audit` -Automated: [activity service](../server/src/__tests__/activity-service.test.ts), [audit runs](../ui/src/pages/audit/AuditRuns.test.tsx), and [routine audit](../ui/src/pages/audit/RoutineAuditActivity.test.tsx) cover these separate data/host layers. +Automated: [activity service](../../server/src/__tests__/activity-service.test.ts), [audit runs](../../ui/src/pages/audit/AuditRuns.test.tsx), and [routine audit](../../ui/src/pages/audit/RoutineAuditActivity.test.tsx) cover these separate data/host layers. Manual: Find a known mutation by actor/resource and a known run by status. Change filters, load more, and follow the source links. Reload the chosen view and verify no company leakage or duplicate events. Inspect a routine run from its audit entry. +Audit-event Load more and run listings differ: Runs currently loads at most 200 +entries without pagination. The tested RoutineAuditActivity component has no +registered activity-route host; inspect execution links actually offered by +the routine rather than treating that component test as a reachable journey. + ## Gotchas - Audit/activity events, run-log rows, first-party Telemetry, and operator OpenTelemetry traces are separate data paths. diff --git a/feature-map/agent-chat.md b/maps/feature-map/agent-chat.md similarity index 60% rename from feature-map/agent-chat.md rename to maps/feature-map/agent-chat.md index e47de311b56..7152588c2fc 100644 --- a/feature-map/agent-chat.md +++ b/maps/feature-map/agent-chat.md @@ -2,7 +2,9 @@ A person can hold a persistent conversation with an agent, return to its history, and hand useful work into a project or task. The gated conference-room chat is a separate conversation surface. -Implementation: [agent chat](../ui/src/pages/AgentChat.tsx), [chat landing](../ui/src/pages/AgentChats.tsx), [conference room](../ui/src/pages/BoardChat.tsx). +Implementation: [agent chat](../../ui/src/pages/AgentChat.tsx), [chat landing](../../ui/src/pages/AgentChats.tsx), [conference room](../../ui/src/pages/BoardChat.tsx). + +Agent access: [companion](../agent-access-map/agent-chat.md). ## Sub-features @@ -25,25 +27,32 @@ Ask for project work in Agent Chat and follow its created project/task links. Open `/board-chat` only when its conference-room gate is enabled. -## Driving it +## Driving it with Playwright and the Paperclip CLI Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Enable the applicable chat feature, use a configured test agent, and distinguish model replies from a mocked conversation fixture. ### `agent-conversation` -Automated: [chat landing](../ui/src/pages/AgentChats.test.tsx) checks recent-chat routing; [conversation service](../server/src/__tests__/agent-conversations.test.ts) covers persisted conversation behavior. +Automated: [chat landing](../../ui/src/pages/AgentChats.test.tsx) checks recent-chat routing; [conversation service](../../server/src/__tests__/agent-conversations.test.ts) covers persisted conversation behavior. -Manual: Start a conversation, send a recognizable request, and inspect its reply. Navigate away and reopen the same history. Test a new conversation, unavailable historical agent, and an inaccessible saved conversation; verify no cross-user or cross-company history appears. +Manual: Start a conversation, send a recognizable request, and inspect its reply. +Navigate away and reopen the same history. Use `/new` for a fresh session within +the same standing conversation. Test an unavailable historical agent and an +inaccessible saved conversation; verify no cross-user or cross-company history appears. ### `project-handoff` -Automated: [chat project tools](../server/src/__tests__/chat-project-tools.test.ts) checks tool contracts; live model choice and UI handoff require a manual run. +Automated: [chat project tools](../../server/src/__tests__/chat-project-tools.test.ts) checks tool contracts; live model choice and UI handoff require a manual run. Manual: Ask the configured agent to create bounded project work, inspect the proposed/created target, and open the resulting task. Verify the request context and intended project survived and the task produces a useful output. ### `conference-room` -Automated: [board chat gate](../server/src/__tests__/board-chat-route-feature-flag.test.ts) proves route gating, not a live conversation. +Automated: [board chat gate](../../server/src/__tests__/board-chat-route-feature-flag.test.ts) proves route gating, not a live conversation. + +Preconditions for this entry point: `local_trusted` mode and an installed, +authenticated `claude` CLI, in addition to the Conference Room flag. These are +separate from Agent Chat's configured-agent prerequisites. Manual: Check disabled access first, enable in the disposable instance, then send and reload a conversation. Inspect the actual responding agent and persisted history; report this result separately from Agent Chat. diff --git a/feature-map/agents.md b/maps/feature-map/agents.md similarity index 61% rename from feature-map/agents.md rename to maps/feature-map/agents.md index a1633a52da5..673d7c45982 100644 --- a/feature-map/agents.md +++ b/maps/feature-map/agents.md @@ -2,11 +2,14 @@ Operators hire agents, configure their identity and instructions, assign reporting relationships, and manage their lifecycle. Built-in agents have their own setup and lifecycle constraints. -Implementation: [hiring](../ui/src/pages/NewAgent.tsx), [configuration](../ui/src/components/AgentConfigForm.tsx), [organization](../ui/src/pages/OrgChart.tsx). +Implementation: [hiring](../../ui/src/pages/NewAgent.tsx), [configuration](../../ui/src/components/AgentConfigForm.tsx), [organization](../../ui/src/pages/OrgChart.tsx). + +Agent access: [companion](../agent-access-map/agents.md). ## Sub-features -- `hire`: choose role, manager, adapter, model, and required account settings. +- `hire`: choose identity, adapter, model, and required account settings; edit + reporting relationships through the agent configuration afterward. - `instructions`: edit agent configuration and instruction files with durable revisions. - `organization`: inspect and change reporting relationships through supported controls. - `lifecycle`: pause/resume or retire an agent while preserving history. @@ -26,25 +29,30 @@ Open `/agents/:agentId` and the configuration/instruction surfaces it exposes. Use the roster’s organization view; `/org` redirects to the roster in the streamlined shell. -## Driving it +## Driving it with Playwright and the Paperclip CLI Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use a disposable company and supported adapter; hiring may require a formal approval. ### `hire-agent` -Automated: [new agent page](../ui/src/pages/NewAgent.test.tsx) and [configuration form](../ui/src/components/AgentConfigForm.render.test.tsx) cover mocked UI behavior. +Automated: [new agent page](../../ui/src/pages/NewAgent.test.tsx) and [configuration form](../../ui/src/components/AgentConfigForm.render.test.tsx) cover mocked UI behavior. Manual: Hire an agent with a distinct name and role. Complete any required approval, reload the roster, and open its configuration. Confirm manager, adapter, and model persisted; run a small task to prove execution readiness. +The current hiring wizard assigns the first hire CEO and subsequent hires +General reporting to the CEO. Change manager/role afterward through supported +configuration. Process/HTTP agents can be created through API/CLI but are excluded +from the hiring picker; API fixture creation does not prove this UI journey. + ### `agent-configuration` -Automated: [instruction service](../server/src/__tests__/agent-instructions-service.test.ts) and [instruction revisions](../server/src/__tests__/agent-instruction-revisions.test.ts) cover file/revision contracts. +Automated: [instruction service](../../server/src/__tests__/agent-instructions-service.test.ts) and [instruction revisions](../../server/src/__tests__/agent-instruction-revisions.test.ts) cover file/revision contracts. Manual: Save a harmless instruction edit, reload, and verify its revision and effect in the next eligible run. Pause and resume the disposable agent; confirm pause prevents new work and does not erase previous runs. ### `organization` -Automated: [org chart](../ui/src/pages/OrgChart.test.tsx) is component evidence. Built-in setup also has [configuration modal tests](../ui/src/components/ConfigureBuiltInAgentModal.test.tsx). +Automated: [org chart](../../ui/src/pages/OrgChart.test.tsx) is component evidence. Built-in setup also has [configuration modal tests](../../ui/src/components/ConfigureBuiltInAgentModal.test.tsx). Manual: Inspect a manager and report, change the relationship through the offered controls, and reload both views. Exercise a rejected invalid relationship. Configure a built-in agent and verify it retains its required identity and restrictions. diff --git a/feature-map/app-permissions.md b/maps/feature-map/app-permissions.md similarity index 76% rename from feature-map/app-permissions.md rename to maps/feature-map/app-permissions.md index fd8a825c51f..7726294d328 100644 --- a/feature-map/app-permissions.md +++ b/maps/feature-map/app-permissions.md @@ -2,7 +2,9 @@ After connecting an app, an operator decides which agents may use it and whether each action is off, requires approval, or is allowed. Activity and action tests help verify the resulting access. -Implementation: [app detail](../ui/src/pages/apps/AppDetail.tsx), [permissions](../ui/src/pages/apps/app-detail/PermissionsPanel.tsx), [activity](../ui/src/pages/apps/app-detail/ActivityPanel.tsx). +Implementation: [app detail](../../ui/src/pages/apps/AppDetail.tsx), [permissions](../../ui/src/pages/apps/app-detail/PermissionsPanel.tsx), [activity](../../ui/src/pages/apps/app-detail/ActivityPanel.tsx). + +Agent access: [companion](../agent-access-map/app-permissions.md). ## Sub-features @@ -28,13 +30,13 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Prep ### `permissions` -Automated: [app detail](../ui/src/pages/apps/AppDetail.test.tsx) covers agent/action controls, quarantined actions, and navigation using mocked requests. +Automated: [app detail](../../ui/src/pages/apps/AppDetail.test.tsx) covers agent/action controls, quarantined actions, and navigation using mocked requests. Manual: Grant one agent access, leave another excluded, and exercise off, ask, and allowed states for a harmless action. Reload between changes. Verify actual denied calls stay denied and ask-first creates a decision rather than executing early. ### `test-and-maintain` -Automated: [app detail tests](../ui/src/pages/apps/AppDetail.test.tsx) cover test-dialog/UI contracts. Real provider execution and revocation require the selected provider’s live check. +Automated: [app detail tests](../../ui/src/pages/apps/AppDetail.test.tsx) cover test-dialog/UI contracts. Real provider execution and revocation require the selected provider’s live check. Manual: Run a read-only action with explicit inputs and inspect returned data and activity. Refresh discovery and review new actions. Revoke or disconnect only the disposable credential, then verify subsequent use fails with an actionable reconnect state. diff --git a/feature-map/budgets-costs.md b/maps/feature-map/budgets-costs.md similarity index 65% rename from feature-map/budgets-costs.md rename to maps/feature-map/budgets-costs.md index e1a1efc5734..2c7fed9149e 100644 --- a/feature-map/budgets-costs.md +++ b/maps/feature-map/budgets-costs.md @@ -2,7 +2,9 @@ Operators inspect recorded spend and configure budget controls for the company, projects, and agents. Hard stops pause or prevent eligible work until the governing incident is resolved. -Implementation: [costs and budget host](../ui/src/pages/Costs.tsx), [audit hub](../ui/src/pages/audit/AuditHub.tsx). +Implementation: [costs and budget host](../../ui/src/pages/Costs.tsx), [audit hub](../../ui/src/pages/audit/AuditHub.tsx). + +Agent access: [companion](../agent-access-map/budgets-costs.md). ## Sub-features @@ -21,19 +23,22 @@ Use Activity → Costs/Budgets in the streamlined shell, `/costs` in the product Open the governing budget configuration and any resulting budget incident card. -## Driving it +## Driving it with Playwright and the Paperclip CLI Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use synthetic low-cost events and small disposable budgets. Do not buy model usage merely to hit a threshold. ### `cost-overview` -Automated: [costs service](../server/src/__tests__/costs-service.test.ts) covers cost contracts; [embedded costs UI](../ui/src/pages/Costs.test.tsx) has narrow Audit-host coverage. +Automated: [costs service](../../server/src/__tests__/costs-service.test.ts) covers cost contracts; [embedded costs UI](../../ui/src/pages/Costs.test.tsx) has narrow Audit-host coverage. -Manual: Inspect a known fixture run and reconcile its recorded spend with the selected report period and resource. Switch company/project filters and reload. Check empty and unavailable cost data without treating missing data as zero spend. +Manual: Inspect a known fixture run and reconcile recorded spend with the chosen +period and resource. Switch the report period/company and inspect the **By +project** breakdown; it is not a project-selector filter. Reload and check empty +or unavailable cost data without treating missing data as zero spend. ### `budget-enforcement` -Automated: [budget service](../server/src/__tests__/budgets-service.test.ts) exercises soft incidents, hard stops, scope, and valid budget raises. +Automated: [budget service](../../server/src/__tests__/budgets-service.test.ts) exercises soft incidents, hard stops, scope, and valid budget raises. Manual: Create a small fixture budget, cross its threshold with synthetic recorded spend, and verify the warning or hard-stop state. Attempt new work and confirm it is gated. Raise the correct budget through an authorized action and verify eligible continuation without clearing unrelated manual pauses. diff --git a/feature-map/cases.md b/maps/feature-map/cases.md similarity index 51% rename from feature-map/cases.md rename to maps/feature-map/cases.md index 10cde78e5eb..a36ad4b9c13 100644 --- a/feature-map/cases.md +++ b/maps/feature-map/cases.md @@ -2,7 +2,9 @@ The cases experiment exposes structured records with types, fields, parent/child relationships, activity, revisions, and related task work. -Implementation: [case list](../ui/src/pages/Cases.tsx), [case detail](../ui/src/pages/CaseDetail.tsx). +Implementation: [case list](../../ui/src/pages/Cases.tsx), [case detail](../../ui/src/pages/CaseDetail.tsx). + +Agent access: [companion](../agent-access-map/cases.md). ## Sub-features @@ -21,23 +23,29 @@ Open `/cases` and switch between available grouped/tree views. Open `/cases/:caseIdentifier` or a task’s linked case. -## Driving it +## Driving it with Playwright and the Paperclip CLI Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Enable cases and prepare related records with different types/statuses plus an associated task. ### `case-list` -Automated: [case list](../ui/src/pages/Cases.test.tsx) covers filters, persisted view state, and keyboard/tree behavior with mocked data. +Automated: [case list](../../ui/src/pages/Cases.test.tsx) covers filters, persisted view state, and keyboard/tree behavior with mocked data. Manual: Search for a child, verify its parent context remains understandable, change filters/grouping/columns, and reload. Open the record by keyboard and direct link. Include empty results and terminal records. ### `case-record` -Automated: [case routes](../server/src/__tests__/cases-routes.test.ts) covers API behavior; [case fields](../ui/src/components/CaseFieldsPanel.test.tsx) covers field UI separately. +Automated: [case routes](../../server/src/__tests__/cases-routes.test.ts) covers API behavior; [case fields](../../ui/src/components/CaseFieldsPanel.test.tsx) covers field UI separately. -Manual: Edit an offered field, reload, inspect its revision/activity, and follow child and task links. Verify cross-company and invalid field changes fail clearly. Distinguish a pipeline-owned item from a stand-alone case. +Manual: Change status or labels, reload, inspect activity, and follow child/task +links. Edit a case document and inspect its document revisions separately. +Arbitrary JSON fields are currently displayed without a general field editor. +Verify cross-company and invalid changes fail clearly. Distinguish a +pipeline-owned item from a stand-alone case. ## Gotchas - A case is not a task execution; inspect linked tasks to prove work occurred. -- Feature gating and case-type schema determine which actions are available. +- Feature gating and offered controls determine available actions. The API + accepts a string case type and arbitrary JSON fields; do not infer typed + case-field validation or a field-editing UI from those values. diff --git a/feature-map/chat-channels.md b/maps/feature-map/chat-channels.md similarity index 65% rename from feature-map/chat-channels.md rename to maps/feature-map/chat-channels.md index 3399965dca4..4a6a55efc2f 100644 --- a/feature-map/chat-channels.md +++ b/maps/feature-map/chat-channels.md @@ -2,7 +2,9 @@ People can talk to an assigned agent through configured external channels, bind their identity, exchange files, and inspect delivery state. Each provider has a different identity, threading, and attachment contract. -Implementation: [endpoint setup](../ui/src/pages/apps/chat/ChatEndpointSetup.tsx), [endpoint detail](../ui/src/pages/apps/chat/ChatEndpointDetail.tsx), [email setup](../ui/src/pages/apps/chat/EmailEndpointSetup.tsx). +Implementation: [endpoint setup](../../ui/src/pages/apps/chat/ChatEndpointSetup.tsx), [endpoint detail](../../ui/src/pages/apps/chat/ChatEndpointDetail.tsx), [email setup](../../ui/src/pages/apps/chat/EmailEndpointSetup.tsx). + +Agent access: [companion](../agent-access-map/chat-channels.md). ## Sub-features @@ -22,24 +24,31 @@ Message the configured bot/address from its external provider and follow the cor Open `/apps/chat/:endpointId/settings` and the endpoint’s other available tabs; identity confirmation uses `/chat-identity/confirm`. -## Driving it +## Driving it with Playwright and provider clients -Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Enable chat connectors and use a test provider account/channel with permission to send messages. Setup is covered in [connection setup](./connection-setup.md). +Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use a +test provider account/channel with permission to send messages. Gates differ: +AgentMail bypasses the chat-connectors UI gate, GitHub uses +`enableGitHubReviewBots`, and other chat providers use `enableChatConnectors`. +Setup is covered in [connection setup](./connection-setup.md). ### `external-thread` -Automated: [channel integration](../server/src/__tests__/chat-channels.integration.test.ts) uses provider fixtures; it does not prove a live Slack, Discord, Teams, Telegram, GitHub, email, or Photon delivery. +Automated: [channel integration](../../server/src/__tests__/chat-channels.integration.test.ts) uses provider fixtures; it does not prove a live Slack, Discord, Teams, Telegram, GitHub, email, or Photon delivery. Manual: For each supported provider being changed, send a unique message, verify the intended agent/company receives it, and inspect the actual external reply in the original thread. Repeat with an unlinked/unauthorized sender, duplicate inbound delivery, and one supported attachment. Record provider-specific results separately. ### `endpoint-management` -Automated: [channel integration](../server/src/__tests__/chat-channels.integration.test.ts) covers shared endpoint/delivery behavior; live credential repair and provider ownership remain manual checks. +Automated: [channel integration](../../server/src/__tests__/chat-channels.integration.test.ts) covers shared endpoint/delivery behavior; live credential repair and provider ownership remain manual checks. Manual: Inspect the bound agent and identity, recent activity, and delivery failure details. Repair a disposable credential, retry a safe message when offered, and verify exactly one external reply. Disable the endpoint and confirm new traffic is not silently processed. ## Gotchas +- Visibility flags do not pause existing delivery. Disable the disposable + endpoint itself to test stopped traffic. + - One provider’s passing fixture does not qualify another provider or live delivery. - An accepted send request does not prove the recipient received the message or file. - Use [steering](./steering.md) and [questions](./questions-and-approvals.md) for their channel-specific interaction checks. diff --git a/feature-map/cli-operations.md b/maps/feature-map/cli-operations.md similarity index 67% rename from feature-map/cli-operations.md rename to maps/feature-map/cli-operations.md index 34c95f01c6c..e3ddaeb6e38 100644 --- a/feature-map/cli-operations.md +++ b/maps/feature-map/cli-operations.md @@ -2,7 +2,9 @@ Operators and agents can work through explicit CLI/API context, manage domain resources, inspect runs, and create isolated local worktree instances for development. -Implementation: [command registry](../cli/src/index.ts), [client context](../cli/src/commands/client/context.ts), [worktree commands](../cli/src/commands/worktree.ts). +Implementation: [command registry](../../cli/src/index.ts), [client context](../../cli/src/commands/client/context.ts), [worktree commands](../../cli/src/commands/worktree.ts). + +Agent access: [companion](../agent-access-map/cli-operations.md). ## Sub-features @@ -25,26 +27,31 @@ Use issue subresource, asset/skill, and run/heartbeat commands exposed by the CL ### `worktree-instance` Use the documented worktree/test-drive commands in an owned checkout; inspect `paperclipai worktree --help` first. +Inspect root help too: `worktree:make/list/merge-history/cleanup` are top-level +commands rather than subcommands of `worktree`. -## Driving it +## Driving it with the Paperclip CLI Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use `--help` from the checked-out CLI to discover exact arguments. Select a disposable company and isolated context; do not inherit an unknown API target. +Pass the server origin to `--api-base` (without appending `/api`); the commands +already include the API path. `issue create` requires explicit `--company-id` +even when a profile stores a company. A persona label is not authentication proof. ### `client-context` -Automated: [context](../cli/src/__tests__/context.test.ts), [HTTP client](../cli/src/__tests__/http.test.ts), and [operations parity](../cli/src/__tests__/operations-parity.test.ts) cover client contracts, not every live endpoint. +Automated: [context](../../cli/src/__tests__/context.test.ts), [HTTP client](../../cli/src/__tests__/http.test.ts), and [operations parity](../../cli/src/__tests__/operations-parity.test.ts) cover client contracts, not every live endpoint. Manual: Inspect the selected context and identity, list the disposable company’s resources, create a harmless task through the supported command, and verify it in the browser. Exercise an invalid token and wrong-company resource. Inspect JSON output and nonzero errors rather than only printed success text. ### `subresources-and-runs` -Automated: [issue subresources](../cli/src/__tests__/issue-subresources.test.ts) and [asset/skill parity](../cli/src/__tests__/admin-asset-skill-parity.test.ts) cover command/API contracts. +Automated: [issue subresources](../../cli/src/__tests__/issue-subresources.test.ts) and [asset/skill parity](../../cli/src/__tests__/admin-asset-skill-parity.test.ts) cover command/API contracts. Manual: Write a small task document or attach a fixture using the supported command, open it in the UI, and compare contents. Invoke eligible test work, inspect the resulting run ID/status/output, and verify denial when the actor lacks permission. ### `worktree-instance` -Automated: [worktree commands](../cli/src/__tests__/worktree.test.ts) and [test drive](../cli/src/__tests__/test-drive.test.ts) cover isolation/setup contracts. +Automated: [worktree commands](../../cli/src/__tests__/worktree.test.ts) and [test drive](../../cli/src/__tests__/test-drive.test.ts) cover isolation/setup contracts. Manual: Initialize an owned disposable worktree instance, inspect its printed paths and ports, verify health and login, and confirm any seed data belongs to the intended source. Stop and clean up only that instance; inspect preserved work before deleting any checkout. diff --git a/feature-map/companies.md b/maps/feature-map/companies.md similarity index 67% rename from feature-map/companies.md rename to maps/feature-map/companies.md index d0ce6853189..0740c974556 100644 --- a/feature-map/companies.md +++ b/maps/feature-map/companies.md @@ -2,7 +2,9 @@ An operator can switch between companies, change their identity and settings, archive them, and preview an import or export. Each company remains a separate scope for work and agents. -Implementation: [companies](../ui/src/pages/Companies.tsx), [settings](../ui/src/pages/CompanySettings.tsx), [import](../ui/src/pages/CompanyImport.tsx), [export](../ui/src/pages/CompanyExport.tsx). +Implementation: [companies](../../ui/src/pages/Companies.tsx), [settings](../../ui/src/pages/CompanySettings.tsx), [import](../../ui/src/pages/CompanyImport.tsx), [export](../../ui/src/pages/CompanyExport.tsx). + +Agent access: [companion](../agent-access-map/companies.md). ## Sub-features @@ -21,24 +23,28 @@ Use the company switcher or `/companies`, then enter company Settings. Use `/company/export` and `/company/import` where enabled, or the corresponding CLI company commands. -## Driving it +## Driving it with Playwright and the Paperclip CLI Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use two disposable companies and a small package fixture. Record cloud-managed restrictions and visible settings gates. ### `company-switcher` -Automated: [company list](../ui/src/pages/Companies.test.tsx) and [archive UI](../ui/src/pages/CompanySettingsArchive.test.tsx) cover UI behavior with mocks. +Automated: [company list](../../ui/src/pages/Companies.test.tsx) and [archive UI](../../ui/src/pages/CompanySettingsArchive.test.tsx) cover UI behavior with mocks. Manual: Create or select a company, rename it, and reload. Switch to the second company and back; verify tasks, agents, and settings follow the selection. Archive the disposable company and verify its visibility and offered restoration path. ### `package-transfer` -Automated: [portability routes](../server/src/__tests__/company-portability-routes.test.ts) and [CLI import/export](../cli/src/__tests__/company-import-export-e2e.test.ts) cover package and command behavior, not every external Git host. +Automated: [portability routes](../../server/src/__tests__/company-portability-routes.test.ts) and [CLI import/export](../../cli/src/__tests__/company-import-export-e2e.test.ts) cover package and command behavior, not every external Git host. Manual: Export a small company, inspect the package preview, and import into a disposable target. Verify counts, references, conflict choices, and agent configuration. Run one imported task after supplying its required local credentials. Confirm source company data is unchanged. ## Gotchas +- Restore an archived company through `/companies` → its menu → **Unarchive**. + Archiving pauses eligible agents and cancels active runs; restoration handles + agents paused specifically for company archival. + - Package presence does not prove a runnable imported company; credentials and environment bindings need separate verification. - Company deletion is a distinct gated operation; do not use it as the archive test. - Settings hidden in a managed deployment should be recorded as unavailable, not bypassed. diff --git a/feature-map/connection-setup.md b/maps/feature-map/connection-setup.md similarity index 85% rename from feature-map/connection-setup.md rename to maps/feature-map/connection-setup.md index 00c9328a9ae..808f7d952ce 100644 --- a/feature-map/connection-setup.md +++ b/maps/feature-map/connection-setup.md @@ -5,6 +5,8 @@ setup from either Apps or a task that needs access. A saved draft, a completed provider login, a usable connection, and a resumed task are separate checkpoints. Chat channels additionally require an endpoint and linked sender identity. +Agent access: [companion](../agent-access-map/connection-setup.md). + ## Sub-features - `catalog`: find a provider, inspect its available methods, and open setup; @@ -57,22 +59,22 @@ or open `/apps/chat/connect`. Complete that provider's endpoint setup, then open its settings (`/apps/chat/:endpointId/settings`). Follow identity linking as the intended sender before messaging the agent externally. -## Driving it +## Driving it with Playwright and the Paperclip CLI Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Choose a disposable provider account/resource and record its auth method, deployment mode, account ownership, and access selection. For live setup, use the provider's documented prerequisites in the -[connector playbook](../doc/connections/CONNECTOR-PLAYBOOK.md). Automated fixtures +[connector playbook](../../doc/connections/CONNECTOR-PLAYBOOK.md). Automated fixtures do not establish OAuth registration, public callbacks, or production provider access. ### `apps-catalog` -Automated: [Browse](../ui/src/pages/apps/Browse.test.tsx), -[unconnected app](../ui/src/pages/apps/AppNotConnected.test.tsx), and -[AppsConnect](../ui/src/pages/apps/AppsConnect.test.tsx) cover catalog entry, +Automated: [Browse](../../ui/src/pages/apps/Browse.test.tsx), +[unconnected app](../../ui/src/pages/apps/AppNotConnected.test.tsx), and +[AppsConnect](../../ui/src/pages/apps/AppsConnect.test.tsx) cover catalog entry, access selection, provider-specific method choices, and setup failures with -mocked APIs. [Connection intents](../tests/e2e/connection-intents.spec.ts) +mocked APIs. [Connection intents](../../tests/e2e/connection-intents.spec.ts) drives store and task setup against one fake provider through a useful continuation: ```sh @@ -99,7 +101,7 @@ Test no-auth and authenticated endpoints separately; never infer one from the ot ### `task-request` -Automated: [interaction card](../ui/src/components/IssueThreadInteractionCard.test.tsx) +Automated: [interaction card](../../ui/src/components/IssueThreadInteractionCard.test.tsx) checks addressed-user controls. AppsConnect tests cover page/dialog parity, task-bound OAuth, popup handoff, and verifying a saved connection before closing. The connection-intents browser suite above exercises fixture-backed continuation. @@ -114,7 +116,7 @@ task does not grant the right to resolve the request. Automated: AppsConnect tests cover exact-draft resume, declined OAuth, reconnect lookup failure/retry, response-lost creation, archived connection revival, and -saved account reuse. [App detail](../ui/src/pages/apps/AppDetail.test.tsx) covers +saved account reuse. [App detail](../../ui/src/pages/apps/AppDetail.test.tsx) covers the connection host. These tests do not complete a real provider login. Manual: leave setup through its footer, reload Apps, choose Finish setup, and @@ -124,10 +126,10 @@ action afterwards. Count connections before/after to catch accidental duplicates ### `chat-channel` -Automated: [setup routing](../ui/src/pages/apps/chat/ChatEndpointSetup.routing.test.tsx), -[saved setup state](../ui/src/pages/apps/chat/ChatEndpointSetup.state.test.ts), -[email setup](../ui/src/pages/apps/chat/EmailEndpointSetup.test.tsx), and -[identity confirmation](../ui/src/pages/apps/chat/ChatIdentityConfirm.test.tsx) +Automated: [setup routing](../../ui/src/pages/apps/chat/ChatEndpointSetup.routing.test.tsx), +[saved setup state](../../ui/src/pages/apps/chat/ChatEndpointSetup.state.test.ts), +[email setup](../../ui/src/pages/apps/chat/EmailEndpointSetup.test.tsx), and +[identity confirmation](../../ui/src/pages/apps/chat/ChatIdentityConfirm.test.tsx) cover shared UI logic with fixtures. Provider-specific live setup is not implied. Manual: complete setup for one named provider, reload endpoint settings, link @@ -138,6 +140,10 @@ Slack setup does not qualify Discord, Telegram, GitHub, email, or iMessage. ## Gotchas +- Private endpoint rejection depends on deployment: owned loopback MCP servers + are supported in local-trusted/private instances. Public authenticated + deployments apply the private-network restriction. + - A successful OAuth callback is not proof of saved connection access or task continuation. Verify all three checkpoints. - Personal identity, shared account access, and per-agent access are distinct. diff --git a/feature-map/coverage.json b/maps/feature-map/coverage.json similarity index 100% rename from feature-map/coverage.json rename to maps/feature-map/coverage.json diff --git a/feature-map/developer-labs.md b/maps/feature-map/developer-labs.md similarity index 58% rename from feature-map/developer-labs.md rename to maps/feature-map/developer-labs.md index dc9a7fbec05..f2cdeb607d1 100644 --- a/feature-map/developer-labs.md +++ b/maps/feature-map/developer-labs.md @@ -2,7 +2,9 @@ Contributors can inspect design examples, interaction labs, and performance fixtures. These are development surfaces and do not count as production feature verification. -Implementation: [route registration](../ui/src/App.tsx), [design guide](../ui/src/pages/DesignGuide.tsx), [long-thread fixture](../ui/src/pages/IssueChatLongThreadPerf.tsx). +Implementation: [route registration](../../ui/src/App.tsx), [design guide](../../ui/src/pages/DesignGuide.tsx), [long-thread fixture](../../ui/src/pages/IssueChatLongThreadPerf.tsx). + +Agent access: [companion](../agent-access-map/developer-labs.md). ## Sub-features @@ -21,23 +23,28 @@ Use `/design-guide`, registered `/ux-lab/...` routes, or Storybook; consult `ui/ Open `/tests/perf/long-thread` only in an appropriate development environment. -## Driving it +## Driving it with Playwright Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use a development/test environment. Confirm the route is registered for the current build before claiming the page is available. ### `preview-lab` -Automated: [onboarding variant](../ui/src/components/OnboardingWizardVariant.test.tsx) is an example component contract. There is no claim of full automated acceptance coverage for every lab page. +Automated: [onboarding variant](../../ui/src/components/OnboardingWizardVariant.test.tsx) is an example component contract. There is no claim of full automated acceptance coverage for every lab page. Manual: Open the specific fixture state, inspect controls and layout, then repeat the affected interaction in the actual product host. Report fixture results separately from persisted task/company results. ### `performance-fixture` -Automated: [task thread component](../ui/src/components/IssueChatThread.test.tsx) does not establish a performance budget. Timing/scroll measurements need a dedicated browser run. +Automated: [task thread component](../../ui/src/components/IssueChatThread.test.tsx) does not establish a performance budget. Timing/scroll measurements need a dedicated browser run. -Manual: Record browser/build/data size, exercise scrolling and composing in the fixture, and retain timing or trace evidence. Confirm a representative real task does not regress before extrapolating fixture measurements. +Manual: Record browser/build/data size, exercise scrolling and rendering in the +long-thread fixture, and retain timing or trace evidence. Its composer is +disabled; test composing separately in a real task. Confirm representative +real-task behavior before extrapolating fixture measurements. ## Gotchas - An unmounted or obsolete lab file remains in the conservative page inventory but is not a shipped entry point. +- Intended developer use is not a universal runtime gate: Design Guide and + global lab/performance routes are also registered in production builds. - Screenshots of a lab do not prove live provider behavior, persistence, or authorization. diff --git a/maps/feature-map/documents-artifacts.md b/maps/feature-map/documents-artifacts.md new file mode 100644 index 00000000000..c3ae6f1c3cd --- /dev/null +++ b/maps/feature-map/documents-artifacts.md @@ -0,0 +1,66 @@ +# Documents, attachments, and work products + +People read and revise task documents, annotate specific content, open attached files, and inspect the outputs an agent hands back. The artifact library provides another route to the same work. + +Implementation: [task documents](../../ui/src/components/IssueDocumentsSection.tsx), [artifact library](../../ui/src/pages/Artifacts.tsx), [document panel](../../ui/src/components/task-side-panel/TaskDocumentPanel.tsx). + +Agent access: [companion](../agent-access-map/documents-artifacts.md). + +## Sub-features + +- `documents`: edit versioned task documents and inspect or restore revisions. +- `annotations`: attach feedback to document content and inspect resolution state. +- `attachments`: upload, preview, and download allowed files with company access checks. +- `work-products`: link deliverables to their source task, resource, and review context. +- `library`: search, filter, group, and open artifact stacks. + +## How to get to it (user POV) + +### `task-document` + +Open a document from the task thread or document side panel. + +### `task-output` + +Open an attachment, output card, or file link from a task. +For direct uploads, use the task side panel's **Open a new tab → Artifacts → +Upload files** or drop files anywhere in that panel. On mobile, open +**More actions → Properties** first. Agent conversations do not expose this upload path. + +### `artifact-library` + +Open `/artifacts` and its grouping, search, and media controls. + +## Driving it with Playwright and the Paperclip CLI + +Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use a task with a text document, a small attachment, and a generated work product. Use nonsensitive fixture files. + +### `task-document` + +Automated: [documents service](../../server/src/__tests__/documents-service.test.ts), [annotations](../../server/src/__tests__/document-annotations-service.test.ts), and [revision restore](../../server/src/__tests__/issue-document-restore-routes.test.ts) cover server contracts. + +Manual: Edit a paragraph, save, reload, and compare revisions. Add an annotation to a specific selection and inspect it after another edit. Resolve it through the supported action. Restore a disposable revision and confirm the new current document and history agree. + +### `task-output` + +Automated: [attachment routes](../../server/src/__tests__/issue-attachment-routes.test.ts) and [work products](../../server/src/__tests__/work-products.test.ts) cover data/access behavior; [artifact arrival browser suite](../../tests/e2e/artifact-tab-arrival.spec.ts) targets arrival. [Artifact uploads](../../tests/e2e/artifact-uploads.spec.ts) drives file selection/drop, filename collisions, retry, and preservation of paused/unassigned tasks. + +Manual: Upload a small Markdown file and CSV through Artifacts. Markdown becomes +a versioned task document; other files become attachments. Read the document, +download the CSV and compare bytes, then reload. Upload the same Markdown filename +again and verify a separate suffixed document without overwriting the original. +On a paused unassigned task, confirm upload does not start work or add comments. +Verify agent-produced work products separately using an eligible test run. +Test an inaccessible user/company and explicit missing workspace-only resources. + +### `artifact-library` + +Automated: [artifact page](../../ui/src/pages/Artifacts.test.tsx) covers grouping, search requests, and pagination using mocks. + +Manual: Find the task’s output through search and grouping, open its stack, page through results, and reload a direct stack link. Confirm the same file/content appears as from the task. + +## Gotchas + +- A local path is not automatically an uploaded artifact accessible to another user. +- A preview screenshot is not proof that download bytes or revision references are correct. +- Version conflicts and stale annotations must stay visible rather than silently targeting different content. diff --git a/feature-map/execution-environments.md b/maps/feature-map/execution-environments.md similarity index 60% rename from feature-map/execution-environments.md rename to maps/feature-map/execution-environments.md index feb89f353d7..bb1fa10d78f 100644 --- a/feature-map/execution-environments.md +++ b/maps/feature-map/execution-environments.md @@ -2,7 +2,9 @@ Operators configure where agents execute, probe readiness, select an environment for work, and inspect remote provisioning or custom-image state when the installed provider supports it. -Implementation: [environment settings](../ui/src/pages/CompanyEnvironments.tsx), [environment runtime](../server/src/services/environment-runtime.ts). +Implementation: [environment settings](../../ui/src/pages/CompanyEnvironments.tsx), [environment runtime](../../server/src/services/environment-runtime.ts). + +Agent access: [companion](../agent-access-map/execution-environments.md). ## Sub-features @@ -17,24 +19,32 @@ Implementation: [environment settings](../ui/src/pages/CompanyEnvironments.tsx), ### `environment-settings` Open Settings → Environments, then create/edit a supported target. +The canonical URL is `/company/settings/instance/environments`. Enable +`enableEnvironments` first; keep `enableManagedSandboxOnly` off to inspect local +targets. The catalog is instance-scoped and mutation requires a local operator +or instance administrator. ### `remote-execution` Choose the environment in agent/project/workspace configuration, then inspect the resulting task/run and workspace. -## Driving it +## Driving it with Playwright and the Paperclip CLI Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use an owned disposable target and a supported provider plugin. Record provider, target identity, account, and spending constraints before a live remote run. ### `environment-settings` -Automated: [environment UI](../ui/src/pages/CompanyEnvironments.test.tsx), [driver configuration UI](../ui/src/pages/CompanySettings.test.tsx), and [environment service](../server/src/__tests__/environment-service.test.ts) cover configuration behavior. +Automated: [environment UI](../../ui/src/pages/CompanyEnvironments.test.tsx), [driver configuration UI](../../ui/src/pages/CompanySettings.test.tsx), and [environment service](../../server/src/__tests__/environment-service.test.ts) cover configuration behavior. Manual: Save a disposable target, reload it, and run its available probe. Verify both success and missing-credential/reachability errors identify the target. Confirm unavailable providers are not offered as working run targets. +The local row has no UI Test button. Its API/CLI probe reports host/cwd +availability only; it does not validate an adapter binary, workspace, or task +execution. Remote-driver probes and a real bounded run need their own evidence. + ### `remote-execution` -Automated: [execution target](../server/src/__tests__/environment-execution-target.test.ts) and [custom images](../server/src/__tests__/environment-custom-images-service.test.ts) cover contracts; [SSH live test](../server/src/__tests__/environment-live-ssh.test.ts) requires its own external setup. +Automated: [execution target](../../server/src/__tests__/environment-execution-target.test.ts) and [custom images](../../server/src/__tests__/environment-custom-images-service.test.ts) cover contracts; [SSH live test](../../server/src/__tests__/environment-live-ssh.test.ts) requires its own external setup. Manual: Run a bounded command in the intended target, inspect its output and workspace, and verify execution did not silently fall back to the server host. For custom-image changes, build/probe the disposable image before assigning it. Inspect cleanup and shutdown after the run. diff --git a/feature-map/gateways-profiles.md b/maps/feature-map/gateways-profiles.md similarity index 60% rename from feature-map/gateways-profiles.md rename to maps/feature-map/gateways-profiles.md index 616c73d8c33..de5f4b58e9d 100644 --- a/feature-map/gateways-profiles.md +++ b/maps/feature-map/gateways-profiles.md @@ -2,11 +2,13 @@ Administrators expose selected tools through gateways and reusable access profiles, connect clients, manage tokens, and inspect gateway activity. -Implementation: [gateways](../ui/src/pages/apps/gateways/GatewaysList.tsx), [gateway detail](../ui/src/pages/apps/gateways/GatewayDetail.tsx), [profiles](../ui/src/pages/tools/profiles/ProfileWizard.tsx). +Implementation: [gateways](../../ui/src/pages/apps/gateways/GatewaysList.tsx), [gateway detail](../../ui/src/pages/apps/gateways/GatewayDetail.tsx), [profiles](../../ui/src/pages/tools/profiles/ProfileWizard.tsx). + +Agent access: [companion](../agent-access-map/gateways-profiles.md). ## Sub-features -- `gateway`: create/edit an endpoint and select its exposed apps/tools. +- `gateway`: create/edit an endpoint backed by a tool access profile. - `profiles`: define reusable tool access configuration and inspect draft/detail state. - `client-connect`: copy the intended endpoint/configuration to an authorized test client. - `tokens`: create/revoke client credentials and verify their scope. @@ -22,19 +24,24 @@ Open `/apps/gateways`, then a gateway’s Overview, Apps/Tools, Tokens, and Acti Open `/apps/advanced/profiles`, create a profile, or follow its detail/edit route. -## Driving it +## Driving it with Playwright and an MCP client Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use a fixture tool connection and test client. Record gateway/profile, token owner, transport, and selected company. +Gateway creation selects a profile. Maintain that profile's app/action selection +through the Apps/Tools links; the gateway form is not a separate tool picker. +Use an active organization member for human-scoped access, and check the actual +endpoint authorization independently of whether the admin UI is visible. + ### `gateway-client` -Automated: [gateway service](../server/src/__tests__/tool-gateway-service.test.ts), [connect client dialog](../ui/src/pages/apps/gateways/ConnectClientDialog.test.tsx), and [token panel](../ui/src/pages/apps/gateways/panels/TokensPanel.test.tsx) cover service/UI contracts. +Automated: [gateway service](../../server/src/__tests__/tool-gateway-service.test.ts), [connect client dialog](../../ui/src/pages/apps/gateways/ConnectClientDialog.test.tsx), and [token panel](../../ui/src/pages/apps/gateways/panels/TokensPanel.test.tsx) cover service/UI contracts. Manual: Create a disposable gateway with one harmless tool, connect a client using its provided configuration, discover/call that tool, and inspect activity. Revoke the client token and confirm the same call fails. Test a tool outside the allowed set. ### `access-profile` -Automated: [profile wizard](../ui/src/pages/tools/profiles/ProfileWizard.test.tsx) and [profile detail](../ui/src/pages/tools/profiles/ProfileDetail.test.tsx) cover component behavior with mocks. +Automated: [profile wizard](../../ui/src/pages/tools/profiles/ProfileWizard.test.tsx) and [profile detail](../../ui/src/pages/tools/profiles/ProfileDetail.test.tsx) cover component behavior with mocks. Manual: Create a bounded profile, choose its offered tools and options, save or resume its draft, and reload the detail. Apply/connect it through the offered flow and inspect the actual client-visible tool set. Verify cancellation and invalid selections retain an understandable draft. diff --git a/feature-map/goals.md b/maps/feature-map/goals.md similarity index 51% rename from feature-map/goals.md rename to maps/feature-map/goals.md index a282d353454..44322e7fbe3 100644 --- a/feature-map/goals.md +++ b/maps/feature-map/goals.md @@ -2,7 +2,9 @@ People define company and lower-level goals and connect projects and tasks to the outcome they serve. -Implementation: [goals](../ui/src/pages/Goals.tsx), [goal detail](../ui/src/pages/GoalDetail.tsx). +Implementation: [goals](../../ui/src/pages/Goals.tsx), [goal detail](../../ui/src/pages/GoalDetail.tsx). + +Agent access: [companion](../agent-access-map/goals.md). ## Sub-features @@ -20,19 +22,24 @@ Open `/goals`, create a goal, and enter `/goals/:goalId`. Inspect the goal context from a project or task and compare explicit versus inherited context. -## Driving it +## Driving it with Playwright and the Paperclip CLI Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Prepare two goals and a disposable project/task in the selected company. ### `goal-detail` -Automated: [project-goal validation](../server/src/__tests__/project-goal-validation.test.ts) covers relationship validation; [goal detail control](../ui/src/pages/GoalDetail.test.tsx) only tests the properties toggle, not the whole creation journey. +Automated: [project-goal validation](../../server/src/__tests__/project-goal-validation.test.ts) covers relationship validation; [goal detail control](../../ui/src/pages/GoalDetail.test.tsx) only tests the properties toggle, not the whole creation journey. -Manual: Create a parent and child goal, save owner/status changes, and reload. Link a project and task through the offered controls, follow their goal context, and verify a cross-company or invalid relationship is rejected. +Manual: Choose **Add Goal/New Goal**, enter **Goal title**, select the level, and +create a parent. Open it and choose **Sub Goal** to create a child with the +parent preselected. Change status in Properties and reload; follow the Parent +Goal link and inspect Sub-Goals. Owner is currently display-only in this UI. +Link a project and task through the offered controls, follow their goal context, +and verify a cross-company or invalid relationship is rejected. ### `task-goal-context` -Automated: [task goal routes](../server/src/__tests__/issues-goal-context-routes.test.ts) and [goal fallback](../server/src/__tests__/issue-goal-fallback.test.ts) cover the server rules. +Automated: [task goal routes](../../server/src/__tests__/issues-goal-context-routes.test.ts) and [goal fallback](../../server/src/__tests__/issue-goal-fallback.test.ts) cover the server rules. Manual: Compare a task with an explicit goal to one relying on project context. Change the allowed association, reload, and verify the displayed/injected context follows the documented server rule rather than retaining an unrelated goal. diff --git a/feature-map/inbox-search.md b/maps/feature-map/inbox-search.md similarity index 61% rename from feature-map/inbox-search.md rename to maps/feature-map/inbox-search.md index 2ef31ac7c7d..ac3570b5c38 100644 --- a/feature-map/inbox-search.md +++ b/maps/feature-map/inbox-search.md @@ -2,7 +2,9 @@ People find work that needs their attention, distinguish unread activity from required decisions, archive handled items, and search across the company. -Implementation: [inbox](../ui/src/pages/Inbox.tsx), [decisions](../ui/src/pages/WhatNeedsMe.tsx), [search](../ui/src/pages/Search.tsx). +Implementation: [inbox](../../ui/src/pages/Inbox.tsx), [decisions](../../ui/src/pages/WhatNeedsMe.tsx), [search](../../ui/src/pages/Search.tsx). + +Agent access: [companion](../agent-access-map/inbox-search.md). ## Sub-features @@ -25,30 +27,34 @@ Open `/decisions`, then a queue at `/decisions/queues/:key` and its source item. Open `/search` or the navigation search entry. -## Driving it +## Driving it with Playwright and the Paperclip CLI Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Seed assigned, unread, blocked, and resolved tasks plus a pending decision. Record the combined Inbox/Tasks experimental setting. ### `inbox` -Automated: [archive routes](../server/src/__tests__/inbox-archive-routes.test.ts) cover server mutations. [Task-list helpers](../ui/src/pages/Issues.test.tsx) cover search URLs, pagination, and presentation constants; they do not render the combined Inbox/Tasks page. Reading, archiving, and navigating that page remain manual checks. +Automated: [archive routes](../../server/src/__tests__/inbox-archive-routes.test.ts) cover server mutations. [Task-list helpers](../../ui/src/pages/Issues.test.tsx) cover search URLs, pagination, and presentation constants; they do not render the combined Inbox/Tasks page. Reading, archiving, and navigating that page remain manual checks. Manual: Read an unread task, return to Unread, archive a handled item, and reload. Verify personal triage state changes while task status stays correct. Switch users and companies to check isolation. ### `decisions` -Automated: [decision queues](../server/src/__tests__/decision-queues-routes.test.ts) covers route behavior. End-to-end queue-to-source navigation is manual. +Automated: [decision queues](../../server/src/__tests__/decision-queues-routes.test.ts) covers route behavior. End-to-end queue-to-source navigation is manual. Manual: Open a pending decision, follow its source, resolve it through the supported control, and reload the queue. Confirm it retires only after the decision persisted and unrelated pending items remain. ### `search` -Automated: [search page](../ui/src/pages/Search.test.tsx) and [company search service](../server/src/__tests__/company-search-service.test.ts) cover UI and server search separately. +Automated: [search page](../../ui/src/pages/Search.test.tsx) and [company search service](../../server/src/__tests__/company-search-service.test.ts) cover UI and server search separately. Manual: Search a unique task title and content phrase, open the result, and compare the actual record. Test filters, no results, and a second company. Verify stale results do not expose inaccessible data. ## Gotchas +- Combined Inbox/Tasks requires both `enableCombinedInboxTasks` and + `enableStreamlinedUi`. [Standalone Inbox tests](../../ui/src/pages/Inbox.test.tsx) + render archive/read controls and toolbars; this is not live multi-user evidence. + - Personal archival is not task cancellation or completion. - Legacy Inbox routes can redirect to the combined task view; record which host actually rendered. - Search service tests do not establish the relevance quality of every query. diff --git a/feature-map/instance-operations.md b/maps/feature-map/instance-operations.md similarity index 67% rename from feature-map/instance-operations.md rename to maps/feature-map/instance-operations.md index dbed2230885..0e02f78a36d 100644 --- a/feature-map/instance-operations.md +++ b/maps/feature-map/instance-operations.md @@ -2,7 +2,9 @@ An operator can install and run Paperclip, diagnose the instance, configure supported deployment settings, manage its service, and produce a recoverable database backup. -Implementation: [CLI entry point](../cli/src/index.ts), [instance settings](../ui/src/pages/InstanceExperimentalSettings.tsx), [backup command](../cli/src/commands/db-backup.ts). +Implementation: [CLI entry point](../../cli/src/index.ts), [instance settings](../../ui/src/pages/InstanceExperimentalSettings.tsx), [backup command](../../cli/src/commands/db-backup.ts). + +Agent access: [companion](../agent-access-map/instance-operations.md). ## Sub-features @@ -22,24 +24,29 @@ Use `paperclipai install`, `run`, `service`, `doctor`, `configure`, `channels`, Use currently visible instance settings for experiments/access and `paperclipai db:backup --help` for one-off backup. Some legacy general-settings URLs redirect. -## Driving it +## Driving it with Playwright and the Paperclip CLI Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use a disposable data directory/service identity. Do not update, restart, or restore over an unrelated live instance as a documentation test. ### `operator-cli` -Automated: [doctor](../cli/src/__tests__/doctor.test.ts), [update command](../cli/src/__tests__/update-command.test.ts), and [service manager](../cli/src/__tests__/service-manager.test.ts) cover command contracts. +Automated: [doctor](../../cli/src/__tests__/doctor.test.ts), [update command](../../cli/src/__tests__/update-command.test.ts), and [service manager](../../cli/src/__tests__/service-manager.test.ts) cover command contracts. Manual: Inspect the selected installation/channel and configuration, start the isolated instance, confirm health and browser access, then stop that instance. For update changes, use the dry-run/check path first and verify installed version and rollback state on an owned disposable installation. ### `settings-backup` -Automated: [experimental settings](../ui/src/pages/InstanceExperimentalSettings.test.tsx) and [database backup routes](../server/src/__tests__/instance-database-backups-routes.test.ts) cover their UI/API contracts. +Automated: [experimental settings](../../ui/src/pages/InstanceExperimentalSettings.test.tsx) and [database backup routes](../../server/src/__tests__/instance-database-backups-routes.test.ts) cover their UI/API contracts. Manual: Change a harmless experimental setting, reload, and confirm the affected route honors it. Back up a disposable instance, inspect the reported file and metadata, and restore using the documented procedure into a second isolated target. Verify a known record after restoration. ## Gotchas +- Source-checkout update/check commands report manual update guidance; they do + not qualify a managed installation. Rollback restores retained CLI payloads, + not database migrations. Service status applies to its selected service + identity, not automatically to an owned foreground dev server. + - A successful backup write does not prove restoration works. - Settings visibility and mutability differ for cloud-managed and self-hosted instances. - Keep the three data paths distinct: first-party Telemetry, operator-configured OpenTelemetry, and local run logs. diff --git a/feature-map/navigation-preferences.md b/maps/feature-map/navigation-preferences.md similarity index 65% rename from feature-map/navigation-preferences.md rename to maps/feature-map/navigation-preferences.md index 66794028f8f..bb2490483c4 100644 --- a/feature-map/navigation-preferences.md +++ b/maps/feature-map/navigation-preferences.md @@ -2,7 +2,9 @@ People organize their sidebar, move between company resources, maintain their profile, and dismiss product announcements with the correct persistence and identity scope. -Implementation: [sidebar](../ui/src/components/Sidebar.tsx), [profile](../ui/src/pages/ProfileSettings.tsx), [announcements](../ui/src/components/AnnouncementWell.tsx). +Implementation: [sidebar](../../ui/src/components/Sidebar.tsx), [profile](../../ui/src/pages/ProfileSettings.tsx), [announcements](../../ui/src/components/AnnouncementWell.tsx). + +Agent access: [companion](../agent-access-map/navigation-preferences.md). ## Sub-features @@ -22,19 +24,24 @@ Use starred/recent/sidebar links and direct company-prefixed URLs. Use Settings → Profile, `/u/:userSlug`, and the announcement well when a publication is available. -## Driving it +## Driving it with Playwright and the Paperclip CLI Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use two test users and companies; record the selected UI shell and any client-local preference storage. ### `sidebar-deep-links` -Automated: [sidebar](../ui/src/components/Sidebar.test.tsx) and [sidebar preference routes](../server/src/__tests__/sidebar-preferences-routes.test.ts) cover UI/server behavior. +Automated: [sidebar](../../ui/src/components/Sidebar.test.tsx) and [sidebar preference routes](../../server/src/__tests__/sidebar-preferences-routes.test.ts) cover UI/server behavior. Manual: Star and reorder supported resources, collapse a section, navigate and reload. Switch company/user and inspect the intended preference scope. Open an unavailable resource and a legacy route; verify clear fallback or correct redirect without leaking another company’s state. +Record persistence separately: section collapse is in-memory and reopens after +reload; global sidebar collapse is disabled. Streamlined starred projects sort +alphabetically. Recents are browser-local by user/company; stars and supported +ordering are saved server-side. Local-trusted mode does not prove two-user isolation. + ### `profile-announcements` -Automated: [profile page](../ui/src/pages/ProfileSettings.test.tsx), [announcement hook](../ui/src/hooks/useAnnouncement.test.tsx), and [announcement routes](../server/src/__tests__/announcements-routes.test.ts) cover separate UI/persistence layers. +Automated: [profile page](../../ui/src/pages/ProfileSettings.test.tsx), [announcement hook](../../ui/src/hooks/useAnnouncement.test.tsx), and [announcement routes](../../server/src/__tests__/announcements-routes.test.ts) cover separate UI/persistence layers. Manual: Change a disposable user’s display details, reload, and inspect an attributed profile link. Dismiss a fixture announcement, switch companies, and verify it remains dismissed for that user; check another user still has their own dismissal state. diff --git a/maps/feature-map/onboarding.md b/maps/feature-map/onboarding.md new file mode 100644 index 00000000000..ecfaeefaaef --- /dev/null +++ b/maps/feature-map/onboarding.md @@ -0,0 +1,63 @@ +# Onboarding and first work + +An operator can start an instance, create a company, configure its first agent, and reach a real first task. Instance setup and company onboarding are separate steps. + +Implementation: [web wizard](../../ui/src/components/OnboardingWizard.tsx), [CLI setup](../../cli/src/commands/onboard.ts). + +Agent access: [companion](../agent-access-map/onboarding.md). + +## Sub-features + +- `instance`: choose local instance configuration and start the server. +- `company`: create a company with its identity and initial agent. +- `adapter`: choose an available harness, model, account, and execution environment. +- `first-task`: leave setup with a task whose execution or missing prerequisite is visible. +- `resume`: return to an incomplete wizard without losing saved state. + +## How to get to it (user POV) + +### `web-wizard` + +Open `/onboarding` or the onboarding launcher in an empty company context. +Use `/<company-prefix>/onboarding` to resume an existing company's agent setup; +the unprefixed route starts at organization naming on self-hosted instances. +Cloud-managed instances reuse their existing company for agent setup. + +### `cli-setup` + +Use `paperclipai onboard --help` or `paperclipai run --help` to select an isolated data directory before setup. + +## Driving it with Playwright and the Paperclip CLI + +Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use a new isolated data directory and record deployment mode. A configured adapter may still need account authentication. + +### `web-wizard` + +Automated: [wizard tests](../../ui/src/components/OnboardingWizard.test.tsx) cover mocked wizard behavior; [onboarding browser suite](../../tests/e2e/onboarding.spec.ts) supplies its own test environment. Live model execution is a separate check. + +- **Create company and resume.** Start a fresh empty instance and run doctor using + the [verification skill](../../.agents/skills/verify-paperclip/SKILL.md). Run + `node .agents/skills/verify-paperclip/scripts/onboarding-smoke.mjs --evidence-dir "$VERIFY_EVIDENCE_DIR"`. + The helper opens `/onboarding` in a fresh self-hosted instance, enters a unique + name in `e.g. Northwind Labs`, and chooses `Continue`. The agent-name field must + appear and the saved company must have no invented goal. Reopen the + company-prefixed onboarding route and reload; the agent step must appear + without creating another company. Screenshots and a trace capture the real UI action and result. + This proves company creation/resumption, not hiring or a first model run. + +Manual: Create a disposable company and first agent. Leave midway, reopen the +company's onboarding route, and finish; drafts save automatically. Follow the +created first task, verify the selected agent and company, and inspect its output +or explicit authentication gate. Reload both company and task. + +### `cli-setup` + +Automated: [CLI onboarding](../../cli/src/__tests__/onboard.test.ts) and [isolated test drive](../../cli/src/__tests__/test-drive.test.ts) cover their setup contracts; they do not establish that every model account is usable. + +Manual: Run setup in the disposable directory, open the URL it prints, and confirm the instance identity and company. Restart using that same configuration and verify saved state. Exercise an unavailable adapter and confirm an actionable error. + +## Gotchas + +- An empty company, an empty instance, and an unauthenticated browser are different states. +- Creating an agent does not prove its provider login or first execution succeeded. +- See [access](./access.md) for authenticated deployments and [agents](./agents.md) for later hiring. diff --git a/feature-map/pipelines.md b/maps/feature-map/pipelines.md similarity index 68% rename from feature-map/pipelines.md rename to maps/feature-map/pipelines.md index ac0bd5d48bf..a8f7780796a 100644 --- a/feature-map/pipelines.md +++ b/maps/feature-map/pipelines.md @@ -2,7 +2,9 @@ With the pipeline experiment enabled, people move structured work through configured stages, inspect stage automation, review outputs, and retain learning records. -Implementation: [pipeline board and item hosts](../ui/src/pages/Pipelines.tsx), [pipeline configuration](../ui/src/pages/PipelineSettings.tsx). +Implementation: [pipeline board and item hosts](../../ui/src/pages/Pipelines.tsx), [pipeline configuration](../../ui/src/pages/PipelineSettings.tsx). + +Agent access: [companion](../agent-access-map/pipelines.md). ## Sub-features @@ -21,24 +23,28 @@ Open `/pipelines`, `/pipelines/:pipelineId`, and its Settings route. Open `/pipelines/:pipelineId/items/:caseId`, `/review-queue`, or `/learnings`. -## Driving it +## Driving it with Playwright and the Paperclip CLI Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Enable pipelines and prepare a disposable pipeline with at least two stages and a fixture agent. Record any associated case type. ### `pipeline-board` -Automated: [pipeline UI helpers](../ui/src/pages/Pipelines.test.tsx) cover grouping and presentation helpers; [pipeline service](../server/src/__tests__/pipelines-service.test.ts) covers service behavior. +Automated: [pipeline UI helpers](../../ui/src/pages/Pipelines.test.tsx) cover grouping and presentation helpers; [pipeline service](../../server/src/__tests__/pipelines-service.test.ts) covers service behavior. Manual: Configure stages and a harmless automation, add an item, and inspect its stage. Follow its task/work references and observe the resulting transition. Reload grouping and settings; verify rejected or failed transitions leave the item in an intelligible state. ### `item-review-learning` -Automated: [pipeline tutorial browser suite](../tests/e2e/pipelines-tutorial-flow.spec.ts) targets a fixture journey; it does not qualify arbitrary stage automation or learning quality. +Automated: [pipeline tutorial browser suite](../../tests/e2e/pipelines-tutorial-flow.spec.ts) targets a fixture journey; it does not qualify arbitrary stage automation or learning quality. Manual: Open a pending review, inspect its source output and revision, accept or reject, and verify the persisted stage outcome. Follow a learning record to its source context and confirm it belongs to the intended pipeline/company. ## Gotchas +- Learnings is filtered case-event history, such as review decisions and forced + moves, not a separate authored learning-record system. The UI visibility flag + is separate from API authorization. + - These routes are experimental and may redirect or deny access when disabled. - ReviewQueue and Learnings are hosted from the pipeline module; a page-file inventory alone misses them. - Pipeline items and free-standing [cases](./cases.md) share records but have different navigation contexts. diff --git a/feature-map/plugins.md b/maps/feature-map/plugins.md similarity index 66% rename from feature-map/plugins.md rename to maps/feature-map/plugins.md index b97f79758e2..839f2a5e26e 100644 --- a/feature-map/plugins.md +++ b/maps/feature-map/plugins.md @@ -2,7 +2,9 @@ Operators install and configure plugins, inspect their status and errors, and use their contributed pages, tools, and managed resources within the plugin’s granted capabilities. -Implementation: [plugin manager](../ui/src/pages/PluginManager.tsx), [plugin settings](../ui/src/pages/PluginSettings.tsx), [plugin page](../ui/src/pages/PluginPage.tsx). +Implementation: [plugin manager](../../ui/src/pages/PluginManager.tsx), [plugin settings](../../ui/src/pages/PluginSettings.tsx), [plugin page](../../ui/src/pages/PluginPage.tsx). + +Agent access: [companion](../agent-access-map/plugins.md). ## Sub-features @@ -21,24 +23,29 @@ Open Settings → Plugins where permitted, or the CLI plugin commands. Open `/plugins/:pluginId`, a registered plugin route, or its contributed company/project settings surface. -## Driving it +## Driving it with Playwright and the Paperclip CLI Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use a known local fixture plugin with bounded capabilities. Record installed version and any declared external dependencies. ### `plugin-administration` -Automated: [install authorization](../server/src/__tests__/plugin-install-route-security.test.ts) and [plugin settings](../ui/src/pages/PluginSettings.test.tsx) cover route/UI contracts. +Automated: [install authorization](../../server/src/__tests__/plugin-install-route-security.test.ts) and [plugin settings](../../ui/src/pages/PluginSettings.test.tsx) cover route/UI contracts. Manual: Install the fixture, supply its configuration, activate it, and inspect status. Save an invalid configuration and verify a useful error. Disable/remove the disposable plugin and check its tools and managed resources no longer behave as active. ### `plugin-contribution` -Automated: [static plugin UI](../server/src/__tests__/plugin-ui-static.test.ts) and [company settings contribution](../ui/src/pages/CompanySettingsPluginPage.test.tsx) cover serving/host behavior. +Automated: [static plugin UI](../../server/src/__tests__/plugin-ui-static.test.ts) and [company settings contribution](../../ui/src/pages/CompanySettingsPluginPage.test.tsx) cover serving/host behavior. Manual: Open the contributed page by navigation and cold deep link, perform a harmless plugin action, and inspect its real outcome. Switch company and test an actor without the capability. Reload with the plugin unavailable and verify the host reports that state. ## Gotchas +- Installation/lifecycle is instance-level; configuration can be company-scoped. + Settings lives at `/company/settings/instance/plugins`. npm installation and + bundled-local installation are different controls. Developer file-watcher + reload is not a lifecycle action in the plugin manager. + - Installing a plugin does not authorize every host capability or external action. - Plugin-provided routes are dynamic; this map covers the host lifecycle, not every third-party feature. - Environment-driver plugins also need the [environment](./execution-environments.md) checks. diff --git a/feature-map/projects.md b/maps/feature-map/projects.md similarity index 61% rename from feature-map/projects.md rename to maps/feature-map/projects.md index f2c7f455913..0ed3470073f 100644 --- a/feature-map/projects.md +++ b/maps/feature-map/projects.md @@ -2,7 +2,9 @@ People group tasks into projects, attach goals and repositories, and configure the context and defaults used by project work. -Implementation: [project list](../ui/src/pages/Projects.tsx), [project detail](../ui/src/pages/ProjectDetail.tsx). +Implementation: [project list](../../ui/src/pages/Projects.tsx), [project detail](../../ui/src/pages/ProjectDetail.tsx). + +Agent access: [companion](../agent-access-map/projects.md). ## Sub-features @@ -21,24 +23,31 @@ Open `/projects`, create a project, and enter its overview or Issues view. Open `/projects/:projectId/configuration` and its repository/workspace settings. -## Driving it +## Driving it with Playwright and the Paperclip CLI Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use a disposable project and a small repository you can access; record checkout and credential ownership. ### `project-list` -Automated: [projects UI](../ui/src/pages/Projects.test.tsx) and [project detail](../ui/src/pages/ProjectDetail.test.tsx) are component tests. +Automated: [projects UI](../../ui/src/pages/Projects.test.tsx) and [project detail](../../ui/src/pages/ProjectDetail.test.tsx) are component tests. Manual: Create a project, add a task from its view, and reload. Verify the task belongs to that project, list counts are plausible, and links preserve company scope. Archive only the disposable project and inspect list/task behavior. ### `repository-settings` -Automated: [repository persistence](../server/src/__tests__/project-repositories-persistence.test.ts) and [repository browser suite](../tests/e2e/project-repositories.spec.ts) cover configured repository workflows. +Automated: [repository persistence](../../server/src/__tests__/project-repositories-persistence.test.ts) and [repository browser suite](../../tests/e2e/project-repositories.spec.ts) cover configured repository workflows. Manual: Attach a small repository, save, and create a project task. Inspect the actual checkout/revision used by its run. Test an invalid repository and missing credentials without treating a saved URL as a working workspace. ## Gotchas +- `/overview` now selects Configuration, not a distinct overview page. Personal + join/leave and star controls have their own persistence and should be checked + when affected; **Star/Unstar {project name}** is the visible star action. +- Project name slugs change on rename and have no historical alias lookup. + Reopen a renamed project through its stable UUID route, wait for the current + Configuration form, and record the newly normalized URL before reloading. + - Project repository configuration and a provisioned execution workspace are different records. - See [workspaces](./workspaces.md), [goals](./goals.md), and [budgets](./budgets-costs.md) for those features. - Do not assume a deleted or archived project should erase its historical task evidence. diff --git a/feature-map/questions-and-approvals.md b/maps/feature-map/questions-and-approvals.md similarity index 84% rename from feature-map/questions-and-approvals.md rename to maps/feature-map/questions-and-approvals.md index 32b611d3211..4ecf303f2aa 100644 --- a/feature-map/questions-and-approvals.md +++ b/maps/feature-map/questions-and-approvals.md @@ -5,6 +5,8 @@ history. A question, plan confirmation, governance approval, and app-tool review can look similar but have different authority and continuation rules. Each decision must stay attached to its original request and survive reload. +Agent access: [companion](../agent-access-map/questions-and-approvals.md). + ## Sub-features - `questions`: single-choice, multiple-choice, custom, and free-text answers; @@ -78,11 +80,11 @@ a component fixture alone cannot prove live response delivery. ### `task-thread` -Automated: [interaction cards](../ui/src/components/IssueThreadInteractionCard.test.tsx), -[paged task cards](../ui/src/components/task-chat/TaskChatInteractionCard.test.tsx), -and [task thread](../ui/src/components/TaskChatThread.test.tsx) cover form, -receipt, and dismissal behavior. [Resolution routes](../server/src/__tests__/issue-thread-interaction-routes.test.ts) -and [response delivery](../server/src/__tests__/question-response-delivery.test.ts) +Automated: [interaction cards](../../ui/src/components/IssueThreadInteractionCard.test.tsx), +[paged task cards](../../ui/src/components/task-chat/TaskChatInteractionCard.test.tsx), +and [task thread](../../ui/src/components/TaskChatThread.test.tsx) cover form, +receipt, and dismissal behavior. [Resolution routes](../../server/src/__tests__/issue-thread-interaction-routes.test.ts) +and [response delivery](../../server/src/__tests__/question-response-delivery.test.ts) cover authority and durable continuation. Run a targeted set with: ```sh @@ -99,7 +101,7 @@ before trying the old approval, then review the current revision. ### `agent-chat` -Automated: the [task thread suite](../ui/src/components/TaskChatThread.test.tsx) +Automated: the [task thread suite](../../ui/src/components/TaskChatThread.test.tsx) parameterizes unanswered-question history for conversation mode on and off. This proves component parity, not a model's plan handoff. @@ -109,7 +111,7 @@ history remains and the conversation does not close as a completed task. ### `attention` -Automated: [attention resolver](../ui/src/components/AttentionInteractionResolver.test.tsx) +Automated: [attention resolver](../../ui/src/components/AttentionInteractionResolver.test.tsx) covers preparing-request refresh without accepting it. The resolution-route tests above cover the API, not every Decisions or Inbox navigation path. @@ -121,8 +123,8 @@ manual coverage gap. ### `board-approval` -Automated: [approval service](../server/src/__tests__/approvals-service.test.ts) -and [idempotency routes](../server/src/__tests__/approval-routes-idempotency.test.ts) +Automated: [approval service](../../server/src/__tests__/approvals-service.test.ts) +and [idempotency routes](../../server/src/__tests__/approval-routes-idempotency.test.ts) cover persistence and duplicate decisions. They do not drive the approval pages. Manual: create disposable formal approval requests; approve one from the list, @@ -132,7 +134,7 @@ the recorded decision and its governed effect, not just a success toast. ### `app-review` -Automated: [connection reviews](../tests/e2e/connection-reviews.spec.ts) drives +Automated: [connection reviews](../../tests/e2e/connection-reviews.spec.ts) drives task/queue synchronization, approve, decline, remembered permission, provider failure, and restart with a scripted process agent and local MCP provider: @@ -144,11 +146,11 @@ Manual: dismiss and reopen a review, decide it from each entry point using fresh requests, and inspect the continuation's useful result. Always allow must show its scope; a future action with changed arguments should obey the saved rule. Provider failure must remain an approved decision with a failed execution. -Real provider and model checks follow [Task reviews](../doc/connections/TASK-REVIEWS.md#verification-workflows). +Real provider and model checks follow [Task reviews](../../doc/connections/TASK-REVIEWS.md#verification-workflows). ### `skill-test` -Automated: [read-only interaction contract](../server/src/__tests__/issue-thread-interaction-routes.test.ts) +Automated: [read-only interaction contract](../../server/src/__tests__/issue-thread-interaction-routes.test.ts) checks that fetching interactions does not mutate them. Shared card suites cover rendering. Neither proves the complete Skill Studio host or its permission matrix. @@ -159,9 +161,9 @@ referenced unit tests. ### `external-channel` -Automated: [chat question forms](../server/src/services/chat-question-forms.test.ts), -[Discord forms](../server/src/services/chat-discord-question-forms.test.ts), -and [interaction publications](../server/src/__tests__/chat-interaction-publications.test.ts) +Automated: [chat question forms](../../server/src/services/chat-question-forms.test.ts), +[Discord forms](../../server/src/services/chat-discord-question-forms.test.ts), +and [interaction publications](../../server/src/__tests__/chat-interaction-publications.test.ts) cover serialization and delivery logic with fixtures. Manual: use a disposable provider conversation, answer as the linked sender, diff --git a/feature-map/recovery.md b/maps/feature-map/recovery.md similarity index 87% rename from feature-map/recovery.md rename to maps/feature-map/recovery.md index 3e8a8564678..0236b1e4dec 100644 --- a/feature-map/recovery.md +++ b/maps/feature-map/recovery.md @@ -5,6 +5,8 @@ continuation is available. Recovery retains task context and ownership, honors pause and budget gates, and distinguishes confirmed failure from an uncertain external outcome. A retry acknowledgement alone is not proof that work resumed. +Agent access: [companion](../agent-access-map/recovery.md). + ## Sub-features - `diagnosis`: a task/run exposes the recorded cause, evidence, owner, attempt @@ -68,12 +70,12 @@ to get a failure screen. Use the existing deterministic recovery suite first. ### `source-task` -Automated: [recovery actions](../server/src/__tests__/issue-recovery-actions.test.ts) +Automated: [recovery actions](../../server/src/__tests__/issue-recovery-actions.test.ts) covers source scoping, bounded attempts, operator stops, quota monitoring, ownership, stale action retirement, and continuation deduplication. -[Recovery cards](../ui/src/components/IssueRecoveryActionCard.test.tsx) cover +[Recovery cards](../../ui/src/components/IssueRecoveryActionCard.test.tsx) cover diagnosis, retry progress, exhaustion, and action availability. The fixture-backed -[execution-recovery journeys](../tests/e2e/execution-recovery/recovery.spec.ts) +[execution-recovery journeys](../../tests/e2e/execution-recovery/recovery.spec.ts) start their own test-drive instances and exercise safe, uncertain, restart, manager-lineage, and legacy-unknown paths: @@ -90,7 +92,7 @@ history remains. A fixture pass does not qualify every live harness/provider. ### `run-detail` -Automated: [run workspace recovery](../ui/src/components/RunWorkspaceRecoverySurface.test.tsx) +Automated: [run workspace recovery](../../ui/src/components/RunWorkspaceRecoverySurface.test.tsx) tests this host; it is component coverage, not an end-to-end repair. Manual: prepare a failed run with error code `workspace_validation_failed`, a @@ -105,10 +107,10 @@ gap. ### `operator-retry` -Automated: [disposition notices](../ui/src/components/DispositionRecoveryNotice.test.tsx) +Automated: [disposition notices](../../ui/src/components/DispositionRecoveryNotice.test.tsx) tests pending/error/gated states and waiting for the real retry acknowledgement. The recovery-card suite covers workspace divergence, reissue, reconcile, and -confirmation. [Workspace export](../ui/src/components/WorkspaceExportRecovery.test.tsx) +confirmation. [Workspace export](../../ui/src/components/WorkspaceExportRecovery.test.tsx) covers its separate repair surface. Manual: reproduce an exhausted disposable action. Click once, verify pending @@ -120,10 +122,10 @@ action paths. A changed card label is insufficient proof of workspace integrity. ### `post-stop-message` -Automated: [legacy continuation](../server/src/services/recovery/legacy-continuation.test.ts), -[queue routes](../server/src/__tests__/issue-queued-comments-routes.test.ts), and -[native restart recovery](../server/src/services/native-runtime/native-restart-recovery.test.ts) -cover their respective recovery paths. [Legacy failure continuation](../tests/e2e/legacy-failure-continuation.spec.ts) +Automated: [legacy continuation](../../server/src/services/recovery/legacy-continuation.test.ts), +[queue routes](../../server/src/__tests__/issue-queued-comments-routes.test.ts), and +[native restart recovery](../../server/src/services/native-runtime/native-restart-recovery.test.ts) +cover their respective recovery paths. [Legacy failure continuation](../../tests/e2e/legacy-failure-continuation.spec.ts) adds a browser journey; it is not native runtime proof. Manual: stop a disposable run, submit a distinguishable follow-up, and observe @@ -134,7 +136,7 @@ outcomes, inspect the provider before authorizing another operation. ### `browser-reconnect` -Automated: [live-update recovery](../ui/src/context/LiveUpdatesProvider.recovery.test.tsx) +Automated: [live-update recovery](../../ui/src/context/LiveUpdatesProvider.recovery.test.tsx) covers client transport recovery with mocks. It does not prove runner restart. Manual: go offline during a disposable run, let the server reach a new state, @@ -155,5 +157,5 @@ only. Separately inspect the underlying execution if it is still stuck. silently trigger recovery that fights the user's instruction. - Source inspection and mocked component tests do not qualify real server restart continuity. Use the appropriate runtime suite for that claim. -- See [execution semantics](../doc/execution-semantics.md) for the governing +- See [execution semantics](../../doc/execution-semantics.md) for the governing recovery contract and [steering](./steering.md) for saved follow-ups. diff --git a/feature-map/routines.md b/maps/feature-map/routines.md similarity index 57% rename from feature-map/routines.md rename to maps/feature-map/routines.md index c951e170912..621fd21bca6 100644 --- a/feature-map/routines.md +++ b/maps/feature-map/routines.md @@ -2,7 +2,9 @@ Operators define repeatable work, configure its inputs and triggers, run it on demand or automatically, and inspect the execution history and resulting tasks. -Implementation: [routine list](../ui/src/pages/Routines.tsx), [routine detail](../ui/src/pages/RoutineDetail.tsx), [trigger editor](../ui/src/components/routine-triggers/TriggerWizard.tsx). +Implementation: [routine list](../../ui/src/pages/Routines.tsx), [routine detail](../../ui/src/pages/RoutineDetail.tsx), [trigger editor](../../ui/src/components/routine-triggers/TriggerWizard.tsx). + +Agent access: [companion](../agent-access-map/routines.md). ## Sub-features @@ -20,21 +22,27 @@ Open `/routines`, create one, and inspect `/routines/:routineId` and its edit se ### `trigger-history` -Use the routine’s trigger controls and History/Audit links; lists also offer Run now. +Use the routine's trigger controls and Runs/Recent Runs/Audit links; lists also +offer Run now. **History** records configuration versions, not executions. -## Driving it +## Driving it with Playwright and the Paperclip CLI Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use a disposable routine, deterministic test agent, and harmless target. Record schedule timezone and enabled state. ### `routine-editor` -Automated: [routine detail helper](../ui/src/pages/RoutineDetail.test.tsx) tests project-selector options, not the rendered editor. [Routine service](../server/src/__tests__/routines-service.test.ts) covers server contracts. Saving and running through the editor remain manual checks. +Automated: [routine detail helper](../../ui/src/pages/RoutineDetail.test.tsx) tests project-selector options, not the rendered editor. [Routine service](../../server/src/__tests__/routines-service.test.ts) covers server contracts. Saving and running through the editor remain manual checks. Manual: Save an agent, request, project, and a required variable. Reload and inspect the saved definition. Run now with a distinguishable input, follow the resulting task, and verify its output and history entry. Check a missing required input. +Variables are detected from `{{name}}` placeholders; mark a detected variable +Required and verify missing input prevents Run routine. Configuration can be +checked without model credentials. A successful subprocess run alone does not +complete the generated task or establish a useful task output. + ### `trigger-history` -Automated: [trigger wizard](../ui/src/components/routine-triggers/TriggerWizard.test.tsx) is component coverage; [routine integration](../server/src/__tests__/routines-e2e.test.ts) is server integration rather than a provider-wide browser proof. +Automated: [trigger wizard](../../ui/src/components/routine-triggers/TriggerWizard.test.tsx) is component coverage; [routine integration](../../server/src/__tests__/routines-e2e.test.ts) is server integration rather than a provider-wide browser proof. Manual: Set a near-term disposable schedule or authorized test webhook, verify one eligible execution and its source trigger, then disable it and confirm further invocations stop. Inspect a failed execution without losing its history. Remove the temporary trigger after testing. diff --git a/feature-map/runs-adapters.md b/maps/feature-map/runs-adapters.md similarity index 68% rename from feature-map/runs-adapters.md rename to maps/feature-map/runs-adapters.md index 6b1f1a4f1e1..bec86d34f2c 100644 --- a/feature-map/runs-adapters.md +++ b/maps/feature-map/runs-adapters.md @@ -2,7 +2,9 @@ Operators choose an agent harness and model account, inspect execution history and transcripts, and distinguish a working configuration from a successful task result. -Implementation: [adapter manager](../ui/src/pages/AdapterManager.tsx), [run host](../ui/src/pages/AgentDetail.tsx), [configuration](../ui/src/components/AgentConfigForm.tsx). +Implementation: [adapter manager](../../ui/src/pages/AdapterManager.tsx), [run host](../../ui/src/pages/AgentDetail.tsx), [configuration](../../ui/src/components/AgentConfigForm.tsx). + +Agent access: [companion](../agent-access-map/runs-adapters.md). ## Sub-features @@ -21,24 +23,28 @@ Open an agent’s model/adapter configuration or the instance adapter settings p Open an agent run at `/agents/:agentId/runs/:runId` from the agent or task activity. -## Driving it +## Driving it with Playwright and the Paperclip CLI Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Record adapter, model, account, native/legacy runtime, and environment. Capabilities vary; one harness result is not a matrix-wide qualification. ### `adapter-setup` -Automated: [adapter validation routes](../server/src/__tests__/agent-adapter-validation-routes.test.ts) and [model refresh](../server/src/__tests__/adapter-model-refresh-routes.test.ts) cover server behavior. +Automated: [adapter validation routes](../../server/src/__tests__/agent-adapter-validation-routes.test.ts) and [model refresh](../../server/src/__tests__/adapter-model-refresh-routes.test.ts) cover server behavior. Manual: Select the intended adapter and account, refresh model choices, and run its environment test. Save, reload, then execute a small task. Repeat with a deliberately unavailable account and verify the failure remains visible. ### `run-history` -Automated: [live run routes](../server/src/__tests__/agent-live-run-routes.test.ts) and [run event sequencing](../server/src/__tests__/heartbeat-run-event-sequencing.test.ts) cover run data contracts. +Automated: [live run routes](../../server/src/__tests__/agent-live-run-routes.test.ts) and [run event sequencing](../../server/src/__tests__/heartbeat-run-event-sequencing.test.ts) cover run data contracts. Manual: Start a disposable task, open its running transcript, and follow it to a terminal outcome. Reload and compare task/run IDs, status, output, and cost. Verify a failed run remains distinguishable from a completed run with useful output. ## Gotchas +- Model accounts are managed in Apps and inline agent account controls. + `/company/settings/connections` configures decision/fast-response models, + not the general model-account library. + - The run log is instance database data; it is not the opt-out first-party Telemetry system or operator-configured OpenTelemetry tracing. - Authentication/model discovery does not establish tool, image, steering, or resumption capability parity. - Use [recovery](./recovery.md) for interrupted runs and [execution environments](./execution-environments.md) for local/SSH/sandbox differences. diff --git a/feature-map/secrets.md b/maps/feature-map/secrets.md similarity index 66% rename from feature-map/secrets.md rename to maps/feature-map/secrets.md index 71dcfb7c262..34082ab4ba9 100644 --- a/feature-map/secrets.md +++ b/maps/feature-map/secrets.md @@ -2,7 +2,9 @@ Operators manage company secret references, users supply their own credentials where required, and reviewers decide proposed secret changes without exposing secret values in ordinary UI evidence. -Implementation: [secrets page](../ui/src/pages/Secrets.tsx), [personal secrets](../ui/src/pages/secrets/MyUserSecretsTab.tsx), [proposals](../ui/src/pages/secrets/ProposalsTab.tsx). +Implementation: [secrets page](../../ui/src/pages/Secrets.tsx), [personal secrets](../../ui/src/pages/secrets/MyUserSecretsTab.tsx), [proposals](../../ui/src/pages/secrets/ProposalsTab.tsx). + +Agent access: [companion](../agent-access-map/secrets.md). ## Sub-features @@ -22,24 +24,28 @@ Open company Settings → Secrets and the company/personal secret views. Use the Proposals tab or Import from vault where available. -## Driving it +## Driving it with Playwright and the Paperclip CLI Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use dummy credentials in an isolated company, two user identities, and a test agent. Never print actual secret values in evidence. ### `secret-management` -Automated: [secrets service](../server/src/__tests__/secrets-service.test.ts) and [user ownership](../server/src/__tests__/secrets-service-user-secret-owner-scoped.test.ts) cover storage/access contracts. +Automated: [secrets service](../../server/src/__tests__/secrets-service.test.ts) and [user ownership](../../server/src/__tests__/secrets-service-user-secret-owner-scoped.test.ts) cover storage/access contracts. Manual: Create a dummy secret reference, bind it through a supported field, and verify its use in a harmless run without logging the value. Update it and reload. Switch users and confirm personal values remain owner-scoped and missing credentials produce an actionable state. ### `proposal-import` -Automated: [proposal routes](../server/src/__tests__/secret-proposals-routes.test.ts) and [vault import dialog](../ui/src/pages/secrets/ImportFromVaultDialog.test.tsx) cover route/UI layers; a real vault integration needs live validation. +Automated: [proposal routes](../../server/src/__tests__/secret-proposals-routes.test.ts) and [vault import dialog](../../ui/src/pages/secrets/ImportFromVaultDialog.test.tsx) cover route/UI layers; a real vault integration needs live validation. Manual: Review a dummy proposal, reject one and accept another, then inspect the resulting reference state and history. Preview a vault import, select only disposable entries, and verify imported references and denied-access errors after reload. ## Gotchas +- Vault import currently targets eligible AWS Secrets Manager providers. + Organization-secret checks in local-trusted mode do not establish two-user + personal-secret ownership or actual external-vault import. + - A displayed reference name is not proof that the actor can resolve its value at runtime. - Company secrets and user-specific values have different ownership. - Redaction needs verification in errors, activity, and run logs as well as the form. diff --git a/feature-map/skills.md b/maps/feature-map/skills.md similarity index 65% rename from feature-map/skills.md rename to maps/feature-map/skills.md index c8565f2b2a7..4085f690090 100644 --- a/feature-map/skills.md +++ b/maps/feature-map/skills.md @@ -2,7 +2,9 @@ Operators discover or import skills, author and test them in Skill Studio, manage source versions, and decide which agents may use them. -Implementation: [skill library](../ui/src/pages/CompanySkills.tsx), [studio](../ui/src/pages/SkillStudio.tsx), [sources](../ui/src/pages/SkillSources.tsx), [agent skills](../ui/src/pages/agent-skills/AgentSkillsTab.tsx). +Implementation: [skill library](../../ui/src/pages/CompanySkills.tsx), [studio](../../ui/src/pages/SkillStudio.tsx), [sources](../../ui/src/pages/SkillSources.tsx), [agent skills](../../ui/src/pages/agent-skills/AgentSkillsTab.tsx). + +Agent access: [companion](../agent-access-map/skills.md). ## Sub-features @@ -21,35 +23,41 @@ Open `/skills`, Discover, or `/skills/sources`; project import is available thro ### `studio` Use New skill or `/skills/studio/:skillId`; writable copies and source-backed originals differ. +Direct authoring starts at `/skills/studio/new`: fill **Name** and **Slug**, then +**Create skill**. Authoring/save/reload requires no model credential; running a +Studio test is separate. ### `agent-enablement` Use skill policy/agent selection in the library or an agent’s Skills tab. -## Driving it +## Driving it with Playwright and the Paperclip CLI Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use a harmless skill fixture and test agent with skill support. Record whether the source is local, bundled, project, or remote. ### `library-sources` -Automated: [library UI](../ui/src/pages/CompanySkills.test.tsx) and [source service](../server/src/__tests__/skill-sources.test.ts) cover their respective layers. +Automated: [library UI](../../ui/src/pages/CompanySkills.test.tsx) and [source service](../../server/src/__tests__/skill-sources.test.ts) cover their respective layers. Manual: Install a fixture, locate it in Installed, inspect its source and files, and reload. Import a source update and compare versions before accepting it. Check a source requiring unavailable repository access and verify the failure is explicit. ### `studio` -Automated: [Studio](../ui/src/pages/SkillStudio.test.tsx) covers UI behavior; model-driven skill testing remains a separate manual result. +Automated: [Studio](../../ui/src/pages/SkillStudio.test.tsx) covers UI behavior; model-driven skill testing remains a separate manual result. Manual: Create or fork a disposable skill, edit instructions, save, and reload. Run a bounded test, respond to any question, and inspect the output. Verify the saved version matches what was tested and failed saves retain the draft. ### `agent-enablement` -Automated: [policy service](../server/src/__tests__/company-skill-policy-service.test.ts) and [agent skills state](../ui/src/pages/agent-skills/AgentSkillsTab.test.ts) cover policy/state contracts. +Automated: [policy service](../../server/src/__tests__/company-skill-policy-service.test.ts) and [agent skills state](../../ui/src/pages/agent-skills/AgentSkillsTab.test.ts) cover policy/state contracts. Manual: Enable the skill for one test agent and leave another without access. Run a small task on each, inspect the available skill context, then change the release/policy and verify the next eligible run uses the intended version. ## Gotchas +- The release picker requires `enableBetaSkills`. Do not invent an Instructions + accessibility label for the Studio body editor; inspect its current handles. + - Installed does not mean enabled for every agent. - Read-only GitHub/bundled skills may require a writable copy rather than an in-place edit. - Studio interaction coverage is in [questions and approvals](./questions-and-approvals.md); a passing mock is not a live harness test. diff --git a/feature-map/status-cards.md b/maps/feature-map/status-cards.md similarity index 74% rename from feature-map/status-cards.md rename to maps/feature-map/status-cards.md index ec0b2b240c4..c91082da6b6 100644 --- a/feature-map/status-cards.md +++ b/maps/feature-map/status-cards.md @@ -2,7 +2,9 @@ The status-card experiment turns a saved prompt/query into an inspectable summary of watched work, with refresh history, configuration, and archive/restore controls. -Implementation: [status page](../ui/src/pages/StatusCards/index.tsx), [card detail](../ui/src/pages/StatusCards/StatusCardDetailDrawer.tsx). +Implementation: [status page](../../ui/src/pages/StatusCards/index.tsx), [card detail](../../ui/src/pages/StatusCards/StatusCardDetailDrawer.tsx). + +Agent access: [companion](../agent-access-map/status-cards.md). ## Sub-features @@ -27,13 +29,13 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Enab ### `status-board` -Automated: [status-card service/routes](../server/src/__tests__/status-cards.test.ts) covers gated lifecycle and generation contracts; [tile UI](../ui/src/pages/StatusCards/StatusCardTile.test.tsx) covers rendering. +Automated: [status-card service/routes](../../server/src/__tests__/status-cards.test.ts) covers gated lifecycle and generation contracts; [tile UI](../../ui/src/pages/StatusCards/StatusCardTile.test.tsx) covers rendering. Manual: Create a bounded card, inspect its generation task, and wait for a saved summary. Change a watched task, refresh, and verify both the summary and update history reflect the source change. Exercise a failed generation and confirm it is not presented as fresh. ### `card-settings` -Automated: [settings form](../ui/src/pages/StatusCards/StatusCardSettingsForm.test.tsx) and [update engine](../server/src/__tests__/status-card-update-engine.test.ts) cover separate UI/engine layers. +Automated: [settings form](../../ui/src/pages/StatusCards/StatusCardSettingsForm.test.tsx) and [update engine](../../server/src/__tests__/status-card-update-engine.test.ts) cover separate UI/engine layers. Manual: Change a disposable card’s prompt or summarizer, run the offered rebuild/refresh, and inspect the resulting query and summary. Archive and restore it, reload its direct link, and check the actual new generation outcome. diff --git a/feature-map/steering.md b/maps/feature-map/steering.md similarity index 87% rename from feature-map/steering.md rename to maps/feature-map/steering.md index 02d1cfa91bc..8366f9614d9 100644 --- a/feature-map/steering.md +++ b/maps/feature-map/steering.md @@ -5,6 +5,8 @@ not been consumed, and choose when to steer or interrupt. Stopping a run and pausing a task are separate controls. The UI must preserve the message, explain why it is waiting, and only claim delivery after an authoritative acknowledgement. +Agent access: [companion](../agent-access-map/steering.md). + ## Sub-features - `queue`: a busy task saves follow-ups and shows their order and wait reason. @@ -57,11 +59,11 @@ unsupported steering without treating an absent capability as a product failure. ### `task-composer` -Automated: [queued-message UI](../ui/src/components/task-chat/TaskChatQueuedMessages.test.tsx) +Automated: [queued-message UI](../../ui/src/components/task-chat/TaskChatQueuedMessages.test.tsx) covers order, failure restoration, unavailable steering, stale revisions, and -discard acknowledgements. [Queue routes](../server/src/__tests__/issue-queued-comments-routes.test.ts) +discard acknowledgements. [Queue routes](../../server/src/__tests__/issue-queued-comments-routes.test.ts) cover durable ordering, authorization, steering acknowledgements, interrupt -recovery, and concurrent dispatch. [Composer](../ui/src/components/task-chat/TaskChatComposer.test.tsx) +recovery, and concurrent dispatch. [Composer](../../ui/src/components/task-chat/TaskChatComposer.test.tsx) covers drafts and uncertain saves. ```sh @@ -69,9 +71,9 @@ pnpm exec vitest run ui/src/components/task-chat/TaskChatQueuedMessages.test.tsx ``` Automated stop coverage is separate: -[composer stop](../tests/e2e/composer-stop.spec.ts) with -[its configuration](../tests/e2e/playwright-composer-stop.config.ts) and -[ACP stop/continuation](../tests/e2e/acp-stop-continuation.spec.ts). Inspect their +[composer stop](../../tests/e2e/composer-stop.spec.ts) with +[its configuration](../../tests/e2e/playwright-composer-stop.config.ts) and +[ACP stop/continuation](../../tests/e2e/acp-stop-continuation.spec.ts). Inspect their fixture and opt-in prerequisites; skipped native cases are not native proof. Manual: queue three distinguishable messages. Edit one, reorder them, reload, @@ -86,7 +88,7 @@ when available; the named component tests do not prove every host integration. ### `agent-chat` -Automated: [agent chat sessions](../tests/e2e/agent-chat-sessions.spec.ts) covers +Automated: [agent chat sessions](../../tests/e2e/agent-chat-sessions.spec.ts) covers persistent conversation/session behavior with deterministic harness fixtures; shared composer tests cover the controls. These do not qualify live steering for every harness. @@ -100,7 +102,7 @@ when the change touches delivery. ### `request-response` -Automated: [response delivery](../server/src/__tests__/question-response-delivery.test.ts) +Automated: [response delivery](../../server/src/__tests__/question-response-delivery.test.ts) proves queueing/coalescing and retry-safe receipts. The queue-route suite tests separate comment and approval queues, explicit steering, and fresh-session restrictions on plan approvals. @@ -113,7 +115,7 @@ approval outcome through an ordinary queued-message editor. ### `external-channel` -Automated: [chat interaction arbitration](../server/src/services/chat-interaction-arbitration.test.ts) +Automated: [chat interaction arbitration](../../server/src/services/chat-interaction-arbitration.test.ts) covers backend arbitration with simulated requests. It is not a provider UI test. Manual: send two distinguishable messages through one disposable linked diff --git a/feature-map/task-coordination.md b/maps/feature-map/task-coordination.md similarity index 65% rename from feature-map/task-coordination.md rename to maps/feature-map/task-coordination.md index 9549f7606d3..aff32dc01d1 100644 --- a/feature-map/task-coordination.md +++ b/maps/feature-map/task-coordination.md @@ -2,7 +2,9 @@ A larger task can coordinate child work, dependencies, reviewers, and execution controls while preserving a clear owner and a traceable completion decision. -Implementation: [task thread](../ui/src/pages/IssueDetail.tsx), [execution policy](../server/src/services/issues.ts). +Implementation: [task thread](../../ui/src/pages/IssueDetail.tsx), [execution policy](../../server/src/services/issues.ts). + +Agent access: [companion](../agent-access-map/task-coordination.md). ## Sub-features @@ -21,19 +23,23 @@ Open the task’s parent/child and dependency links in task detail. Inspect the task’s execution policy and available review, pause, resume, or stop controls. -## Driving it +## Driving it with Playwright and the Paperclip API Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Prepare a parent, child, and dependent task plus implementer/reviewer agents. Record execution policy and use a disposable tree. +Parent/dependency navigation and inherited tree holds can be checked on unassigned +tasks. Agents are required for the continuation and signoff portion. A tree pause +creates a hold; it does not change task statuses to a paused status. + ### `task-relationships` -Automated: [dependency wakeups](../server/src/__tests__/issue-dependency-wakeups-routes.test.ts) checks server behavior; visual relationship navigation remains a manual check. +Automated: [dependency wakeups](../../server/src/__tests__/issue-dependency-wakeups-routes.test.ts) checks server behavior; visual relationship navigation remains a manual check. Manual: Block one task on another, finish the prerequisite, and observe the dependent task’s eligible continuation. Follow parent/child links back to the source request. Verify no duplicate run or false parent completion occurs. ### `review-and-controls` -Automated: [execution policy](../server/src/__tests__/issue-execution-policy.test.ts), [tree control](../server/src/__tests__/issue-tree-control-service.test.ts), and [signoff browser suite](../tests/e2e/signoff-policy.spec.ts) target these layers. A linked suite is not a claim that its current run is green. +Automated: [execution policy](../../server/src/__tests__/issue-execution-policy.test.ts), [tree control](../../server/src/__tests__/issue-tree-control-service.test.ts), and [signoff browser suite](../../tests/e2e/signoff-policy.spec.ts) target these layers. A linked suite is not a claim that its current run is green. Manual: Complete implementation under a review-required policy and confirm it waits for the required review outcome. Exercise rejection and later approval. Pause the parent while child work is pending, then verify resumption respects budget and ownership gates. diff --git a/feature-map/tasks.md b/maps/feature-map/tasks.md similarity index 59% rename from feature-map/tasks.md rename to maps/feature-map/tasks.md index a2a7e42bc39..19eb631af3c 100644 --- a/feature-map/tasks.md +++ b/maps/feature-map/tasks.md @@ -2,7 +2,9 @@ People create, assign, organize, discuss, and finish tasks. A task retains its owner, project, priority, history, and outputs as it moves through the work lifecycle. -Implementation: [new task](../ui/src/components/NewIssueDialog.tsx), [task list](../ui/src/pages/Issues.tsx), [task detail](../ui/src/pages/IssueDetail.tsx). +Implementation: [new task](../../ui/src/components/NewIssueDialog.tsx), [task list](../../ui/src/pages/Issues.tsx), [task detail](../../ui/src/pages/IssueDetail.tsx). + +Agent access: [companion](../agent-access-map/tasks.md). ## Sub-features @@ -26,30 +28,39 @@ Open `/issues` or a project’s Issues view; legacy `/tasks` links redirect. Open `/issues/:issueId` from a row, mention, or direct link. -## Driving it +## Driving it with Playwright and the Paperclip CLI -Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use an invokable test agent and a project; record whether a real or simulated harness handles the request. +Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use a +disposable project. Persistence checks can assign work to Me or leave it +unassigned; execution checks additionally need an invokable test agent. Record +whether a real or simulated harness handles execution. ### `new-task` -Automated: [creation dialog](../ui/src/components/NewIssueDialog.test.tsx) is component coverage with mocked requests. +Automated: [creation dialog](../../ui/src/components/NewIssueDialog.test.tsx) is component coverage with mocked requests. Manual: Create a uniquely named task with an agent and project. Open its resulting link and reload. Confirm text, attachment, assignment, and project persisted, and verify execution started only when the task and agent are eligible. +Creation is request-first: enter **Describe a task…**, choose the assignee and +project, and submit **Create task**. A standalone title field is not always shown. +Use a human/unassigned task to verify persistence without invoking a model. + ### `task-list` -Automated: [task-list helpers](../ui/src/pages/Issues.test.tsx) cover search URLs, pagination, deduplication, and presentation constants. The suite does not render the page or exercise filtering, grouping, opening a task, and returning; those interactions still need a running-browser check. +Automated: [task-list helpers](../../ui/src/pages/Issues.test.tsx) cover search URLs, pagination, deduplication, and presentation constants. The suite does not render the page or exercise filtering, grouping, opening a task, and returning; those interactions still need a running-browser check. Manual: Filter by status and assignee, change grouping/sort, open a task, and return. Verify the correct records and selected view survive navigation and reload. Include an empty result and a failed request. ### `task-detail` -Automated: [task detail](../ui/src/pages/IssueDetail.test.tsx) covers component behavior, while [issue service](../server/src/__tests__/issues-service.test.ts) covers server contracts. +Automated: [task detail](../../ui/src/pages/IssueDetail.test.tsx) covers component behavior, while [issue service](../../server/src/__tests__/issues-service.test.ts) covers server contracts. Manual: Add a comment and edit authorized properties. Reload and verify history, actor, and values. Complete a disposable task and inspect its deliverable; reopen through an available action and confirm the next execution follows current policy. ## Gotchas +- Priority remains an API field, but ordinary priority UI editing is hidden. + - A status label alone does not prove that the requested work exists. - Single-assignee and atomic checkout rules still apply when tasks are created from different hosts. - Use [coordination](./task-coordination.md), [steering](./steering.md), and [documents](./documents-artifacts.md) for their distinct workflows. diff --git a/feature-map/teams.md b/maps/feature-map/teams.md similarity index 71% rename from feature-map/teams.md rename to maps/feature-map/teams.md index a1370c75ebd..c3ecdecbd4a 100644 --- a/feature-map/teams.md +++ b/maps/feature-map/teams.md @@ -2,7 +2,9 @@ Operators can preview and install a team package with its agents and related resources. Catalog/package installation is available through the CLI/API; the repository also contains catalog UI components that are not a current top-level route. -Implementation: [CLI teams](../cli/src/commands/client/teams.ts), [catalog components](../ui/src/pages/TeamCatalog.tsx). +Implementation: [CLI teams](../../cli/src/commands/client/teams.ts), [catalog components](../../ui/src/pages/TeamCatalog.tsx). + +Agent access: [companion](../agent-access-map/teams.md). ## Sub-features @@ -21,24 +23,28 @@ Use the CLI teams catalog/preview/install operations in the selected context. The catalog components and install wizard are available in developer tests/design surfaces. No top-level `/teams` route is registered; use the CLI/API for the current installation workflow. -## Driving it +## Driving it with the Paperclip CLI Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use a disposable company, a small catalog entry, and explicit company context. Check `paperclipai teams --help` for current subcommands. ### `catalog-cli` -Automated: [team commands](../cli/src/__tests__/teams.test.ts) and [catalog service](../server/src/__tests__/teams-catalog-service.test.ts) cover command and service contracts. +Automated: [team commands](../../cli/src/__tests__/teams.test.ts) and [catalog service](../../server/src/__tests__/teams-catalog-service.test.ts) cover command and service contracts. Manual: List an entry, preview its proposed agents and resources, install it into the disposable company, and inspect each created resource. Run a small task after configuring accounts. Retry preview/install as supported and inspect duplicate/conflict behavior. ### `catalog-components` -Automated: [catalog component](../ui/src/pages/TeamCatalog.test.tsx) and [install hook](../ui/src/pages/useInstallTeamCatalogEntry.test.tsx) cover preview and install UI logic with mocks. +Automated: [catalog component](../../ui/src/pages/TeamCatalog.test.tsx) and [install hook](../../ui/src/pages/useInstallTeamCatalogEntry.test.tsx) cover preview and install UI logic with mocks. Manual: When changing or mounting these components, exercise preview, manager/source options, cancellation, failed install, and success. Follow created agents in the actual company. Record whether this was a component harness or a real product entry point. ## Gotchas +- Catalog preview records an audit event but installs no agents/projects. Use + `teams browse/inspect/preview` with explicit company and origin API context; + the current product has no `/teams` route. + - Source files do not prove that a screen is reachable in the current app. - Installed team configuration may still need credentials or environment setup. - Company package import is a separate [portability](./companies.md) workflow. diff --git a/feature-map/workspaces.md b/maps/feature-map/workspaces.md similarity index 65% rename from feature-map/workspaces.md rename to maps/feature-map/workspaces.md index d7b24fca7c1..786f5f4d8ef 100644 --- a/feature-map/workspaces.md +++ b/maps/feature-map/workspaces.md @@ -2,7 +2,9 @@ A project or task can use a managed workspace with inspectable files, Git state, services, and runtime controls. Operators can follow its lifecycle through provisioning, use, and closure. -Implementation: [workspace list](../ui/src/pages/Workspaces.tsx), [execution workspace](../ui/src/pages/ExecutionWorkspaceDetail.tsx), [project workspace](../ui/src/pages/ProjectWorkspaceDetail.tsx). +Implementation: [workspace list](../../ui/src/pages/Workspaces.tsx), [execution workspace](../../ui/src/pages/ExecutionWorkspaceDetail.tsx), [project workspace](../../ui/src/pages/ProjectWorkspaceDetail.tsx). + +Agent access: [companion](../agent-access-map/workspaces.md). ## Sub-features @@ -22,24 +24,29 @@ Use the task’s workspace card or Files panel and follow its workspace link. Open `/workspaces`, a project workspace, or `/execution-workspaces/:workspaceId` and its Services/Configuration/Runtime logs views. -## Driving it +## Driving it with Playwright and the Paperclip CLI Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Enable isolated-workspace UI where needed. Use an owned disposable repository and environment; record workspace ID and branch. ### `task-workspace` -Automated: [workspace card](../ui/src/components/IssueWorkspaceCard.test.tsx) and [task binding](../server/src/__tests__/issue-runtime-workspace-binding.test.ts) cover host/binding contracts. +Automated: [workspace card](../../ui/src/components/IssueWorkspaceCard.test.tsx) and [task binding](../../server/src/__tests__/issue-runtime-workspace-binding.test.ts) cover host/binding contracts. Manual: Run a task that writes a small file, follow the bound workspace, and inspect the file and revision. Reload and verify links still refer to the same workspace, not another checkout or a stale run. ### `workspace-management` -Automated: [workspace detail](../ui/src/pages/ExecutionWorkspaceDetail.test.tsx) and [runtime service](../server/src/__tests__/workspace-runtime.test.ts) cover UI and runtime contracts separately. +Automated: [workspace detail](../../ui/src/pages/ExecutionWorkspaceDetail.test.tsx) and [runtime service](../../server/src/__tests__/workspace-runtime.test.ts) cover UI and runtime contracts separately. Manual: Provision a disposable workspace, wait for readiness, start an available service, open its endpoint, and inspect a real response and logs. Stop it and verify it actually stops. Exercise close/reopen only after inspecting pending changes and active runs. ## Gotchas +- UI visibility, default isolation policy, the local environment row, a workspace + record, and a running service are distinct prerequisites. A fresh instance + still needs an owned cwd/repository and runtime commands; `/workspaces` lists + existing work. Reopening depends on lifecycle, not a generic detail action. + - A link or running badge does not prove the service is reachable from the user’s browser. - Workspace IDs, runtime leases, and repository paths are not interchangeable. - Use [recovery](./recovery.md) for divergence/export repair and [CLI operations](./cli-operations.md) for local worktree tooling. diff --git a/packages/adapter-utils/src/acpx-engine/constants.ts b/packages/adapter-utils/src/acpx-engine/constants.ts index 663d19ec243..1c37dc958cd 100644 --- a/packages/adapter-utils/src/acpx-engine/constants.ts +++ b/packages/adapter-utils/src/acpx-engine/constants.ts @@ -26,6 +26,13 @@ export const ACPX_HANDSHAKE_TRANSPORT_POLL_MS = 250; // much smaller than the whole-adapter execution timeout. export const ACPX_DUPLEX_LOSS_CANCEL_DEADLINE_MS = 30_000; +// The closed ACP `ToolKind` enum. Run diagnostics may report these labels for +// pending tool calls; tool titles, names, and inputs never leave the run. +// Any other reported kind is collapsed to "unknown". +export const ACP_TOOL_KINDS = [ + "read", "edit", "delete", "move", "search", "execute", "think", "fetch", "switch_mode", "other", +] as const; + export const ACPX_ADAPTER_AGENT_IDS = { claude_local: "claude", codex_local: "codex", diff --git a/packages/adapter-utils/src/acpx-engine/execute.test.ts b/packages/adapter-utils/src/acpx-engine/execute.test.ts index 78fd4ebfcf3..095996c1f83 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.test.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.test.ts @@ -33,6 +33,7 @@ import { parseGeminiVersionParts, referencedSourceContentSignature, rewriteGeminiAcpFlagForVersion, + summarizeAcpStopActivity, summarizeAcpxTurnUsage, type AcpxEngineExecutorOptions, } from "./execute.js"; @@ -3400,6 +3401,118 @@ describe("ACP activity diagnostics", () => { expect(currentNow).toBeGreaterThan(10_000); }, ); + + it("snapshots a silent turn's activity when the wall-clock timeout fires, before cancel events", async () => { + const root = await makeTempRoot(); + const cwd = path.join(root, "worktree"); + await fs.mkdir(cwd, { recursive: true }); + let currentNow = 1000; + let releaseTurn: (() => void) | null = null; + const turnCancelled = new Promise<void>((resolve) => { releaseTurn = resolve; }); + const execute = createAcpxEngineExecutor({ + now: () => currentNow, + createRuntime: () => ({ + ...buildRuntime(), + startTurn: () => ({ + events: (async function* () { + yield { type: "tool_call", toolCallId: "private-id", title: "private command", kind: "execute", status: "in_progress" }; + // The agent goes silent with the tool still pending. + currentNow = 9000; + await turnCancelled; + // The cancel makes the agent emit a closing event, so the + // finalize-time age alone would make this turn look active. + currentNow = 9500; + yield { type: "status", text: "private closing status" }; + })(), + result: turnCancelled.then(() => ({ status: "cancelled", stopReason: "cancelled" })), + cancel: async () => { releaseTurn?.(); }, + }), + }) as never, + }); + const result = await execute({ + runId: "silent-timeout", agent: { id: "agent-1", companyId: "company-1" }, runtime: {}, + config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state"), cwd, timeoutSec: 1 }, + context: {}, onMeta: async () => {}, onLog: async () => {}, + } as never); + expect(result.timedOut).toBe(true); + expect(result.resultJson).toMatchObject({ + acpObservedEventCount: 2, + acpLastEventAgeMs: 0, + acpPendingToolCount: 1, + acpLastEventAgeAtStopMs: 8000, + acpOldestPendingToolAgeAtStopMs: 8000, + acpPendingToolKinds: "execute", + }); + const diagnostics = Object.fromEntries(Object.entries(result.resultJson ?? {}).filter(([key]) => key.startsWith("acp"))); + expect(JSON.stringify(diagnostics)).not.toContain("private"); + }, 15_000); + + it("keeps the stalled tool's stop-time age when the cancel closes that tool", async () => { + const root = await makeTempRoot(); + const cwd = path.join(root, "worktree"); + await fs.mkdir(cwd, { recursive: true }); + let currentNow = 1000; + let releaseTurn: (() => void) | null = null; + const turnCancelled = new Promise<void>((resolve) => { releaseTurn = resolve; }); + const execute = createAcpxEngineExecutor({ + now: () => currentNow, + createRuntime: () => ({ + ...buildRuntime(), + startTurn: () => ({ + events: (async function* () { + yield { type: "tool_call", toolCallId: "private-id", title: "private command", kind: "execute", status: "in_progress" }; + currentNow = 9000; + await turnCancelled; + // The cancel closes the stalled tool before finalization runs. + currentNow = 9500; + yield { type: "tool_call", tag: "tool_call_update", toolCallId: "private-id", status: "cancelled" }; + })(), + result: turnCancelled.then(() => ({ status: "cancelled", stopReason: "cancelled" })), + cancel: async () => { releaseTurn?.(); }, + }), + }) as never, + }); + const result = await execute({ + runId: "cancel-closes-tool", agent: { id: "agent-1", companyId: "company-1" }, runtime: {}, + config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state"), cwd, timeoutSec: 1 }, + context: {}, onMeta: async () => {}, onLog: async () => {}, + } as never); + expect(result.timedOut).toBe(true); + expect(result.resultJson).toMatchObject({ + acpPendingToolCount: 0, + acpLastEventAgeAtStopMs: 8000, + acpOldestPendingToolAgeAtStopMs: 8000, + acpPendingToolKinds: "execute", + }); + const diagnostics = Object.fromEntries(Object.entries(result.resultJson ?? {}).filter(([key]) => key.startsWith("acp"))); + expect(JSON.stringify(diagnostics)).not.toContain("private"); + }, 15_000); +}); + +describe("summarizeAcpStopActivity", () => { + it("reports nothing for an unstopped turn with no pending tools", () => { + expect(summarizeAcpStopActivity(null, 1000, [])).toEqual({}); + }); + + it("reports pending kinds without a stop, and collapses unknown kinds", () => { + expect(summarizeAcpStopActivity(null, 1000, [ + { kind: "fetch", firstSeenAt: 10 }, { kind: "private_tool_name" }, {}, { kind: "fetch" }, + ])).toEqual({ acpPendingToolKinds: "fetch,unknown" }); + }); + + it("measures ages from the stop and ignores tools first seen after it", () => { + expect(summarizeAcpStopActivity(10_000, 4000, [ + { kind: "execute", firstSeenAt: 3000 }, { kind: "read", firstSeenAt: 12_000 }, + ])).toEqual({ + acpLastEventAgeAtStopMs: 6000, + acpOldestPendingToolAgeAtStopMs: 7000, + acpPendingToolKinds: "execute,read", + }); + }); + + it("omits the event age when no event preceded the stop", () => { + expect(summarizeAcpStopActivity(10_000, null, [])).toEqual({}); + }); }); describe("summarizeAcpxTurnUsage", () => { diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts index e84c6014991..01ae1020b8d 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.ts @@ -105,6 +105,7 @@ import { type AcpRuntimeUsageCost, } from "acpx/runtime"; import { + ACP_TOOL_KINDS, ACPX_DUPLEX_LOSS_CANCEL_DEADLINE_MS, ACPX_HANDSHAKE_TIMEOUT_MS, ACPX_HANDSHAKE_TRANSPORT_POLL_MS, @@ -3249,6 +3250,46 @@ async function emitRuntimeEvent( } } +function isPendingAcpTool(tool: { status?: string }): boolean { + return tool.status !== "completed" && tool.status !== "failed" && tool.status !== "cancelled"; +} + +/** + * Activity as of the moment the turn's stop fired, for a stopped turn only. + * Separates a turn that went silent long before its deadline from one that + * was still working. Reports only ages and closed ACP tool-kind labels. + */ +export function summarizeAcpStopActivity( + stopRequestedAt: number | null, + lastEventBeforeStopAt: number | null, + pendingTools: ReadonlyArray<{ kind?: string; firstSeenAt?: number }>, +): { acpLastEventAgeAtStopMs?: number; acpOldestPendingToolAgeAtStopMs?: number; acpPendingToolKinds?: string } { + const summary: ReturnType<typeof summarizeAcpStopActivity> = {}; + if (pendingTools.length > 0) { + const knownKinds: readonly string[] = ACP_TOOL_KINDS; + summary.acpPendingToolKinds = uniqueSorted( + pendingTools.map((tool) => (tool.kind && knownKinds.includes(tool.kind) ? tool.kind : "unknown")), + ).join(","); + } + if (stopRequestedAt === null) return summary; + const age = (since: number) => { + const value = stopRequestedAt - since; + return Number.isSafeInteger(value) && value >= 0 ? value : undefined; + }; + if (lastEventBeforeStopAt !== null) { + const value = age(lastEventBeforeStopAt); + if (value !== undefined) summary.acpLastEventAgeAtStopMs = value; + } + const pendingSince = pendingTools + .map((tool) => tool.firstSeenAt) + .filter((at): at is number => typeof at === "number" && at <= stopRequestedAt); + if (pendingSince.length > 0) { + const value = age(Math.min(...pendingSince)); + if (value !== undefined) summary.acpOldestPendingToolAgeAtStopMs = value; + } + return summary; +} + function resultErrorMessage(result: AcpRuntimeTurnResult): string | null { if (result.status !== "failed") return null; return result.error.message; @@ -4046,7 +4087,7 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { let runtimeStopConfirmed = false; let safeInterruptedSession = false; let preserveInterruptedSession = false; - const interruptionTools = new Map<string, { kind?: string; status?: string }>(); + const interruptionTools = new Map<string, { kind?: string; status?: string; firstSeenAt?: number }>(); let incompleteToolInventory = false; try { await ctx.onCancellationReady?.(); @@ -4759,6 +4800,14 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { let observedUsage: Pick<AdapterExecutionResult, "usage" | "usageBasis" | "costUsd" | "usageComplete"> = { usageComplete: false }; let lastRuntimeEventAt: number | null = null; let observedRuntimeEvents = 0; + // Activity as of the moment the turn's stop (wall-clock timeout or + // operator stop) fired. The cancel can make the agent emit closing + // events (including updates that close the stalled tool), so the + // finalize-time age and pending set alone cannot tell a silent stall + // from live work. + let turnStopRequestedAt: number | null = null; + let lastRuntimeEventBeforeStopAt: number | null = null; + let pendingToolsAtStop: Array<{ kind?: string; firstSeenAt?: number }> | null = null; let turnDispatchAttempted = false; // The turn-local state the sequence steps share. `promptBuild` sets the // prompt, `preTurnUsage` sets the pre-turn status, `turnStart` sets the @@ -4846,6 +4895,11 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { }; const stepTurnStart = (signal: AbortSignal, startTimeoutMs: number | undefined): StartedTurn => { ctx.signal?.throwIfAborted(); + signal.addEventListener("abort", () => { + if (turnStopRequestedAt !== null) return; + turnStopRequestedAt = now(); + pendingToolsAtStop = [...interruptionTools.values()].filter(isPendingAcpTool); + }, { once: true }); // After entering startTurn, a thrown error cannot prove the provider // received no work. Only failures before this boundary are free to settle. turnDispatchAttempted = true; @@ -4939,6 +4993,7 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { const drainEvents = (async (): Promise<void> => { for await (const event of turn.events) { lastRuntimeEventAt = now(); + if (turnStopRequestedAt === null) lastRuntimeEventBeforeStopAt = lastRuntimeEventAt; observedRuntimeEvents += 1; // ACPX currently flattens client-side filesystem/terminal receipts // into status text. They cannot establish complete action outcomes. @@ -4950,6 +5005,7 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { interruptionTools.set(event.toolCallId, { kind: event.kind ?? previous?.kind, status: event.status ?? previous?.status, + firstSeenAt: previous?.firstSeenAt ?? lastRuntimeEventAt, }); } } @@ -5004,14 +5060,14 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { // Capture runtime activity before usage reads, error logs, or cleanup // make an otherwise silent turn look active at its execution deadline. const lastEventAgeMs = lastRuntimeEventAt === null ? undefined : now() - lastRuntimeEventAt; + const pendingTools = [...interruptionTools.values()].filter(isPendingAcpTool); const activityDiagnostics = { acpObservedEventCount: observedRuntimeEvents, ...(lastEventAgeMs !== undefined && Number.isSafeInteger(lastEventAgeMs) && lastEventAgeMs >= 0 ? { acpLastEventAgeMs: lastEventAgeMs } : {}), - acpPendingToolCount: [...interruptionTools.values()].filter( - (tool) => tool.status !== "completed" && tool.status !== "failed" && tool.status !== "cancelled", - ).length, + acpPendingToolCount: pendingTools.length, acpToolInventoryComplete: !incompleteToolInventory, + ...summarizeAcpStopActivity(turnStopRequestedAt, lastRuntimeEventBeforeStopAt, pendingToolsAtStop ?? pendingTools), }; if (input.kind === "terminal") { const terminal = input.terminal; diff --git a/packages/adapter-utils/src/ssh-command-managed.test.ts b/packages/adapter-utils/src/ssh-command-managed.test.ts new file mode 100644 index 00000000000..12d7c7f7788 --- /dev/null +++ b/packages/adapter-utils/src/ssh-command-managed.test.ts @@ -0,0 +1,71 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal<typeof import("node:child_process")>(); + // Execute the authored remote command locally, through both subprocess paths. + return { + ...actual, + execFile: vi.fn((file: string, args: string[], options: object, callback: Parameters<typeof actual.execFile>[3]) => { + if (file !== "ssh") throw new Error(`Unexpected subprocess ${file}`); + return actual.execFile("sh", ["-c", args.at(-1)!], options, callback); + }), + spawn: vi.fn((file: string, args: string[], options: Parameters<typeof actual.spawn>[2]) => { + if (file !== "ssh") throw new Error(`Unexpected subprocess ${file}`); + return actual.spawn("sh", ["-c", args.at(-1)!], options); + }), + }; +}); + +import { createSshCommandManagedRuntimeRunner } from "./ssh.js"; + +const roots: string[] = []; +afterEach(async () => { + for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }); +}); + +async function runner() { + const cwd = await mkdtemp(path.join(os.tmpdir(), "ssh-managed-argv-")); + roots.push(cwd); + return createSshCommandManagedRuntimeRunner({ + spec: { + host: "fixture.invalid", username: "fixture", port: 22, + remoteCwd: cwd, remoteWorkspacePath: cwd, + privateKey: null, knownHosts: null, strictHostKeyChecking: true, + }, + }); +} + +describe("SSH command-managed argv", () => { + it("rejects environment keys that could be interpreted as env options", async () => { + await expect((await runner()).execute({ command: "true", env: { "-u": "PATH" } })) + .rejects.toThrow("Invalid SSH environment variable key"); + }); + + it.each([[false, false], [true, false], [false, true], [true, true]])( + "preserves shell positional arguments with environment=%s and stdin=%s", + async (withEnvironment, withStdin) => { + const value = "literal 'quote' $(printf unsafe) `printf unsafe`"; + const result = await (await runner()).execute({ + command: "sh", + args: ["-c", 'set -eu; printf "%s\\n" "$0" "$1" "$2" "${SENTINEL_TEXT-unset}"', "named-probe", "two words", value], + ...(withEnvironment ? { env: { SENTINEL_TEXT: value } } : {}), + ...(withStdin ? { stdin: "" } : {}), + timeoutMs: 5000, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(["named-probe", "two words", value, withEnvironment ? value : "unset"].join("\n") + "\n"); + }, + ); + + it("applies environment to a non-shell command", async () => { + const result = await (await runner()).execute({ + command: "printenv", args: ["SENTINEL_TEXT"], + env: { SENTINEL_TEXT: "two words" }, timeoutMs: 5000, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("two words\n"); + }); +}); diff --git a/packages/adapter-utils/src/ssh.ts b/packages/adapter-utils/src/ssh.ts index 68e61fc6390..342ca553bd1 100644 --- a/packages/adapter-utils/src/ssh.ts +++ b/packages/adapter-utils/src/ssh.ts @@ -60,17 +60,17 @@ export function createSshCommandManagedRuntimeRunner(input: { const cwd = commandInput.cwd?.trim() || defaultCwd; const envEntries = Object.entries(commandInput.env ?? {}) .filter((entry): entry is [string, string] => typeof entry[1] === "string"); + for (const [key] of envEntries) { + if (!isValidShellEnvKey(key)) { + throw new Error(`Invalid SSH environment variable key: ${key}`); + } + } const envPrefix = envEntries.length > 0 - ? `env ${envEntries.map(([key, value]) => `${key}=${shellQuote(value)}`).join(" ")} ` + ? `env ${envEntries.map(([key, value]) => shellQuote(`${key}=${value}`)).join(" ")} ` : ""; - const exportPrefix = envEntries.length > 0 - ? envEntries.map(([key, value]) => `export ${key}=${shellQuote(value)};`).join(" ") + " " - : ""; - const commandScript = command === "sh" || command === "bash" - ? (args[0] === "-c" || args[0] === "-lc") && typeof args[1] === "string" - ? `${exportPrefix}${args[1]}` - : `${envPrefix}exec ${[shellQuote(command), ...args.map((arg) => shellQuote(arg))].join(" ")}` - : `${envPrefix}exec ${[shellQuote(command), ...args.map((arg) => shellQuote(arg))].join(" ")}`; + // Keep sh -c's command name and positional arguments intact. Flattening + // its script loses $0/$1/... and breaks native runner launch/identity. + const commandScript = `exec ${envPrefix}${[shellQuote(command), ...args.map((arg) => shellQuote(arg))].join(" ")}`; const remoteCommand = `cd ${shellQuote(cwd)} && ${commandScript}`; try { diff --git a/packages/adapters/codex-local/src/index.ts b/packages/adapters/codex-local/src/index.ts index c9d54904d7d..38640d46f49 100644 --- a/packages/adapters/codex-local/src/index.ts +++ b/packages/adapters/codex-local/src/index.ts @@ -1,6 +1,7 @@ import { PAPERCLIP_RUNNER_DEFAULT_MODELS, } from "@paperclipai/adapter-utils"; +import { PAPERCLIP_RUNNER_MINIMUM_GLIBC_VERSION } from "@paperclipai/shared"; export const type = "codex_local"; export const label = "Codex"; @@ -212,7 +213,9 @@ export const models = [ export const agentConfigurationDoc = `# codex_local agent configuration -New agents select this Codex harness with adapterType: codex_local. On qualified Linux x64 targets, automatic selection stores adapterType: paperclip_runner with provider: codex. macOS, Windows, and Linux ARM retain legacy defaults until their runner artifacts are qualified. Other harness defaults are unchanged. Set the request-level runner: legacy to use this adapter's execution and custom settings. Existing agents and reviewed pending hires retain their saved runner; runtime errors never silently switch runners. +New agents select the Codex harness with adapterType: codex_local and an optional request-level runner: auto, paperclip, or legacy. Automatic selection uses Paperclip Runner on qualified Linux x64/ARM64 targets with GNU libc ${PAPERCLIP_RUNNER_MINIMUM_GLIBC_VERSION} or newer and macOS arm64/x64 targets. It stores adapterType: paperclip_runner with provider: codex. Windows, 32-bit ARM, Alpine/musl, and older or unknown Linux libc retain automatic legacy selection. Qualification and artifact checks use the selected execution environment, including its architecture, rather than the controller's platform. Other harness defaults are unchanged. + +Set runner: legacy to retain this adapter's execution and custom settings. Explicit runner: paperclip requires a qualified target. Existing agents and reviewed pending hires retain their saved runner; authentication, model, dependency, and runtime errors never silently switch runners. The fields below describe legacy codex_local execution. Native creation translates supported settings and rejects unsupported settings with the explicit Legacy runner option. Adapter: codex_local diff --git a/scripts/check-maps.mjs b/scripts/check-maps.mjs new file mode 100644 index 00000000000..9aef27c5d32 --- /dev/null +++ b/scripts/check-maps.mjs @@ -0,0 +1,131 @@ +#!/usr/bin/env node +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptPath = fileURLToPath(import.meta.url); +const defaultRoot = path.resolve(path.dirname(scriptPath), ".."); +const fields = ["API", "Agent access", "Policy", "Evidence", "Verification", "Gaps"]; +const values = { + Review: ["baseline", "operation-reviewed"], + API: ["present", "missing", "not-applicable", "unreviewed"], + "Agent access": ["available", "partial", "missing", "restricted", "operator-only", "not-applicable", "unreviewed"], + Evidence: ["source-only", "verified", "failed", "blocked", "not-run"], + Verification: ["passed", "failed", "blocked", "not-run"], +}; + +function withoutExamples(text) { + return text.replace(/^(`{3,}|~{3,})[^\n]*\n[\s\S]*?^\1\s*$/gm, ""); +} + +function sections(text, level) { + const pattern = new RegExp("^" + "#".repeat(level) + " `([^`]+)`[^\\n]*$", "gm"); + const matches = [...text.matchAll(pattern)]; + return matches.map((match, index) => ({ + id: match[1], + body: text.slice(match.index + match[0].length, matches[index + 1]?.index ?? text.length), + })); +} + +export function checkMaps(repoRoot = defaultRoot) { + const errors = []; + const featureRoot = path.join(repoRoot, "maps/feature-map"); + const agentRoot = path.join(repoRoot, "maps/agent-access-map"); + const fail = (file, message) => errors.push(`${path.relative(repoRoot, file)}: ${message}`); + function read(file) { + if (!existsSync(file)) { fail(file, "missing file"); return ""; } + return withoutExamples(readFileSync(file, "utf8")); + } + function files(dir) { + if (!existsSync(dir)) { fail(dir, "missing map directory"); return []; } + return readdirSync(dir).filter((name) => name.endsWith(".md") && name !== "README.md").sort(); + } + function unique(ids, file, label) { + const seen = new Set(); + for (const id of ids) { + if (seen.has(id)) fail(file, `duplicate ${label}: ${id}`); + seen.add(id); + } + } + function links(file, text) { + for (const match of text.matchAll(/\]\(([^)\n]+)\)/g)) { + const target = match[1].replace(/^<|>$/g, ""); + if (/^(?:[a-z][a-z\d+.-]*:|#)/i.test(target)) continue; + if (target.startsWith("/")) { fail(file, `use a relative map reference: ${target}`); continue; } + let local; + try { local = decodeURIComponent(target.split(/[?#]/, 1)[0]); } + catch { fail(file, `invalid local reference: ${target}`); continue; } + if (!existsSync(path.resolve(path.dirname(file), local))) fail(file, `broken local reference: ${target}`); + } + } + function field(file, body, name) { + const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = body.match(new RegExp(`^${escaped}:[ \\t]*(\\S[^\\n]*)$`, "m")); + if (!match) { fail(file, `missing ${name} field`); return null; } + if (!values[name]) return null; + const state = match[1].match(/^`([^`]+)`/); + if (!state || !values[name].includes(state[1])) { fail(file, `invalid ${name} state`); return null; } + if (["not-applicable", "restricted", "operator-only"].includes(state[1]) && !match[1].slice(state[0].length).replace(/[\s—–:.-]/g, "")) { + fail(file, `${name} ${state[1]} needs a reason or path`); + } + return state[1]; + } + function accessRecord(file, body) { + const states = Object.fromEntries(fields.map((name) => [name, field(file, body, name)])); + if (states.Evidence === "verified" && states.Verification !== "passed") { + fail(file, "verified evidence requires a passed verification record"); + } + const api = body.split(/^API:/m)[1]?.split(/^(?:Agent access|Policy|Evidence|Verification|Gaps):/m)[0] ?? ""; + if (states.API === "present" && !/\b(?:GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS) \/[^\s`]+/.test(api)) { + fail(file, "present API needs an exact method/path example"); + } + } + + const productFiles = files(featureRoot); + const agentFiles = files(agentRoot); + const indexes = [read(path.join(featureRoot, "README.md")), read(path.join(agentRoot, "README.md"))]; + let capabilities = 0; + for (const name of new Set([...productFiles, ...agentFiles])) { + const productFile = path.join(featureRoot, name); + const agentFile = path.join(agentRoot, name); + const product = read(productFile); + const agent = read(agentFile); + const subfeatures = product.split(/^## Sub-features\s*$/m)[1]?.split(/^## /m)[0] ?? ""; + const ids = [...subfeatures.matchAll(/^- `([^`]+)`:/gm)].map((match) => match[1]); + if (!ids.length) fail(productFile, "missing stable sub-feature IDs"); + unique(ids, productFile, "capability"); + const entries = sections(agent, 2); + unique(entries.map((entry) => entry.id), agentFile, "capability"); + for (const id of ids) if (!entries.some((entry) => entry.id === id)) fail(agentFile, `missing capability: ${id}`); + for (const entry of entries) { + if (!ids.includes(entry.id)) fail(agentFile, `orphan capability: ${entry.id}`); + const review = field(agentFile, entry.body, "Review"); + if (review === "operation-reviewed") { + const operations = sections(entry.body, 3); + if (!operations.length) fail(agentFile, `operation-reviewed capability ${entry.id} has no operations`); + unique(operations.map((operation) => operation.id), agentFile, "operation"); + for (const operation of operations) accessRecord(agentFile, operation.body); + } else accessRecord(agentFile, entry.body); + } + if (!agent.includes(`](../feature-map/${name})`)) fail(agentFile, "missing matching product link"); + if (!product.includes(`](../agent-access-map/${name})`)) fail(productFile, "missing matching companion link"); + for (const [index, text] of indexes.entries()) { + if (!new RegExp(`\\]\\((?:\\./)?${name.replaceAll(".", "\\.")}\\)`).test(text)) { + fail(path.join(index === 0 ? featureRoot : agentRoot, "README.md"), `missing area: ${name}`); + } + } + if (!/^Review date: \d{4}-\d{2}-\d{2}\. Source commit: `[a-f\d]{40}`\./m.test(agent)) fail(agentFile, "missing review date/source commit"); + links(productFile, product); + links(agentFile, agent); + capabilities += ids.length; + } + for (const file of [path.join(repoRoot, "maps/README.md"), path.join(featureRoot, "README.md"), path.join(agentRoot, "README.md")]) links(file, read(file)); + return { errors, areas: productFiles.length, capabilities }; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === scriptPath) { + const result = checkMaps(); + for (const error of result.errors) console.error(error); + if (result.errors.length) process.exitCode = 1; + else console.log(`Maps aligned: ${result.areas} areas, ${result.capabilities} capability IDs; references and contract fields valid. Structural checks only.`); +} diff --git a/scripts/check-maps.test.mjs b/scripts/check-maps.test.mjs new file mode 100644 index 00000000000..aae6e43aff8 --- /dev/null +++ b/scripts/check-maps.test.mjs @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { checkMaps } from "./check-maps.mjs"; + +const record = `API: \`present\` — POST /api/example. +Agent access: \`partial\` — agent REST; native binding missing. +Policy: same company and calling owner. +Evidence: \`source-only\` — implementation inspected. +Verification: \`not-run\` — assert permitted owner and denied other company. +Gaps: native mutation path. +`; + +function fixture(t) { + const root = mkdtempSync(path.join(os.tmpdir(), "paperclip-map-contract-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + for (const dir of ["maps/feature-map", "maps/agent-access-map"]) mkdirSync(path.join(root, dir), { recursive: true }); + const product = path.join(root, "maps/feature-map/example.md"); + const agent = path.join(root, "maps/agent-access-map/example.md"); + writeFileSync(path.join(root, "maps/README.md"), "# Contract\n"); + for (const dir of ["feature-map", "agent-access-map"]) writeFileSync(path.join(root, "maps", dir, "README.md"), "[Example](example.md)\n"); + writeFileSync(product, "# Example\n\n[Agent access](../agent-access-map/example.md)\n\n## Sub-features\n\n- `create`: create a record.\n\n## How to get to it (user POV)\n"); + writeFileSync(agent, `# Agent access\n\nProduct map: [Example](../feature-map/example.md).\n\nReview date: 2026-10-12. Source commit: \`${"a".repeat(40)}\`.\n\n## \`create\`\n\nReview: \`baseline\`.\n\n${record}`); + return { root, product, agent }; +} + +test("paired source-only records pass without being promoted to live proof", (t) => { + const { root } = fixture(t); + assert.deepEqual(checkMaps(root), { errors: [], areas: 1, capabilities: 1 }); +}); + +test("a product area cannot lose its companion", (t) => { + const { root, agent } = fixture(t); + rmSync(agent); + assert.ok(checkMaps(root).errors.some((error) => error.includes("missing file"))); +}); + +test("renaming a capability on one side reports missing and orphan IDs", (t) => { + const { root, agent } = fixture(t); + writeFileSync(agent, readFileSync(agent, "utf8").replace("## `create`", "## `update`")); + const result = checkMaps(root); + assert.ok(result.errors.some((error) => error.includes("missing capability: create"))); + assert.ok(result.errors.some((error) => error.includes("orphan capability: update"))); +}); + +test("duplicate IDs cannot disguise the missing capability", (t) => { + const { root, agent } = fixture(t); + writeFileSync(agent, readFileSync(agent, "utf8") + `\n## \`create\`\n\nReview: \`baseline\`.\n\n${record}`); + assert.ok(checkMaps(root).errors.some((error) => error.includes("duplicate capability: create"))); +}); + +test("a moved or deleted source reference is reported", (t) => { + const { root, agent } = fixture(t); + writeFileSync(agent, readFileSync(agent, "utf8") + "\n[Source](../../server/deleted.ts#L12)\n"); + assert.ok(checkMaps(root).errors.some((error) => error.includes("broken local reference"))); +}); + +test("every reviewed operation needs its own access fields", (t) => { + const { root, agent } = fixture(t); + const text = readFileSync(agent, "utf8").replace("Review: `baseline`.", "Review: `operation-reviewed`.\n\n### `create`"); + writeFileSync(agent, text + `\n### \`update\`\n\n${record.replace(/^API:.*\n/m, "")}`); + assert.ok(checkMaps(root).errors.some((error) => error.includes("missing API field"))); +}); + +test("verified evidence cannot coexist with verification not run", (t) => { + const { root, agent } = fixture(t); + writeFileSync(agent, readFileSync(agent, "utf8").replace("Evidence: `source-only`", "Evidence: `verified`")); + assert.ok(checkMaps(root).errors.some((error) => error.includes("verified evidence requires a passed verification record"))); +}); + +test("an exception needs its reason rather than an empty exemption label", (t) => { + const { root, agent } = fixture(t); + writeFileSync(agent, readFileSync(agent, "utf8").replace(/^API:.*$/m, "API: `not-applicable`.")); + assert.ok(checkMaps(root).errors.some((error) => error.includes("not-applicable needs a reason"))); +}); + +test("API presence needs a concrete method and path", (t) => { + const { root, agent } = fixture(t); + writeFileSync(agent, readFileSync(agent, "utf8").replace("POST /api/example", "see the backend")); + assert.ok(checkMaps(root).errors.some((error) => error.includes("present API needs an exact method/path"))); +}); + +test("an empty policy cannot borrow the next field's content", (t) => { + const { root, agent } = fixture(t); + writeFileSync(agent, readFileSync(agent, "utf8").replace(/^Policy:.*$/m, "Policy:\n")); + assert.ok(checkMaps(root).errors.some((error) => error.includes("missing Policy field"))); +}); diff --git a/server/src/__tests__/agent-adapter-validation-routes.test.ts b/server/src/__tests__/agent-adapter-validation-routes.test.ts index 8a8b9387cbc..51fdc63bc6b 100644 --- a/server/src/__tests__/agent-adapter-validation-routes.test.ts +++ b/server/src/__tests__/agent-adapter-validation-routes.test.ts @@ -657,6 +657,78 @@ describe("agent routes adapter validation", () => { expect(res.status, JSON.stringify(res.body)).toBe(200); }); + it.each(["pass", "fail"] as const)("tests saved legacy Codex on a disabled adapter and retains its %s runtime verdict", async (status) => { + const adapterConfig = { model: "gpt-5.4", extraArgs: ["--legacy-setting"], env: { CODEX_HOME: "/saved-codex-home" } }; + mockAgentService.getById.mockResolvedValue({ ...(await mockAgentService.getById()), adapterConfig }); + mockAdapterPluginStore.getDisabledAdapterTypes.mockReturnValue(["codex_local"]); + const probe = vi.fn(async () => ({ adapterType: "codex_local", status, testedAt: new Date(0).toISOString(), + checks: status === "fail" ? [{ code: "adapter_auth_missing", level: "error" as const, message: "Sign in to the saved Codex account." }] : [] })); + const { registerServerAdapter } = await import("../adapters/index.js"); + registerServerAdapter({ ...externalAdapter, type: "codex_local", testEnvironment: probe }); + try { + const app = await createApp(); + const res = await requestApp(app, baseUrl => request(baseUrl) + .post("/api/companies/company-1/adapters/codex_local/test-environment") + .send({ agentId: "11111111-1111-4111-8111-111111111111", adapterConfig: { + ...adapterConfig, env: { CODEX_HOME: { type: "plain", value: "***REDACTED***" } }, + } })); + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body).toMatchObject({ adapterType: "codex_local", status }); + expect(probe).toHaveBeenCalledOnce(); + expect(probe).toHaveBeenCalledWith(expect.objectContaining({ adapterType: "codex_local", config: adapterConfig })); + expect(mockSecretService.normalizeAdapterConfigForPersistence).toHaveBeenCalledWith("company-1", adapterConfig, expect.objectContaining({ adapterType: "codex_local" })); + expect(mockAgentService.create).not.toHaveBeenCalled(); + expect(mockAgentService.update).not.toHaveBeenCalled(); + } finally { await unregisterTestAdapter("codex_local"); } + }); + + it.each(["draft", "explicit legacy", "explicit auto", "explicit native", "changed harness"] as const)("rejects a disabled Codex setup selection for %s", async (mode) => { + mockAdapterPluginStore.getDisabledAdapterTypes.mockReturnValue(["codex_local"]); + if (mode === "changed harness") mockAgentService.getById.mockResolvedValue({ ...(await mockAgentService.getById()), adapterType: "external_test" }); + const probe = vi.fn(); + const { registerServerAdapter } = await import("../adapters/index.js"); + registerServerAdapter({ ...externalAdapter, type: "codex_local", testEnvironment: probe }); + try { + const runner = mode === "explicit legacy" ? "legacy" : mode === "explicit auto" ? "auto" : mode === "explicit native" ? "paperclip" : undefined; + const app = await createApp(); + const res = await requestApp(app, baseUrl => request(baseUrl) + .post("/api/companies/company-1/adapters/codex_local/test-environment") + .send({ adapterConfig: {}, ...(mode === "draft" ? {} : { agentId: "11111111-1111-4111-8111-111111111111" }), ...(runner ? { runner } : {}) })); + expect(res.status, JSON.stringify(res.body)).toBe(422); + expect(res.body.details).toMatchObject({ code: "agent_runner_unavailable" }); + expect(probe).not.toHaveBeenCalled(); + expect(mockSecretService.normalizeAdapterConfigForPersistence).not.toHaveBeenCalled(); + } finally { await unregisterTestAdapter("codex_local"); } + }); + + it("still rejects new Codex creation when its adapter is disabled", async () => { + mockAdapterPluginStore.getDisabledAdapterTypes.mockReturnValue(["codex_local"]); + const app = await createApp(); + const res = await requestApp(app, baseUrl => request(baseUrl) + .post("/api/companies/company-1/agents").send({ name: "New Codex", adapterType: "codex_local" })); + expect(res.status, JSON.stringify(res.body)).toBe(422); + expect(res.body.details).toMatchObject({ code: "agent_runner_unavailable" }); + expect(mockAgentService.create).not.toHaveBeenCalled(); + }); + + it("rechecks selection policy when a saved native adapter switches its underlying harness", async () => { + mockAgentService.getById.mockResolvedValue({ ...(await mockAgentService.getById()), adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } }); + mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: false }); + const probe = vi.fn(); + const { registerServerAdapter } = await import("../adapters/index.js"); + registerServerAdapter({ ...externalAdapter, type: "paperclip_runner", testEnvironment: probe }); + try { + const app = await createApp(); + const res = await requestApp(app, baseUrl => request(baseUrl) + .post("/api/companies/company-1/adapters/paperclip_runner/test-environment") + .send({ agentId: "11111111-1111-4111-8111-111111111111", adapterConfig: { provider: "acpx", acpxAgent: "claude" } })); + expect(res.status, JSON.stringify(res.body)).toBe(422); + expect(res.body.details).toMatchObject({ code: "paperclip_runner_rollout_disabled" }); + expect(probe).not.toHaveBeenCalled(); + expect(mockSecretService.normalizeAdapterConfigForPersistence).not.toHaveBeenCalled(); + } finally { await unregisterTestAdapter("paperclip_runner"); } + }); + it("still creates an agent on an adapter that is registered and enabled", async () => { const { registerServerAdapter } = await import("../adapters/index.js"); registerServerAdapter(externalAdapter); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 06d60e3f2cd..86d56da756e 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -3142,15 +3142,21 @@ export function agentRoutes( ? restoreRedactedAgentEnv(inputAdapterConfig, savedAgent.adapterConfig) : inputAdapterConfig; } - const selection = await resolveNewAgentRunnerForCompany(db, companyId, { - adapterType: type, adapterConfig: adapterConfigForTest, - runner: req.body.runner ?? (savedAgent && savedAgent.adapterType === type ? (type === "paperclip_runner" ? undefined : "legacy") : undefined), - defaultEnvironmentId: requestedEnvironmentId, - }); + const retainsSavedExecution = savedAgent && req.body.runner === undefined + && savedAgent.adapterType === type + && agentHarnessType(type, adapterConfigForTest) === agentHarnessType(savedAgent.adapterType, savedAgent.adapterConfig); + // Preserve saved execution; the adapter probe below still validates runtime readiness. + const selection = retainsSavedExecution + ? { adapterType: type, adapterConfig: adapterConfigForTest } + : await resolveNewAgentRunnerForCompany(db, companyId, { + adapterType: type, adapterConfig: adapterConfigForTest, + runner: req.body.runner, + defaultEnvironmentId: requestedEnvironmentId, + }); type = selection.adapterType; adapterConfigForTest = selection.adapterConfig; const adapter = requireServerAdapter(type); - if (!savedAgent || req.body.runner !== undefined || type !== savedAgent.adapterType) await assertSelectableAdapterType(type, adapterConfigForTest); + if (!retainsSavedExecution) await assertSelectableAdapterType(type, adapterConfigForTest); if (poolBinding?.mode === "router") { await validateManagedAgentBinding(req, companyId, savedAgentId ?? "", type, adapterConfigForTest, poolBinding, requestedEnvironmentId, false, !savedAgentId); res.json({ adapterType: type, status: "warn", testedAt: new Date().toISOString(), checks: [{ code: "ai_connection_pool_task_test_required", level: "warn", message: "Pool configuration is available. Run a task to verify the selected account and harness; this preview does not allocate a task or contact a provider." }] }); diff --git a/server/src/services/__tests__/run-failure-diagnostics.test.ts b/server/src/services/__tests__/run-failure-diagnostics.test.ts index 731c569e0e9..e75e043311c 100644 --- a/server/src/services/__tests__/run-failure-diagnostics.test.ts +++ b/server/src/services/__tests__/run-failure-diagnostics.test.ts @@ -323,11 +323,29 @@ describe("run failure diagnostics", () => { expect(JSON.stringify(result)).not.toContain("private"); }); + it("includes stop-time ACP activity and closed pending tool-kind labels", () => { + const result = sanitizeRunFailureDiagnostics(collectRunFailureDiagnostics(run({ resultJson: { + acpLastEventAgeMs: 700, acpLastEventAgeAtStopMs: 14_000_000, acpOldestPendingToolAgeAtStopMs: 14_100_000, + acpPendingToolKinds: "execute,unknown", + } }), {})); + expect(result.execution).toEqual({ + acpLastEventAgeMs: 700, acpLastEventAgeAtStopMs: 14_000_000, acpOldestPendingToolAgeAtStopMs: 14_100_000, + acpPendingToolKinds: "execute,unknown", + }); + }); + + it.each(["execute,private command", "", "execute,", "Execute", ["execute"], 1])( + "omits pending tool kinds that are not closed labels (%j)", (value) => { + const result = collectRunFailureDiagnostics(run({ resultJson: { acpPendingToolKinds: value } }), {}); + expect(result.execution).toEqual({}); + }, + ); + it.each([null, -1, Infinity, NaN, 1.5, Number.MAX_SAFE_INTEGER + 1, "private", {}])( "omits invalid ACP activity fields (%j)", (value) => { const result = collectRunFailureDiagnostics(run({ resultJson: { acpLastEventAgeMs: value, acpObservedEventCount: value, acpPendingToolCount: value, - acpToolInventoryComplete: value, + acpToolInventoryComplete: value, acpLastEventAgeAtStopMs: value, acpOldestPendingToolAgeAtStopMs: value, } }), {}); expect(result.execution).toEqual({}); }, diff --git a/server/src/services/chat-channels.ts b/server/src/services/chat-channels.ts index 0cfbfa5fdbf..31338f7acc1 100644 --- a/server/src/services/chat-channels.ts +++ b/server/src/services/chat-channels.ts @@ -1,10 +1,15 @@ +import { createTeamsFileAuthority } from "./chat-channels/microsoft-teams/file-transfer-authority.js"; +import { createPhotonEvents } from "./chat-channels/imessage-photon/events.js"; +import { createTelegramPublicationDrafts } from "./chat-channels/telegram/publication-drafts.js"; +import { createSlackFileReceipts } from "./chat-channels/slack/file-receipts.js"; +import { createSlackSessionStatus } from "./chat-channels/slack/session-status.js"; import { createDiscordCommands } from "./chat-channels/discord/commands.js"; import { createSlackTaskStart, slackSlashTaskRecoveryPayload, slackTaskStarterThreadId } from "./chat-channels/slack/task-start.js"; import { createGitHubWebhookIngress, GITHUB_RECOVERY_STATE_KEY, type GitHubIngressStageResult } from "./chat-channels/github/webhook-ingress.js"; import { createSlackSessionStop } from "./chat-channels/slack/session-stop.js"; import { createTelegramMaintenance } from "./chat-channels/telegram/maintenance.js"; import type { EndpointRow, DbTransaction, DbOrTransaction, RuntimeContext, LifecycleRuntimeFence, InboundRuntimeContext } from "./chat-channels/runtime-types.js"; -import { createChatFailedRunRetry, type ChatFailedRetrySource as FailedChatRetrySource } from "./chat-channels/failed-run-retry.js"; +import { createChatFailedRunRetry } from "./chat-channels/failed-run-retry.js"; import { createChatPublicationReplacement } from "./chat-channels/publication-replacement.js"; import { createChatIdentityLinks } from "./chat-channels/identity-links.js"; import { createChatResourceReconciliation } from "./chat-channels/resource-reconciliation.js"; @@ -107,16 +112,16 @@ import { runtimeCanonicalOrigin } from "./cloud-runtime-identity.js"; import { takePhotonCompanion } from "./photon/attachments.js"; import { writePhotonCheckpoint } from "./photon/receiver.js"; import { PhotonState } from "./photon/state.js"; -import { nativeSha256 } from "./native-runtime/canonical.js"; + import { HEIF_CONTENT_TYPES, photonHeifPreview, validatePhotonImage } from "./photon/media.js"; import { projectSafeChatPublicationText } from "./chat-publication-projection.js"; -import { PhotonAnswerValidationError, nativePhotonInteraction, publishPhotonPrompt, photonResponseCommand, parsePhotonQuestionAnswer, type PhotonPromptReceipt, type PhotonInteractionBinding, type PhotonDraft } from "./photon/interactions.js"; -import { validateNativeQuestionResponseInput } from "./native-runtime/native-question-bridge.js"; -import type { AskUserQuestionsAnswer, AskUserQuestionsInteraction, IssueThreadInteraction } from "@paperclipai/shared"; -import { PhotonCloudClient, PhotonError, photonFailure, photonSharedIdentity, photonSharedScope } from "./photon/cloud.js"; -import { PhotonChatAdapter, photonThreadId, photonReplyReference } from "./photon/adapter.js"; +import { nativePhotonInteraction, publishPhotonPrompt, type PhotonPromptReceipt, type PhotonInteractionBinding } from "./photon/interactions.js"; + + +import { PhotonCloudClient, PhotonError, photonSharedIdentity, photonSharedScope } from "./photon/cloud.js"; +import { PhotonChatAdapter, photonReplyReference } from "./photon/adapter.js"; import { photonChannelConfigurationSchema } from "@paperclipai/shared"; -import type { LiveEvent as PhotonEvent } from "@photon-ai/advanced-imessage"; + import { defaultSlackAppConfiguration } from "@paperclipai/shared"; import { createHash, @@ -221,9 +226,7 @@ import { } from "../attachment-types.js"; import { isUniqueViolation } from "../db-errors.js"; import { - bindTeamsPersonalRecipient, deriveTeamsPersonalRecipient, - parseTeamsPersonalRecipient, } from "./chat-teams-personal-recipient.js"; import { teamsFileTransferService, @@ -253,15 +256,8 @@ import { } from "./chat-telegram-media-intake.js"; import { normalizeTelegramRichMessage } from "./chat-telegram-rich-intake.js"; import { - TELEGRAM_DRAFT_ACTION_KIND, - TELEGRAM_DRAFT_STOPPED_REASON, isTelegramDraftStopped, - parseTelegramDraftBinding, - telegramDraftTextSha256, - telegramGenerationStoppedReceipt, - telegramPrivateDraftDestination, type TelegramDraftControl, - type TelegramGenerationStoppedProof, } from "./chat-telegram-draft-stop.js"; @@ -354,11 +350,8 @@ import { } from "./chat-discord-question-forms.js"; import { parseSlackSessionStop, - setSlackSessionStatus, - slackSessionStatusForPublication, } from "./chat-slack-sessions.js"; import { - slackSessionSyncPayload, stageSlackSessionSync, } from "./chat-slack-session-outbox.js"; import { @@ -859,9 +852,6 @@ const REACTION_LINK_MAX_AGE_MS = 2 * 60_000; const DISCORD_ROOT_THREAD_CONFIRMATION_DELAY_MS = 5 * 60_000; const DELIVERY_LEASE_TTL_MS = 90_000; const DELIVERY_DRAIN_LIMIT = 100; -const SLACK_FILE_RECEIPT_MAX_AGE_MS = 24 * 60 * 60_000; -const SLACK_FILE_RECEIPT_MAX_ATTEMPTS = 12; -const SLACK_FILE_RECEIPT_STALE_MS = 60_000; const ORPHAN_FOLLOW_UP_GRACE_MS = 5_000; const ORPHAN_FOLLOW_UP_MAX_ATTEMPTS = 12; const PUBLICATION_ENDPOINT_CONCURRENCY = 4; @@ -1082,81 +1072,6 @@ type ProviderEffectPayload = { credentialFingerprint: string; }; -type SlackFileUploadReceiptPayload = { - version: 1; - publicationId: string; - publicationAttempt: number; - threadId: string; - fileIds: string[]; - botExternalId: string; - runtimeGeneration: number; - credentialFingerprint: string; -}; - -function slackFileUploadReceiptPayload( - payload: Record<string, unknown>, -): SlackFileUploadReceiptPayload | null { - if ( - payload.version !== 1 || - typeof payload.publicationId !== "string" || - !isUuidLike(payload.publicationId) || - typeof payload.publicationAttempt !== "number" || - !Number.isSafeInteger(payload.publicationAttempt) || - payload.publicationAttempt < 1 || - typeof payload.threadId !== "string" || - payload.threadId.length < 1 || - payload.threadId.length > 2_048 || - !payload.threadId.startsWith("slack:") || - typeof payload.botExternalId !== "string" || - payload.botExternalId.length < 1 || - payload.botExternalId.length > 255 || - typeof payload.runtimeGeneration !== "number" || - !Number.isSafeInteger(payload.runtimeGeneration) || - payload.runtimeGeneration < 0 || - typeof payload.credentialFingerprint !== "string" || - !/^[a-f0-9]{64}$/.test(payload.credentialFingerprint) || - !Array.isArray(payload.fileIds) || - payload.fileIds.length < 1 || - payload.fileIds.length > 20 || - payload.fileIds.some( - (fileId) => - typeof fileId !== "string" || !/^F[A-Z0-9]{1,254}$/.test(fileId), - ) || - new Set(payload.fileIds).size !== payload.fileIds.length - ) { - return null; - } - return payload as SlackFileUploadReceiptPayload; -} - -function operatorConfirmedSlackFileReceipt( - result: Record<string, unknown> | null, - payload: SlackFileUploadReceiptPayload, -): boolean { - return ( - result?.operatorConfirmedPublicationId === payload.publicationId && - result.operatorConfirmedAttempt === payload.publicationAttempt && - typeof result.operatorConfirmedAt === "string" && - Number.isFinite(Date.parse(result.operatorConfirmedAt)) - ); -} - -function sameSlackFileUploadReceipt( - left: SlackFileUploadReceiptPayload, - right: SlackFileUploadReceiptPayload, -): boolean { - return ( - left.publicationId === right.publicationId && - left.publicationAttempt === right.publicationAttempt && - left.threadId === right.threadId && - left.botExternalId === right.botExternalId && - left.runtimeGeneration === right.runtimeGeneration && - left.credentialFingerprint === right.credentialFingerprint && - left.fileIds.length === right.fileIds.length && - left.fileIds.every((fileId, index) => fileId === right.fileIds[index]) - ); -} - function isSlackReceiptReactionAlreadyApplied(error: unknown): boolean { const pending: Array<{ depth: number; value: unknown }> = [ { depth: 0, value: error }, @@ -5348,6 +5263,10 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { } const withCredentialMutationLease = chatCredentialMutationLease(db, options); + const { handlePhotonFailure, handlePhotonEvent } = createPhotonEvents(db, { runtimeCallbackRecord, runtimeCallbackEndpoint, invalidateRuntime, ensurePrincipal, requireCurrentExternalActionAuthorization, isExternalActionAuthorizationChange, scheduleMessageProcessing, processPendingPublications, questionResponses, redactError, renewDiscordGatewayOwnershipForMessageAdmission, handleSdkMessage }); + const { handleTelegramGenerationStopped, reserveTelegramPublicationDraft } = createTelegramPublicationDrafts(db, { runtimeCallbackRecord, runtimeCallbackEndpoint, runtimeFor, runtime, runtimeContexts, credentialFingerprint, authorizeRetainedChatSourcePublication }); + const { recordSlackFileUploadReceipt, stageSlackFilePublicationSuccess, processPendingSlackFileUploadReceipts, nextSlackReceiptAt } = createSlackFileReceipts(db, { endpointRecord, withCredentialMutationLease, runtimeCallbackEndpoint, nonDirectDestinationAllowed, runtimeFor, isExplicitOperatorPublication, recordExactOutboundPublicationLink }); + const { processPendingSlackSessionSyncs } = createSlackSessionStatus(db, { endpointRecord, withCredentialMutationLease, runtimeCallbackEndpoint, nonDirectDestinationAllowed, runIdFromMilestonePublication, resolveCredentials, fetchImpl, redactError, get slackSessionSyncSelectionBarrier() { return options.slackSessionSyncSelectionBarrier; } }); const { reconcileDiscordCommands, handleDiscordNativeCommand } = createDiscordCommands(db, { endpointRecord, runtimeContextForRecord, runtimeGeneration, credentialFingerprint, resolveCredentialRefs, fetchImpl, withCredentialMutationLease, runtimeCallbackRecord, ensurePrincipal, runtimeCallbackEndpoint, nonDirectDestinationAllowed, lockCurrentPrincipalAuthorization, getDiscordGatewayOwnership: (endpointId) => discordGatewayOwnerships.get(endpointId), discordGatewayRuntimeIsCurrent, stageAuthorizedTaskControlPublication, scheduleMessageProcessing, processPendingPublications }); const { latestSlackDmControlThreadId, finalizeSlackTaskStartFailure, admitConfirmedSlackTaskStart, processSlackTaskStart, processPendingSlackTaskStarts } = createSlackTaskStart(db, { endpointRecord, runtimeGeneration, credentialFingerprint, invalidateRuntime, runtimeFor, processMessage, runtimeContextForRecord, redactError, withCredentialMutationLease, sponsorAllowsGuest, nonDirectDestinationAllowed, runtimeCallbackEndpoint, lockCurrentPrincipalAuthorization, get slackTaskAdmissionClaimBarrier() { return options.slackTaskAdmissionClaimBarrier; }, maxErrorText: MAX_ERROR_TEXT }); const { stageGitHubWebhookIngress, githubIngressAcceptedResponse, processGitHubWebhookIngress, processFailedGitHubWebhookDeliveries, processPendingGitHubWebhookIngress } = createGitHubWebhookIngress(db, { endpointRecord, runtimeContextForRecord, resolveCredentialRefs, runtimeGeneration, credentialFingerprint, getWebhookPublicBaseUrl, redactError, handleWebhook, runtimeCallbackEndpoint, fetchImpl, persistence, withCredentialMutationLease, isShuttingDown: () => shuttingDown, get githubWebhookReplayBarrier() { return options.githubWebhookReplayBarrier; }, supportedWebhookEvents: SUPPORTED_GITHUB_WEBHOOK_EVENTS }); @@ -6294,829 +6213,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { return inspection; } - async function handlePhotonFailure( - endpointId: string, - error: unknown, - context: RuntimeContext, - ) { - if (error instanceof PhotonError && error.code === "attachment_not_ready") - return; - error = photonFailure(error); - const fatal = - error instanceof PhotonError && - [ - "credentials", - "line_unavailable", - "history_gap", - "invalid_response", - ].includes(error.code); - const message = - error instanceof PhotonError - ? error.message - : "Photon connection interrupted; reconnecting"; - await db.transaction(async (tx) => { - const endpoint = await runtimeCallbackEndpoint(tx, endpointId, context, [ - "verifying", - "active", - "attention", - ]); - if (!endpoint) return; - await tx - .update(chatEndpoints) - .set({ - status: "attention", - healthMessage: message, - lastError: message, - updatedAt: new Date(), - }) - .where(eq(chatEndpoints.id, endpointId)); - if (fatal) - await tx - .update(toolConnections) - .set({ - enabled: false, - healthStatus: "error", - healthMessage: message, - }) - .where(eq(toolConnections.id, endpoint.connectionId)); - }); - // Retirement is queued outside the receiver callback to avoid self-joining. - void invalidateRuntime(endpointId).catch(() => undefined); - } - - async function processPhotonResponse( - endpoint: EndpointRow, - event: PhotonEvent, - threadId: string, - adapter: PhotonChatAdapter, - context: RuntimeContext, - ): Promise<boolean> { - const command = - event.type === "message.received" - ? photonResponseCommand(event.message.content.text ?? "") - : null; - const replyGuid = - event.type === "message.received" ? event.message.replyTargetGuid : null; - const prompt = - event.type === "poll.changed" - ? await adapter.state.read<PhotonPromptReceipt>( - `poll-message:${event.pollMessageGuid}`, - ) - : replyGuid - ? ((await adapter.state.read<PhotonPromptReceipt>( - `prompt-message:${replyGuid}`, - )) ?? - (await adapter.state.read<PhotonPromptReceipt>( - `poll-message:${replyGuid}`, - ))) - : null; - const reference = command?.reference ?? prompt?.reference; - if (!reference) { - if ( - !event.isFromMe && - (replyGuid || - (event.type === "poll.changed" && - (event.delta.type === "voted" || event.delta.type === "unvoted"))) - ) { - // A vote can beat the local poll-binding write. Keep it behind the - // checkpoint while a publication in this chat is in flight. - const pending = await db - .select({ id: chatPublications.id }) - .from(chatPublications) - .innerJoin( - chatConversations, - eq(chatConversations.id, chatPublications.conversationId), - ) - .where( - and( - eq(chatPublications.endpointId, endpoint.id), - eq(chatConversations.externalThreadId, threadId), - inArray(chatPublications.state, [ - "pending", - "retry", - "streaming", - "delivery_unknown", - ]), - isNotNull(sql`${chatPublications.payload}->>'interactionId'`), - ), - ) - .limit(1); - if (pending.length) - throw new PhotonError( - "attachment_not_ready", - "Waiting for Photon poll binding", - ); - } - return ( - event.type === "poll.changed" || - (event.type === "message.received" && - /^\/(answer|submit)\b/i.test(event.message.content.text ?? "")) - ); - } - const action = await db - .select() - .from(chatActions) - .where( - and( - eq(chatActions.companyId, endpoint.companyId), - eq(chatActions.endpointId, endpoint.id), - eq(chatActions.kind, "photon_interaction"), - eq(chatActions.providerActionId, `photon:${reference}`), - ), - ) - .then((rows) => rows[0]); - if (!action || action.status !== "issued" || !action.conversationId) - return true; - const binding = action.payload as unknown as PhotonInteractionBinding; - if ( - binding.version !== 1 || - binding.reference !== reference || - !Number.isFinite(Date.parse(binding.expiresAt)) || - Date.parse(binding.expiresAt) <= Date.now() - ) - return true; - const conversation = await db - .select() - .from(chatConversations) - .where( - and( - eq(chatConversations.companyId, endpoint.companyId), - eq(chatConversations.endpointId, endpoint.id), - eq(chatConversations.id, action.conversationId), - ), - ) - .then((rows) => rows[0]); - if ( - !conversation || - !["active", "waiting"].includes(conversation.state) || - conversation.sessionGeneration !== binding.sessionGeneration || - conversation.externalThreadId !== threadId - ) - return true; - const publication = await db - .select() - .from(chatPublications) - .where( - and( - eq(chatPublications.companyId, endpoint.companyId), - eq(chatPublications.endpointId, endpoint.id), - eq(chatPublications.id, binding.publicationId), - eq(chatPublications.conversationId, conversation.id), - ), - ) - .then((rows) => rows[0]); - if ( - !publication || - publication.payload.interactionId !== binding.interactionId || - new Date(event.occurredAt).getTime() < action.createdAt.getTime() - ) - return true; - if ( - ["pending", "retry", "streaming", "delivery_unknown"].includes( - publication.state, - ) - ) - throw new PhotonError( - "attachment_not_ready", - "Waiting for Photon prompt publication", - ); - if (publication.state !== "published") return true; - const interaction = ( - await issueThreadInteractionService(db).listForIssue(conversation.issueId) - ).find((candidate) => candidate.id === binding.interactionId); - if ( - !interaction || - interaction.status !== "pending" || - interaction.companyId !== endpoint.companyId || - interaction.createdByAgentId !== endpoint.assignedAgentId || - !nativePhotonInteraction(interaction) - ) - return true; - const actor = - event.type === "message.received" ? event.message.sender : event.actor; - if (event.isFromMe || !actor?.address || actor.service !== "iMessage") - return true; - const chat = await adapter.chatInfo(threadId); - if ( - chat.isArchived || - !chat.participants.some( - (person) => - person.address === actor.address && person.service === actor.service, - ) - ) - return true; - const principal = await ensurePrincipal( - endpoint, - { - userId: `${actor.service}:${actor.address}`, - userName: actor.address, - fullName: actor.address, - isMe: false, - isBot: false, - }, - event, - ); - if ( - !principal.userId || - principal.linkedDenied || - principal.principal.kind !== "user" || - principal.principal.isBot - ) - return true; - const assertCurrent = async () => - db.transaction((tx) => - requireCurrentExternalActionAuthorization(tx, { - conversationId: conversation.id, - endpointId: endpoint.id, - expectedUserId: principal.userId!, - principalId: principal.principal.id, - runtimeContext: context, - }), - ); - try { - await assertCurrent(); - } catch (error) { - if (isExternalActionAuthorizationChange(error)) return true; - throw error; - } - const draftKey = `draft:${reference}:${principal.principal.id}:${principal.userId}`; - let draft = (await adapter.state.read<PhotonDraft>(draftKey)) ?? { - schema: 1, - interactionId: interaction.id, - principalId: principal.principal.id, - userId: principal.userId, - answers: [], - lastSequence: 0, - }; - if (draft.lastSequence > event.sequence) return true; - const questionIndex = command?.questionIndex ?? prompt?.questionIndex ?? 0; - let answerValue = - command?.value ?? - (event.type === "message.received" - ? (event.message.content.text ?? "") - : ""); - let pollChoice: string | undefined; - if (event.type === "poll.changed") { - if (!prompt || event.pollMessageGuid !== prompt.pollMessageGuid) - return true; - if (event.delta.type !== "voted" && event.delta.type !== "unvoted") - return true; - pollChoice = prompt.options[event.delta.optionIdentifier]; - if (!pollChoice) return true; - if (event.delta.type === "unvoted") { - const questionId = - interaction.kind === "ask_user_questions" - ? interaction.payload.questions[questionIndex]?.id - : null; - await adapter.state.update<PhotonDraft>(draftKey, () => ({ - ...draft, - answers: draft.answers.filter( - (answer) => - answer.questionId !== questionId || - !answer.optionIds.includes(pollChoice!), - ), - decision: draft.decision === pollChoice ? undefined : draft.decision, - lastSequence: event.sequence, - })); - return true; - } - } - const enqueueResponse = async (key: string, text: string) => { - await assertCurrent(); - await db.transaction(async (tx) => { - await tx - .insert(chatActions) - .values({ - companyId: endpoint.companyId, - endpointId: endpoint.id, - conversationId: conversation.id, - kind: "photon_response_notice", - providerActionId: `photon-notice:${key}`, - payload: { - reference, - questionIndex, - publicationId: binding.publicationId, - }, - status: "processed", - }) - .onConflictDoNothing(); - await notifyChatPublicationWork(tx); - await tx - .insert(chatPublications) - .values({ - companyId: endpoint.companyId, - endpointId: endpoint.id, - conversationId: conversation.id, - issueId: conversation.issueId, - idempotencyKey: key, - payload: { - ...publication.payload, - card: undefined, - text: projectSafeChatPublicationText(text), - }, - state: "pending", - }) - .onConflictDoNothing(); - }); - scheduleMessageProcessing(() => - processPendingPublications().then(() => undefined), - ); - }; - const notice = (text: string) => - enqueueResponse(`photon-response:${reference}:${event.sequence}`, text); - try { - if (interaction.kind === "ask_user_questions") { - if (command?.command !== "submit") { - let answer: AskUserQuestionsAnswer; - if (pollChoice) { - const question = interaction.payload.questions[questionIndex]; - if ( - !question || - !question.options.some((option) => option.id === pollChoice) - ) - return true; - answer = { questionId: question.id, optionIds: [pollChoice] }; - } else - answer = parsePhotonQuestionAnswer( - interaction, - questionIndex, - answerValue, - ); - draft = { - ...draft, - answers: [ - ...draft.answers.filter( - (entry) => entry.questionId !== answer.questionId, - ), - answer, - ], - lastSequence: event.sequence, - }; - } - const answers = interaction.payload.questions.flatMap((question) => - draft.answers.filter((answer) => answer.questionId === question.id), - ); - if ( - interaction.payload.questions.length > 1 && - command?.command !== "submit" - ) { - await adapter.state.update<PhotonDraft>(draftKey, () => draft); - const next = interaction.payload.questions.findIndex( - (question) => - !answers.some((answer) => answer.questionId === question.id), - ); - if (next >= 0) - await enqueueResponse( - `photon-question:${reference}:${next}`, - "Input needed", - ); - else - await notice( - `Your answers are saved. Send /submit ${reference} to submit them.`, - ); - return true; - } - await validateNativeQuestionResponseInput(interaction, { answers }); - // Canonical service rechecks required fields, options, audience, and - // pending -> answered atomically with the response-delivery receipt. - const issue = await db - .select() - .from(issues) - .where( - and( - eq(issues.companyId, endpoint.companyId), - eq(issues.id, conversation.issueId), - ), - ) - .then((rows) => rows[0]); - if (!issue) return true; - const answered = await issueThreadInteractionService( - db, - ).answerQuestions( - issue, - interaction.id, - { answers }, - { userId: principal.userId }, - { - beforeResolveInTransaction: async (tx) => { - await requireCurrentExternalActionAuthorization(tx, { - conversationId: conversation.id, - endpointId: endpoint.id, - expectedUserId: principal.userId!, - principalId: principal.principal.id, - runtimeContext: context, - }); - const current = await tx - .select() - .from(chatConversations) - .where(eq(chatConversations.id, conversation.id)) - .then((rows) => rows[0]); - if ( - current?.sessionGeneration !== binding.sessionGeneration || - Date.parse(binding.expiresAt) <= Date.now() - ) - throw forbidden( - "Photon prompt expired or its task generation changed", - ); - }, - afterResolveInTransaction: async (tx, resolved) => { - const claimed = await tx - .update(chatActions) - .set({ - principalId: principal.principal.id, - status: "processed", - result: { - code: "photon_question_answered", - interactionId: resolved.id, - interactionStatus: resolved.status, - answersSha256: nativeSha256( - (resolved as AskUserQuestionsInteraction).result?.answers, - ), - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, action.id), - eq(chatActions.status, "issued"), - ), - ) - .returning({ id: chatActions.id }); - if (!claimed.length) - throw forbidden("Photon prompt has already been resolved"); - await logActivity(tx as unknown as Db, { - companyId: endpoint.companyId, - actorType: "user", - actorId: principal.userId!, - action: "issue.thread_interaction_answered", - entityType: "issue", - entityId: issue.id, - details: { - source: "external_chat", - provider: endpoint.provider, - endpointId: endpoint.id, - conversationId: conversation.id, - interactionId: resolved.id, - }, - }); - }, - }, - ); - scheduleMessageProcessing(async () => { - await questionResponses.deliver(answered.id); - await processPendingPublications(); - }); - } else { - if (command?.command === "submit") return true; - const matched = /^(accept|reject|1|2)(?:\s+([\s\S]+))?$/i.exec( - answerValue.trim(), - ); - const decision = - pollChoice === "accept" || pollChoice === "reject" - ? pollChoice - : matched - ? /^(accept|1)$/i.test(matched[1]) - ? "accept" - : "reject" - : draft.decision; - if (!decision) - throw new PhotonAnswerValidationError( - `Send /answer ${reference} Accept or /answer ${reference} Reject <reason>.`, - ); - const reason = - matched?.[2]?.trim() ?? - (draft.decision === "reject" && !pollChoice - ? answerValue.trim() - : ""); - if ( - decision === "reject" && - interaction.payload.rejectRequiresReason && - !reason - ) { - await adapter.state.update<PhotonDraft>(draftKey, () => ({ - ...draft, - decision, - lastSequence: event.sequence, - })); - await notice( - `Give a rejection reason with /answer ${reference} Reject <reason>.`, - ); - return true; - } - const issue = await db - .select() - .from(issues) - .where( - and( - eq(issues.companyId, endpoint.companyId), - eq(issues.id, conversation.issueId), - ), - ) - .then((rows) => rows[0]); - if (!issue) return true; - const mutation = { - beforeResolveInTransaction: async (tx: DbTransaction) => { - await requireCurrentExternalActionAuthorization(tx, { - conversationId: conversation.id, - endpointId: endpoint.id, - expectedUserId: principal.userId!, - principalId: principal.principal.id, - runtimeContext: context, - }); - const current = await tx - .select() - .from(chatConversations) - .where(eq(chatConversations.id, conversation.id)) - .then((rows) => rows[0]); - if ( - current?.sessionGeneration !== binding.sessionGeneration || - Date.parse(binding.expiresAt) <= Date.now() - ) - throw forbidden( - "Photon prompt expired or its task generation changed", - ); - }, - afterResolveInTransaction: async ( - tx: DbTransaction, - resolved: IssueThreadInteraction, - ) => { - const claimed = await tx - .update(chatActions) - .set({ - principalId: principal.principal.id, - status: "processed", - result: { - code: "photon_confirmation_resolved", - interactionId: resolved.id, - interactionStatus: resolved.status, - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, action.id), - eq(chatActions.status, "issued"), - ), - ) - .returning({ id: chatActions.id }); - if (!claimed.length) - throw forbidden("Photon prompt has already been resolved"); - await logActivity(tx as unknown as Db, { - companyId: endpoint.companyId, - actorType: "user", - actorId: principal.userId!, - action: - decision === "accept" - ? "issue.thread_interaction_accepted" - : "issue.thread_interaction_rejected", - entityType: "issue", - entityId: issue.id, - details: { - source: "external_chat", - provider: endpoint.provider, - endpointId: endpoint.id, - conversationId: conversation.id, - interactionId: resolved.id, - }, - }); - }, - }; - if (decision === "accept") - await issueThreadInteractionService(db).acceptInteraction( - issue, - interaction.id, - {}, - { userId: principal.userId }, - mutation, - ); - else - await issueThreadInteractionService(db).rejectInteraction( - issue, - interaction.id, - { reason }, - { userId: principal.userId }, - mutation, - ); - scheduleMessageProcessing(async () => { - await processPendingPublications(); - }); - } - } catch (error) { - if (error instanceof PhotonError) throw error; - if (isExternalActionAuthorizationChange(error)) return true; - if (error instanceof HttpError && [403, 404, 409].includes(error.status)) - return true; - // Only validator errors become correction copy. Infrastructure errors - // leave the checkpoint unchanged and retry through durable recovery. - if ( - (error instanceof HttpError && error.status === 422) || - error instanceof PhotonAnswerValidationError - ) { - const missingIndex = interaction.kind === "ask_user_questions" && command?.command === "submit" - ? interaction.payload.questions.findIndex((question) => - question.required !== false && !draft.answers.some((answer) => answer.questionId === question.id)) - : -1; - await notice( - `${redactError(error)} Send /answer ${reference}.${(missingIndex >= 0 ? missingIndex : questionIndex) + 1} <answer> to correct it.`, - ); - return true; - } - throw error; - } - return true; - } - - async function handlePhotonEvent( - endpointId: string, - event: PhotonEvent, - context: RuntimeContext, - ) { - const record = await runtimeCallbackRecord(endpointId, context, [ - "verifying", - "active", - "attention", - ]); - if (!record) - throw new Error("Photon receiver no longer owns this endpoint"); - const adapter = context.endpointRuntime!.getProviderAdapter(); - if (!(adapter instanceof PhotonChatAdapter)) - throw new Error("Photon adapter unavailable"); - if (adapter.authentication.identity.allocation === "shared" && event.type === "group.changed") return; - if ( - event.type === "group.changed" && - (event.change.type === "participantRemoved" || - event.change.type === "participantLeft") && - event.change.participant.address === record.endpoint.botExternalId - ) { - const removedThreadId = photonThreadId({ - lineId: adapter.authentication.identity.lineId, - chatGuid: event.chatGuid, - isGroup: true, - }); - await db.transaction(async (tx) => { - if ( - !(await runtimeCallbackEndpoint(tx, endpointId, context, [ - "verifying", - "active", - ])) - ) - throw new Error("Photon receiver is no longer current"); - if ( - ( - await renewDiscordGatewayOwnershipForMessageAdmission( - tx, - endpointId, - context, - ) - ).kind !== "owned" - ) - throw new Error("Photon receiver lease was replaced"); - // Removal can be the first retained event after activation. Preserve a - // tombstone even when the group has never been discovered locally. - await tx - .insert(chatEndpointResources) - .values({ - companyId: record.endpoint.companyId, - endpointId, - providerResourceId: removedThreadId, - type: "group_chat", - label: "Unavailable iMessage group", - enabled: false, - availability: "unavailable", - metadata: { photonRemoved: true }, - }) - .onConflictDoUpdate({ - target: [ - chatEndpointResources.endpointId, - chatEndpointResources.type, - chatEndpointResources.providerResourceId, - ], - set: { - availability: "unavailable", - metadata: sql`jsonb_set(${chatEndpointResources.metadata}, '{photonRemoved}', 'true'::jsonb)`, - updatedAt: new Date(), - }, - }); - }); - await adapter.endTyping(removedThreadId); - return; - } - const groupIdentity = photonThreadId({ lineId: adapter.authentication.identity.lineId, chatGuid: event.chatGuid, isGroup: true }); - const removedGroup = await db.select({ metadata: chatEndpointResources.metadata }).from(chatEndpointResources).where(and(eq(chatEndpointResources.companyId, record.endpoint.companyId), eq(chatEndpointResources.endpointId, endpointId), eq(chatEndpointResources.providerResourceId, groupIdentity))).limit(1).then((rows) => rows[0]); - const readded = event.type === "group.changed" && event.change.type === "participantAdded" && event.change.participant.address === record.endpoint.botExternalId; - // Late events from a removed chat must still advance recovery without a - // now-forbidden chat lookup holding every other conversation behind them. - if (removedGroup?.metadata.photonRemoved === true && !readded) return; - const chat = await adapter.client.chats - .get(event.chatGuid) - .catch((error) => { - throw photonFailure(error); - }); - if (chat.guid !== event.chatGuid || chat.service !== "iMessage" || (chat.isGroup && adapter.authentication.identity.allocation === "shared")) return; - const threadId = photonThreadId({ - lineId: adapter.authentication.identity.lineId, - chatGuid: chat.guid, - isGroup: chat.isGroup, - }); - const removed = - event.type === "group.changed" && - (event.change.type === "participantRemoved" || - event.change.type === "participantLeft") && - event.change.participant.address === record.endpoint.botExternalId; - const available = await db.transaction(async (tx) => { - if ( - !(await runtimeCallbackEndpoint(tx, endpointId, context, [ - "verifying", - "active", - ])) - ) - throw new Error("Photon receiver is no longer current"); - const ownership = await renewDiscordGatewayOwnershipForMessageAdmission( - tx, - endpointId, - context, - ); - if (ownership.kind !== "owned") - throw new Error("Photon receiver lease was replaced"); - const existing = await tx - .select() - .from(chatEndpointResources) - .where( - and( - eq(chatEndpointResources.endpointId, endpointId), - eq(chatEndpointResources.providerResourceId, threadId), - eq( - chatEndpointResources.type, - chat.isGroup ? "group_chat" : "direct_message", - ), - ), - ) - .then((rows) => rows[0]); - const added = - event.type === "group.changed" && - event.change.type === "participantAdded" && - event.change.participant.address === record.endpoint.botExternalId; - const stillRemoved = - removed || (existing?.metadata.photonRemoved === true && !added); - const availability = - stillRemoved || chat.isArchived ? "unavailable" : "available"; - const metadata = { - photonRemoved: stillRemoved, - participants: chat.participants - .slice(0, 256) - .map((p) => ({ address: p.address, service: p.service })), - }; - const label = ( - chat.displayName || chat.participants.map((p) => p.address).join(", ") - ).slice(0, 512); - await tx - .insert(chatEndpointResources) - .values({ - companyId: record.endpoint.companyId, - endpointId, - providerResourceId: threadId, - type: chat.isGroup ? "group_chat" : "direct_message", - label, - enabled: !chat.isGroup, - availability, - metadata, - }) - .onConflictDoUpdate({ - target: [ - chatEndpointResources.endpointId, - chatEndpointResources.type, - chatEndpointResources.providerResourceId, - ], - set: { label, availability, metadata, updatedAt: new Date() }, - }); - return availability === "available"; - }); - if (!available) return; - if ( - !event.isFromMe && - (await processPhotonResponse( - record.endpoint, - event, - threadId, - adapter, - context, - )) - ) - return; - if (event.type === "message.received" && !event.isFromMe) { - await handleSdkMessage( - { - provider: "imessage-photon", - endpointId, - thread: context.endpointRuntime!.thread(threadId), - message: adapter.normalize(event.message, chat), - trigger: chat.isGroup ? "unaddressed_message" : "direct_message", - }, - context, - ); - } - } - async function configure( endpointId: string, input: ConfigureChatEndpointInput, @@ -9956,6 +9052,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { wakeup: (...args) => options.heartbeat.wakeup(...args), staleProcessingMs: DELIVERY_PROCESSING_STALE_MS, }); + const { stageTeamsBoardFileIntent, deriveTeamsFileTransferAuthority } = createTeamsFileAuthority({ runtimeGeneration, credentialFingerprint, runtime, runtimeContexts, localRuntimeEpoch, runtimeVersions, lockCurrentPrincipalAuthorization, lifecycleRuntimeFence, assertFailedRetryReceipt, inboundWakeScope, failedChatRetrySource, isExplicitOperatorPublication, retryCommentIds }); async function authorizeRetainedChatSourcePublication( tx: DbOrTransaction, publication: typeof chatPublications.$inferSelect, @@ -23216,943 +22313,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { ); } - // Native Teams file consent is authority for one personal recipient and one - // immutable file, not authority inferred from the SDK's latest route cache. - const teamsBoardFileIntentKind = "teams_board_file_intent"; - const teamsFileProofHash = (value: unknown) => - createHash("sha256") - .update( - JSON.stringify(value, (_key, entry) => - entry && typeof entry === "object" && !Array.isArray(entry) - ? Object.fromEntries( - Object.keys(entry) - .sort() - .map((key) => [key, entry[key]]), - ) - : entry, - ), - ) - .digest("hex"); - - async function teamsFileCurrentScope( - tx: DbTransaction, - supplied: typeof chatPublications.$inferSelect, - fence?: LifecycleRuntimeFence, - ) { - const [endpoint] = await tx - .select() - .from(chatEndpoints) - .where( - and( - eq(chatEndpoints.companyId, supplied.companyId), - eq(chatEndpoints.id, supplied.endpointId), - ), - ) - .for("no key update", { noWait: true }); - if ( - !endpoint || - endpoint.provider !== "microsoft-teams" || - endpoint.status !== "active" || - !endpoint.allowDirectMessages || - !isUuidLike(endpoint.providerAccountId) || - !isUuidLike(endpoint.botExternalId) - ) - return null; - const [connection] = await tx - .select() - .from(toolConnections) - .where( - and( - eq(toolConnections.companyId, endpoint.companyId), - eq(toolConnections.id, endpoint.connectionId), - ), - ) - .for("share", { noWait: true }); - if (!connection?.enabled || connection.status !== "active") return null; - const currentFence = { - generation: runtimeGeneration(endpoint.setup), - credentialFingerprint: credentialFingerprint( - connection.credentialSecretRefs, - ), - }; - const instance = runtime.get(endpoint.id); - const registration = instance - ? runtimeContexts.get(instance as object) - : null; - if ( - !instance || - !registration || - registration.generation !== currentFence.generation || - registration.credentialFingerprint !== - currentFence.credentialFingerprint || - registration.localEpoch !== localRuntimeEpoch(endpoint.id) || - runtimeVersions.get(endpoint.id) !== registration.version || - (fence && - (fence.generation !== currentFence.generation || - fence.credentialFingerprint !== currentFence.credentialFingerprint)) - ) - return null; - const [conversation] = await tx - .select() - .from(chatConversations) - .where( - and( - eq(chatConversations.companyId, endpoint.companyId), - eq(chatConversations.endpointId, endpoint.id), - eq(chatConversations.id, supplied.conversationId), - eq(chatConversations.issueId, supplied.issueId), - ), - ) - .for("update", { noWait: true }); - if ( - !conversation?.isDirectMessage || - !["active", "waiting"].includes(conversation.state) - ) - return null; - const [superseded] = await tx - .select({ id: chatConversations.id }) - .from(chatConversations) - .where( - and( - eq(chatConversations.companyId, endpoint.companyId), - eq(chatConversations.endpointId, endpoint.id), - eq(chatConversations.externalThreadId, conversation.externalThreadId), - gt( - chatConversations.sessionGeneration, - conversation.sessionGeneration, - ), - ), - ) - .limit(1); - if (superseded) return null; - const [issue] = await tx - .select({ id: issues.id, assigneeAgentId: issues.assigneeAgentId }) - .from(issues) - .where( - and( - eq(issues.companyId, endpoint.companyId), - eq(issues.id, conversation.issueId), - ), - ) - .for("share", { noWait: true }); - if (!issue || issue.assigneeAgentId !== endpoint.assignedAgentId) - return null; - // The conversation lock serializes rollover. The endpoint lock serializes - // reach/credential changes; no lock here waits behind inverse Board order. - if (conversation.resourceId) - await tx - .select({ id: chatEndpointResources.id }) - .from(chatEndpointResources) - .where( - and( - eq(chatEndpointResources.companyId, endpoint.companyId), - eq(chatEndpointResources.endpointId, endpoint.id), - eq(chatEndpointResources.id, conversation.resourceId), - ), - ) - .for("update", { noWait: true }); - const [publication] = await tx - .select() - .from(chatPublications) - .where( - and( - eq(chatPublications.companyId, endpoint.companyId), - eq(chatPublications.endpointId, endpoint.id), - eq(chatPublications.conversationId, conversation.id), - eq(chatPublications.issueId, conversation.issueId), - eq(chatPublications.id, supplied.id), - ), - ) - .for("update", { noWait: true }); - const ids = publication?.payload.attachmentIds; - if ( - !publication?.commentId || - !Array.isArray(ids) || - ids.length !== 1 || - !isUuidLike(ids[0]) || - publication.payload.progressState !== undefined || - publication.payload.interactionId || - publication.payload.card || - publication.idempotencyKey.startsWith("control:") || - publication.idempotencyKey.startsWith("wake:") - ) - return null; - const [comment] = await tx - .select() - .from(issueComments) - .where( - and( - eq(issueComments.companyId, endpoint.companyId), - eq(issueComments.issueId, conversation.issueId), - eq(issueComments.id, publication.commentId), - isNull(issueComments.deletedAt), - ), - ) - .for("share", { noWait: true }); - const [file] = await tx - .select({ attachment: issueAttachments, asset: assets }) - .from(issueAttachments) - .innerJoin( - assets, - and( - eq(assets.id, issueAttachments.assetId), - eq(assets.companyId, endpoint.companyId), - ), - ) - .where( - and( - eq(issueAttachments.companyId, endpoint.companyId), - eq(issueAttachments.issueId, conversation.issueId), - eq(issueAttachments.issueCommentId, publication.commentId), - eq(issueAttachments.id, ids[0]), - ), - ) - .for("share", { noWait: true }); - const filename = - file?.asset.originalFilename ?? - (file ? `attachment-${file.attachment.id}` : ""); - if ( - !comment || - comment.updatedAt.getTime() !== comment.createdAt.getTime() || - !file || - !Number.isSafeInteger(file.asset.byteSize) || - file.asset.byteSize <= 0 || - file.asset.byteSize > MAX_ATTACHMENT_BYTES || - file.asset.byteSize >= 60 * 1024 * 1024 || - !/^[a-f0-9]{64}$/.test(file.asset.sha256) || - !file.asset.objectKey.trim() || - !isAllowedContentType(file.asset.contentType) || - !filename || - filename.length > 255 || - filename !== filename.trim() || - filename.endsWith(".") || - /[<>:"/\\|?*\x00-\x1f\x7f]/.test(filename) - ) - return null; - const byteIdentity = { - attachmentId: file.attachment.id, - assetId: file.asset.id, - sha256: file.asset.sha256, - byteSize: file.asset.byteSize, - filename, - contentType: file.asset.contentType, - originatingRunId: file.attachment.originatingRunId, - }; - return { - endpoint, - conversation, - publication, - comment, - file, - filename, - currentFence, - byteIdentity, - instance, - }; - } - - async function teamsFilePrincipalAuthorization( - tx: DbTransaction, - endpoint: EndpointRow, - principalId: string, - ) { - const identity = await tx.execute( - sql`select pg_try_advisory_xact_lock(hashtextextended(${`chat-identity:${endpoint.companyId}:${principalId}`}, 0)) as locked`, - ); - if (!identity[0]?.locked) - throw new NativeChatReviewPresentationContentionError(); - const [link] = await tx - .select() - .from(chatIdentityLinks) - .where( - and( - eq(chatIdentityLinks.companyId, endpoint.companyId), - eq(chatIdentityLinks.endpointId, endpoint.id), - eq(chatIdentityLinks.principalId, principalId), - ), - ) - .for("update", { noWait: true }); - const userId = - link?.status === "linked" ? link.paperclipUserId : endpoint.sponsorUserId; - if (userId) - await tx - .select() - .from(companyMemberships) - .where( - and( - eq(companyMemberships.companyId, endpoint.companyId), - eq(companyMemberships.principalType, "user"), - eq(companyMemberships.principalId, userId), - ), - ) - .for("update", { noWait: true }); - // All rows and the identity advisory key used by the common authorization - // helper are already held; the helper cannot introduce an inverse wait. - return lockCurrentPrincipalAuthorization(tx, endpoint, principalId); - } - - async function teamsFileSourceBinding( - tx: DbTransaction, - scope: NonNullable<Awaited<ReturnType<typeof teamsFileCurrentScope>>>, - actionId: string, - ) { - const { endpoint, conversation } = scope; - const [action] = await tx - .select() - .from(chatActions) - .where( - and( - eq(chatActions.companyId, endpoint.companyId), - eq(chatActions.endpointId, endpoint.id), - eq(chatActions.conversationId, conversation.id), - eq(chatActions.id, actionId), - eq(chatActions.kind, "inbound_wakeup"), - eq(chatActions.status, "processed"), - ), - ) - .for("update", { noWait: true }); - if ( - !action?.deliveryId || - !action.principalId || - action.payload.version !== 1 || - action.payload.issueId !== conversation.issueId || - action.payload.agentId !== endpoint.assignedAgentId || - action.payload.sessionGeneration !== conversation.sessionGeneration || - typeof action.payload.commentId !== "string" || - !isUuidLike(action.payload.commentId) - ) - return null; - const [delivery] = await tx - .select() - .from(chatDeliveries) - .where( - and( - eq(chatDeliveries.companyId, endpoint.companyId), - eq(chatDeliveries.endpointId, endpoint.id), - eq(chatDeliveries.conversationId, conversation.id), - eq(chatDeliveries.id, action.deliveryId), - eq(chatDeliveries.principalId, action.principalId), - eq(chatDeliveries.state, "processed"), - inArray(chatDeliveries.eventKind, [ - "direct_message", - "mention", - "message", - ]), - ), - ) - .for("update", { noWait: true }); - const originalFence = delivery ? lifecycleRuntimeFence(delivery) : null; - const [principal] = await tx - .select() - .from(chatExternalPrincipals) - .where( - and( - eq(chatExternalPrincipals.companyId, endpoint.companyId), - eq(chatExternalPrincipals.id, action.principalId), - eq(chatExternalPrincipals.provider, "microsoft-teams"), - eq( - chatExternalPrincipals.providerAccountId, - endpoint.providerAccountId!, - ), - eq(chatExternalPrincipals.kind, "user"), - eq(chatExternalPrincipals.isBot, false), - ), - ) - .for("share", { noWait: true }); - if (!delivery || !originalFence || !principal) return null; - const admission = { - companyId: endpoint.companyId, - endpointId: endpoint.id, - runtimeGeneration: originalFence.generation, - credentialFingerprint: originalFence.credentialFingerprint, - tenantId: endpoint.providerAccountId!, - botAppId: endpoint.botExternalId!, - providerEventId: delivery.providerEventId, - threadId: conversation.externalThreadId, - isDirectMessage: true, - }; - const parsed = parseTeamsPersonalRecipient( - delivery.normalizedEvent.teamsPersonalRecipient, - admission, - ); - const binding = parsed - ? bindTeamsPersonalRecipient(parsed, { - admission, - deliveryId: delivery.id, - principalId: principal.id, - conversationId: conversation.id, - conversationGeneration: conversation.sessionGeneration, - externalPrincipalId: principal.externalId, - }) - : null; - const originalConversation = delivery.normalizedEvent.conversation as - | Record<string, unknown> - | undefined; - const originalMessage = delivery.normalizedEvent.message as - | Record<string, unknown> - | undefined; - const originalPrincipal = delivery.normalizedEvent.principal as - | Record<string, unknown> - | undefined; - if ( - !binding || - originalConversation?.isDirectMessage !== true || - typeof originalConversation.externalThreadId !== "string" || - canonicalTeamsThreadId(originalConversation.externalThreadId) !== - canonicalTeamsThreadId(conversation.externalThreadId) || - originalMessage?.providerMessageId !== - binding.recipient.providerActivityId || - originalPrincipal?.externalId !== principal.externalId - ) - return null; - const [source] = await tx - .select({ comment: issueComments, link: chatMessageLinks }) - .from(chatMessageLinks) - .innerJoin( - issueComments, - and( - eq(issueComments.id, chatMessageLinks.commentId), - eq(issueComments.companyId, endpoint.companyId), - eq(issueComments.issueId, conversation.issueId), - ), - ) - .where( - and( - eq(chatMessageLinks.companyId, endpoint.companyId), - eq(chatMessageLinks.endpointId, endpoint.id), - eq(chatMessageLinks.conversationId, conversation.id), - eq(chatMessageLinks.deliveryId, delivery.id), - eq(chatMessageLinks.commentId, action.payload.commentId), - eq(chatMessageLinks.direction, "inbound"), - ), - ) - .for("share", { noWait: true }); - const expectedUserId = - action.payload.requestedByActorType === "user" - ? action.payload.requestedByActorId - : null; - if ( - !source || - source.link.providerMessageId !== binding.recipient.providerActivityId || - source.comment.deletedAt || - source.comment.createdAt > scope.comment.createdAt || - source.comment.updatedAt.getTime() !== - source.comment.createdAt.getTime() || - source.comment.authorUserId !== expectedUserId || - !["user", "system"].includes( - String(action.payload.requestedByActorType), - ) || - (expectedUserId === null && - action.payload.requestedByActorId !== principal.id) - ) - return null; - const [lifecycle] = await tx - .select({ id: chatDeliveries.id }) - .from(chatDeliveries) - .where( - and( - eq(chatDeliveries.companyId, endpoint.companyId), - eq(chatDeliveries.endpointId, endpoint.id), - or( - isNull(chatDeliveries.conversationId), - eq(chatDeliveries.conversationId, conversation.id), - ), - ne(chatDeliveries.state, "filtered"), - inArray(chatDeliveries.eventKind, [ - "message_updated", - "message_deleted", - "message_restored", - ]), - sql`${chatDeliveries.normalizedEvent}->'runtimeContext' = ${JSON.stringify(originalFence)}::jsonb`, - sql`${chatDeliveries.normalizedEvent}->'message'->>'targetProviderEventId' = ${delivery.providerEventId}`, - ), - ) - .limit(1); - if (lifecycle) return null; - const authorization = await teamsFilePrincipalAuthorization( - tx, - endpoint, - principal.id, - ); - if (!authorization.allowed || authorization.userId !== expectedUserId) - return null; - const [receipt] = await tx - .select() - .from(agentWakeupRequests) - .where( - and( - eq(agentWakeupRequests.companyId, endpoint.companyId), - eq(agentWakeupRequests.id, action.id), - eq(agentWakeupRequests.agentId, endpoint.assignedAgentId), - ), - ) - .for("share", { noWait: true }); - if (!receipt || ["skipped", "cancelled"].includes(receipt.status)) - return null; - try { - assertFailedRetryReceipt( - createDurableChatWakeupRequest({ - id: action.id, - companyId: endpoint.companyId, - agentId: endpoint.assignedAgentId, - issueId: conversation.issueId, - commentId: source.comment.id, - requestedByActorType: action.payload.requestedByActorType as - | "user" - | "system", - requestedByActorId: String(action.payload.requestedByActorId), - requestedAt: action.createdAt, - authorize: async () => {}, - }), - receipt, - ); - } catch (error) { - if ( - error instanceof HttpError && - (error.details as { code?: string } | undefined)?.code === - "chat_failed_run_retry_not_authorized" - ) - return null; - throw error; - } - return { - binding, - authorization, - action, - sourceFacts: { - actionId: action.id, - deliveryId: delivery.id, - commentId: source.comment.id, - principalId: principal.id, - scope: inboundWakeScope(action), - originalFence, - bodySha256: createHash("sha256") - .update(source.comment.body) - .digest("hex"), - receiptId: receipt.id, - recipientBindingSha256: teamsFileProofHash(binding), - }, - }; - } - - async function teamsBoardOriginBinding( - tx: DbTransaction, - scope: NonNullable<Awaited<ReturnType<typeof teamsFileCurrentScope>>>, - ) { - // Pick the task's first admitted source, never the latest DM actor. If the - // durable task contains different recipient principals, consent is ambiguous. - const principals = await tx - .selectDistinct({ id: chatActions.principalId }) - .from(chatActions) - .where( - and( - eq(chatActions.companyId, scope.endpoint.companyId), - eq(chatActions.endpointId, scope.endpoint.id), - eq(chatActions.conversationId, scope.conversation.id), - eq(chatActions.kind, "inbound_wakeup"), - eq(chatActions.status, "processed"), - sql`${chatActions.payload}->>'sessionGeneration' = ${String(scope.conversation.sessionGeneration)}`, - ), - ) - .limit(2); - if (principals.length !== 1 || !principals[0]?.id) return null; - const [origin] = await tx - .select({ id: chatActions.id }) - .from(chatActions) - .where( - and( - eq(chatActions.companyId, scope.endpoint.companyId), - eq(chatActions.endpointId, scope.endpoint.id), - eq(chatActions.conversationId, scope.conversation.id), - eq(chatActions.kind, "inbound_wakeup"), - eq(chatActions.status, "processed"), - sql`${chatActions.payload}->>'sessionGeneration' = ${String(scope.conversation.sessionGeneration)}`, - ), - ) - .orderBy(asc(chatActions.createdAt), asc(chatActions.id)) - .limit(1); - return origin ? teamsFileSourceBinding(tx, scope, origin.id) : null; - } - - async function teamsBoardAuthorIsCurrent( - tx: DbTransaction, - companyId: string, - userId: string, - ) { - const [membership] = await tx - .select() - .from(companyMemberships) - .where( - and( - eq(companyMemberships.companyId, companyId), - eq(companyMemberships.principalType, "user"), - eq(companyMemberships.principalId, userId), - ), - ) - .for("share", { noWait: true }); - return ( - membership?.status === "active" && membership.membershipRole !== "viewer" - ); - } - - function teamsBoardFileIntentPayload( - scope: NonNullable<Awaited<ReturnType<typeof teamsFileCurrentScope>>>, - source: NonNullable<Awaited<ReturnType<typeof teamsFileSourceBinding>>>, - userId: string, - ) { - return { - schema: "paperclip.teams.board-file-intent.v1", - publicationId: scope.publication.id, - issueId: scope.conversation.issueId, - commentId: scope.comment.id, - attachmentId: scope.file.attachment.id, - userId, - conversationGeneration: scope.conversation.sessionGeneration, - sourceActionId: source.action.id, - sourceScopeSha256: teamsFileProofHash(source.sourceFacts), - recipientBindingSha256: teamsFileProofHash(source.binding), - byteIdentitySha256: teamsFileProofHash(scope.byteIdentity), - commentBodySha256: createHash("sha256") - .update(scope.comment.body) - .digest("hex"), - }; - } - - function teamsFileRuntimeStillCurrent( - scope: NonNullable<Awaited<ReturnType<typeof teamsFileCurrentScope>>>, - ) { - const registration = runtimeContexts.get(scope.instance as object); - return ( - runtime.get(scope.endpoint.id) === scope.instance && - registration?.generation === scope.currentFence.generation && - registration.credentialFingerprint === - scope.currentFence.credentialFingerprint && - registration.localEpoch === localRuntimeEpoch(scope.endpoint.id) && - runtimeVersions.get(scope.endpoint.id) === registration.version - ); - } - - async function stageTeamsBoardFileIntent( - tx: DbTransaction, - publication: typeof chatPublications.$inferSelect, - userId: string, - conversation: ConversationRow, - ): Promise<void> { - if ( - !publication.idempotencyKey.startsWith("explicit-board:") || - !conversation.isDirectMessage - ) - return; - const scope = await teamsFileCurrentScope(tx, publication); - if ( - !scope || - scope.conversation.id !== conversation.id || - scope.conversation.sessionGeneration !== conversation.sessionGeneration || - scope.comment.authorUserId !== userId || - scope.comment.authorAgentId || - scope.comment.createdByRunId || - !(await teamsBoardAuthorIsCurrent(tx, scope.endpoint.companyId, userId)) - ) - return; - const source = await teamsBoardOriginBinding(tx, scope); - if (!source || !teamsFileRuntimeStillCurrent(scope)) return; - const key = `${teamsBoardFileIntentKind}:${publication.id}`; - const payload = teamsBoardFileIntentPayload(scope, source, userId); - const [existing] = await tx - .select() - .from(chatActions) - .where( - and( - eq(chatActions.companyId, scope.endpoint.companyId), - eq(chatActions.endpointId, scope.endpoint.id), - eq(chatActions.providerActionId, key), - ), - ) - .for("update", { noWait: true }); - if (existing) { - if ( - existing.kind !== teamsBoardFileIntentKind || - existing.status !== "processed" || - existing.deliveryId !== source.binding.deliveryId || - existing.principalId !== source.binding.principalId || - existing.conversationId !== scope.conversation.id || - teamsFileProofHash(existing.payload) !== teamsFileProofHash(payload) - ) - throw conflict("The Teams file recipient intent has changed", { - code: "chat_file_transfer_authority_changed", - }); - return; - } - await tx.insert(chatActions).values({ - companyId: scope.endpoint.companyId, - endpointId: scope.endpoint.id, - conversationId: scope.conversation.id, - deliveryId: source.binding.deliveryId, - principalId: source.binding.principalId, - kind: teamsBoardFileIntentKind, - providerActionId: key, - status: "processed", - payload, - }); - } - - async function deriveTeamsFileTransferAuthority( - tx: DbTransaction, - publication: typeof chatPublications.$inferSelect, - fence: LifecycleRuntimeFence, - continuingTeamsFileTransfer?: NonNullable< - NonNullable< - Parameters<typeof failedChatRetrySource>[1]["committedResponse"] - >["continuingTeamsFileTransfer"] - >, - ): Promise<TeamsFileTransferAuthority | null> { - const scope = await teamsFileCurrentScope(tx, publication, fence); - if (!scope) return null; - let source: NonNullable<Awaited<ReturnType<typeof teamsFileSourceBinding>>>; - let causal: Record<string, unknown>; - if (isExplicitOperatorPublication(scope.publication)) { - if ( - !scope.publication.idempotencyKey.startsWith("explicit-board:") || - !scope.comment.authorUserId || - scope.comment.authorAgentId || - scope.comment.createdByRunId || - !(await teamsBoardAuthorIsCurrent( - tx, - scope.endpoint.companyId, - scope.comment.authorUserId, - )) - ) - return null; - const [intent] = await tx - .select() - .from(chatActions) - .where( - and( - eq(chatActions.companyId, scope.endpoint.companyId), - eq(chatActions.endpointId, scope.endpoint.id), - eq(chatActions.conversationId, scope.conversation.id), - eq(chatActions.kind, teamsBoardFileIntentKind), - eq( - chatActions.providerActionId, - `${teamsBoardFileIntentKind}:${scope.publication.id}`, - ), - eq(chatActions.status, "processed"), - ), - ) - .for("share", { noWait: true }); - if ( - !intent || - typeof intent.payload.sourceActionId !== "string" || - !isUuidLike(intent.payload.sourceActionId) - ) - return null; - const bound = await teamsBoardOriginBinding(tx, scope); - if ( - !bound || - bound.action.id !== intent.payload.sourceActionId || - intent.deliveryId !== bound.binding.deliveryId || - intent.principalId !== bound.binding.principalId - ) - return null; - const currentPayload = teamsBoardFileIntentPayload( - scope, - bound, - scope.comment.authorUserId, - ); - if ( - teamsFileProofHash(intent.payload) !== - teamsFileProofHash(currentPayload) - ) - return null; - source = bound; - causal = { - kind: "explicit_board_send", - intentId: intent.id, - ...currentPayload, - }; - } else { - const runId = scope.comment.createdByRunId; - if ( - !runId || - scope.comment.authorAgentId !== scope.endpoint.assignedAgentId || - scope.file.attachment.originatingRunId !== runId - ) - return null; - const [run] = await tx - .select() - .from(heartbeatRuns) - .where( - and( - eq(heartbeatRuns.companyId, scope.endpoint.companyId), - eq(heartbeatRuns.id, runId), - eq(heartbeatRuns.agentId, scope.endpoint.assignedAgentId), - eq(heartbeatRuns.nativeIssueId, scope.conversation.issueId), - eq(heartbeatRuns.runtimeMode, "native"), - eq(heartbeatRuns.status, "succeeded"), - isNotNull(heartbeatRuns.finishedAt), - ), - ) - .for("share", { noWait: true }); - const marker = run?.resultJson?.nativeCommittedChatResponse as - | Record<string, unknown> - | undefined; - if ( - !run || - !marker || - marker.schema !== "paperclip.native_committed_chat_response.v1" || - typeof marker.resultId !== "string" || - !isUuidLike(marker.resultId) || - typeof marker.decisionId !== "string" || - !isUuidLike(marker.decisionId) || - typeof marker.canonicalSha256 !== "string" || - !/^(?:sha256:)?[a-f0-9]{64}$/.test(marker.canonicalSha256) - ) - return null; - // Pre-lock the exact causal source and actor rows before the existing - // complete native publication proof invokes its common authorization. - const originActions = await tx - .select({ id: chatActions.id }) - .from(chatActions) - .where( - and( - eq(chatActions.companyId, scope.endpoint.companyId), - eq(chatActions.conversationId, scope.conversation.id), - eq(chatActions.kind, "inbound_wakeup"), - inArray( - sql<string>`${chatActions.payload}->>'commentId'`, - retryCommentIds(run.contextSnapshot ?? {}), - ), - ), - ); - const bindings: Array< - NonNullable<Awaited<ReturnType<typeof teamsFileSourceBinding>>> - > = []; - for (const action of originActions) { - const binding = await teamsFileSourceBinding(tx, scope, action.id); - if (!binding) return null; - bindings.push(binding); - } - let proved: FailedChatRetrySource; - try { - proved = await failedChatRetrySource(tx, { - companyId: scope.endpoint.companyId, - issueId: scope.conversation.issueId, - agentId: run.agentId, - failedRunId: run.id, - publication: true, - committedResponse: { - resultId: marker.resultId, - canonicalSha256: marker.canonicalSha256, - decisionId: marker.decisionId, - continuingTeamsFileTransfer, - }, - }); - } catch (error) { - if ( - error instanceof HttpError && - (error.details as { code?: string } | undefined)?.code === - "chat_failed_run_retry_not_authorized" - ) - return null; - throw error; - } - if ( - proved.provider !== "microsoft-teams" || - proved.endpointId !== scope.endpoint.id || - proved.conversationId !== scope.conversation.id || - proved.sessionGeneration !== scope.conversation.sessionGeneration || - bindings.length !== proved.sources.length - ) - return null; - const ordered = proved.sources.map((entry) => - bindings.find((binding) => binding.action.id === entry.actionId), - ); - const first = ordered[0]; - if ( - !first || - ordered.some( - (entry) => - !entry || - entry.binding.principalId !== proved.principalId || - entry.binding.recipient.providerConversationId !== - first.binding.recipient.providerConversationId || - entry.binding.recipient.providerUserId !== - first.binding.recipient.providerUserId || - entry.binding.recipient.aadObjectId !== - first.binding.recipient.aadObjectId, - ) - ) - return null; - if ( - run.resultJson?.finalizationReasonCode === - "governed_response_waiting" && - !(await authorizeNativeChatReviewPresentation( - tx as unknown as Db, - { - companyId: scope.endpoint.companyId, - issueId: scope.conversation.issueId, - runId: run.id, - resultJson: run.resultJson, - destination: { - endpointId: scope.endpoint.id, - conversationId: scope.conversation.id, - }, - }, - "nonblocking", - )) - ) - return null; - source = first; - causal = { - kind: "native_committed_response", - runId: run.id, - resultId: marker.resultId, - canonicalSha256: marker.canonicalSha256, - decisionId: marker.decisionId, - sourceScopeSha256: proved.sourceScopeSha256, - sources: ordered.map((entry) => entry!.sourceFacts), - }; - } - // No mutable stage, attempt, receipt, route URL or provider token enters - // this digest. The next authorization derives these facts afresh. - const sourceDigest = teamsFileProofHash({ - schema: "paperclip.teams.file-source.v1", - companyId: scope.endpoint.companyId, - endpointId: scope.endpoint.id, - conversationId: scope.conversation.id, - conversationGeneration: scope.conversation.sessionGeneration, - issueId: scope.conversation.issueId, - publicationId: scope.publication.id, - commentId: scope.comment.id, - causal, - byteIdentity: scope.byteIdentity, - }); - if (!teamsFileRuntimeStillCurrent(scope)) return null; - return { - companyId: scope.endpoint.companyId, - endpointId: scope.endpoint.id, - conversationId: scope.conversation.id, - issueId: scope.conversation.issueId, - publicationId: scope.publication.id, - commentId: scope.comment.id, - attachmentId: scope.file.attachment.id, - principalId: source.binding.principalId, - authorizedUserId: source.authorization.userId, - runtimeGeneration: scope.currentFence.generation, - credentialFingerprint: scope.currentFence.credentialFingerprint, - conversationGeneration: scope.conversation.sessionGeneration, - sourceDigest, - tenantId: source.binding.recipient.tenantId, - botAppId: source.binding.recipient.botAppId, - aadObjectId: source.binding.recipient.aadObjectId, - providerConversationId: source.binding.recipient.providerConversationId, - providerUserId: source.binding.recipient.providerUserId, - sha256: scope.file.asset.sha256, - byteSize: scope.file.asset.byteSize, - filename: scope.filename, - }; - } - function teamsTransferProtocol( input: { credentialLease?: CredentialMutationLeaseGuard; @@ -24630,148 +22790,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { }; } - async function recordSlackFileUploadReceipt(input: { - credentialLease: CredentialMutationLeaseGuard; - conversation: ConversationRow; - endpoint: EndpointRow; - publication: typeof chatPublications.$inferSelect; - receipt: SlackFileUploadAcceptedReceipt; - runtimeContext: LifecycleRuntimeFence; - }): Promise<string> { - const publicationAttempt = input.publication.attempts + 1; - const botExternalId = input.endpoint.botExternalId; - const expectedThreadId = `slack:${input.receipt.channelId}:${input.receipt.threadTs ?? ""}`; - if ( - input.endpoint.provider !== "slack" || - !botExternalId || - input.receipt.version !== 1 || - expectedThreadId !== input.conversation.externalThreadId || - input.receipt.fileIds.length !== - (input.publication.payload.attachmentIds?.length ?? 0) - ) { - throw new Error( - "Slack file upload receipt did not match its publication", - ); - } - const payload: SlackFileUploadReceiptPayload = { - version: 1, - publicationId: input.publication.id, - publicationAttempt, - threadId: expectedThreadId, - fileIds: [...input.receipt.fileIds], - botExternalId, - runtimeGeneration: input.runtimeContext.generation, - credentialFingerprint: input.runtimeContext.credentialFingerprint, - }; - if (!slackFileUploadReceiptPayload(payload)) { - throw new Error("Slack file upload receipt was malformed"); - } - const providerActionId = `slack-file-receipt:${input.publication.id}:${publicationAttempt}`; - const recordedAt = new Date(); - return db.transaction(async (tx) => { - await input.credentialLease.assertOwned(tx); - const publication = await tx - .select({ id: chatPublications.id }) - .from(chatPublications) - .where( - and( - eq(chatPublications.id, input.publication.id), - eq(chatPublications.companyId, input.publication.companyId), - eq(chatPublications.endpointId, input.publication.endpointId), - eq( - chatPublications.conversationId, - input.publication.conversationId, - ), - eq(chatPublications.state, "streaming"), - eq(chatPublications.attempts, publicationAttempt), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - const endpoint = publication - ? await runtimeCallbackEndpoint( - tx, - input.endpoint.id, - input.runtimeContext, - ["verifying", "active"], - ) - : null; - const conversation = endpoint - ? await tx - .select({ - externalThreadId: chatConversations.externalThreadId, - id: chatConversations.id, - }) - .from(chatConversations) - .where( - and( - eq(chatConversations.id, input.conversation.id), - eq(chatConversations.companyId, input.publication.companyId), - eq(chatConversations.endpointId, input.endpoint.id), - inArray(chatConversations.state, [ - "active", - "waiting", - "completed", - ]), - ), - ) - .for("no key update") - .then((rows) => rows[0] ?? null) - : null; - if ( - !publication || - endpoint?.provider !== "slack" || - endpoint.botExternalId !== botExternalId || - conversation?.externalThreadId !== expectedThreadId - ) { - throw new Error( - "Slack file upload ownership changed before receipt persistence", - ); - } - await notifyChatPublicationWork(tx); - const inserted = await tx - .insert(chatActions) - .values({ - companyId: input.publication.companyId, - endpointId: input.publication.endpointId, - conversationId: input.publication.conversationId, - kind: "slack_file_upload_receipt", - providerActionId, - payload, - status: "received", - result: { code: "slack_file_upload_identity_pending", attempts: 0 }, - createdAt: recordedAt, - updatedAt: recordedAt, - }) - .onConflictDoNothing() - .returning({ id: chatActions.id }); - if (inserted[0]) { - await input.credentialLease.assertOwned(tx); - return inserted[0].id; - } - const existing = await tx - .select({ id: chatActions.id }) - .from(chatActions) - .where( - and( - eq(chatActions.endpointId, input.publication.endpointId), - eq(chatActions.providerActionId, providerActionId), - eq(chatActions.kind, "slack_file_upload_receipt"), - sql`${chatActions.payload} = ${JSON.stringify(payload)}::jsonb`, - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - if (!existing) { - throw new Error( - "Slack file upload receipt conflicted with durable state", - ); - } - await input.credentialLease.assertOwned(tx); - return existing.id; - }); - } - async function recordExactOutboundPublicationLink( tx: DbOrTransaction, input: { @@ -24813,447 +22831,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { .then((rows) => rows.length > 0); } - async function stageSlackFilePublicationSuccess( - tx: DbTransaction, - input: { - committedAt: Date; - endpoint: EndpointRow; - publication: typeof chatPublications.$inferSelect; - providerMessageId: string; - runtimeContext: LifecycleRuntimeFence; - stageSessionSync: boolean; - }, - ): Promise<void> { - if ( - !(await recordExactOutboundPublicationLink(tx, { - publication: input.publication, - providerMessageId: input.providerMessageId, - })) - ) { - throw new Error( - "Slack file upload message identity conflicts with another publication", - ); - } - await tx - .update(chatEndpoints) - .set({ - lastPublicationAt: input.committedAt, - updatedAt: input.committedAt, - }) - .where( - and( - eq(chatEndpoints.id, input.endpoint.id), - inArray(chatEndpoints.status, ["verifying", "active"]), - sql`coalesce((${chatEndpoints.setup}->>'runtimeGeneration')::integer, 0) = ${input.runtimeContext.generation}`, - ), - ); - if (input.stageSessionSync) { - await stageSlackSessionSync(tx, { - companyId: input.publication.companyId, - endpointId: input.publication.endpointId, - conversationId: input.publication.conversationId, - runtimeGeneration: input.runtimeContext.generation, - credentialFingerprint: input.runtimeContext.credentialFingerprint, - }); - } - } - - async function handleTelegramGenerationStopped( - callback: ChatSdkCallbackEvent<TelegramGenerationStoppedProof>, - context: RuntimeContext, - ): Promise<void> { - const receipt = telegramGenerationStoppedReceipt(callback.event); - if (callback.provider !== "telegram" || !receipt) return; - const record = await runtimeCallbackRecord(callback.endpointId, context, [ - "verifying", - "active", - ]); - if ( - record?.endpoint.provider !== "telegram" || - record.endpoint.botExternalId !== receipt.botUserId - ) - return; - const candidates = await db - .select() - .from(chatActions) - .where( - and( - eq(chatActions.companyId, record.endpoint.companyId), - eq(chatActions.endpointId, callback.endpointId), - eq(chatActions.kind, TELEGRAM_DRAFT_ACTION_KIND), - sql`${chatActions.payload}->>'botUserId' = ${receipt.botUserId}`, - sql`${chatActions.payload}->>'draftId' = ${String(receipt.draftId)}`, - ), - ) - .limit(2); - if (candidates.length !== 1) return; - const candidate = candidates[0]!; - const binding = parseTelegramDraftBinding(candidate.payload); - if ( - !binding || - binding.chatId !== receipt.chatId || - binding.messageThreadId !== receipt.messageThreadId || - binding.runtimeGeneration !== context.generation || - binding.credentialFingerprint !== context.credentialFingerprint - ) - return; - await db.transaction(async (tx) => { - // The sender holds a renewable credential lease during draft HTTP. Do - // not acquire that lease here: a Stop can arrive inside the first RPC. - // Both Stop and final-send use the publication→endpoint→draft lock order. - const publication = await tx - .select() - .from(chatPublications) - .where( - and( - eq(chatPublications.companyId, record.endpoint.companyId), - eq(chatPublications.endpointId, callback.endpointId), - eq(chatPublications.conversationId, binding.conversationId), - eq(chatPublications.id, binding.publicationId), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - const endpoint = await runtimeCallbackEndpoint( - tx, - callback.endpointId, - context, - ["verifying", "active"], - ); - if ( - !publication || - !endpoint || - endpoint.provider !== "telegram" || - endpoint.botExternalId !== receipt.botUserId || - runtime.get(endpoint.id) !== context.endpointRuntime || - publication.attempts !== binding.publicationAttempt || - telegramDraftTextSha256( - renderPublicationTransportText(publication.payload), - ) !== binding.textSha256 - ) - return; - const connection = await tx - .select() - .from(toolConnections) - .where( - and( - eq(toolConnections.companyId, endpoint.companyId), - eq(toolConnections.id, endpoint.connectionId), - ), - ) - .for("share") - .then((rows) => rows[0] ?? null); - if ( - !connection?.enabled || - connection.status !== "active" || - credentialFingerprint(connection.credentialSecretRefs) !== - binding.credentialFingerprint - ) - return; - const conversation = await tx - .select() - .from(chatConversations) - .where( - and( - eq(chatConversations.companyId, endpoint.companyId), - eq(chatConversations.endpointId, endpoint.id), - eq(chatConversations.id, binding.conversationId), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - if ( - !conversation || - !conversation.isDirectMessage || - conversation.sessionGeneration !== binding.sessionGeneration || - conversation.externalThreadId !== - `telegram:${binding.chatId}${binding.messageThreadId === null ? "" : `:${binding.messageThreadId}`}` - ) - return; - const action = await tx - .select() - .from(chatActions) - .where(eq(chatActions.id, candidate.id)) - .for("update") - .then((rows) => rows[0] ?? null); - if ( - !action || - JSON.stringify(action.payload) !== JSON.stringify(candidate.payload) - ) - return; - // final_sending is an irreversible local I/O claim, not a delivery - // receipt. A late Stop must not claim that an in-flight final was undone. - if ( - action.result?.phase !== "drafting" || - action.status !== "processing" || - !["streaming", "delivery_unknown"].includes(publication.state) - ) - return; - const stoppedAt = new Date(); - await tx - .update(chatActions) - .set({ - status: "cancelled", - result: { - phase: "stopped", - updateId: receipt.updateId, - stoppedAt: stoppedAt.toISOString(), - }, - updatedAt: stoppedAt, - }) - .where(eq(chatActions.id, action.id)); - await notifyChatPublicationWork(tx); - await tx - .update(chatPublications) - .set({ - state: "cancelled", - nextAttemptAt: null, - redactedError: TELEGRAM_DRAFT_STOPPED_REASON, - updatedAt: stoppedAt, - }) - .where(eq(chatPublications.id, publication.id)); - await tx - .update(chatActions) - .set({ - status: "cancelled", - result: { - code: "telegram_draft_presentation_stopped", - attempts: publication.attempts, - }, - updatedAt: stoppedAt, - }) - .where( - and( - eq(chatActions.companyId, publication.companyId), - eq(chatActions.endpointId, publication.endpointId), - eq( - chatActions.providerActionId, - `task-control-authorization:${publication.id}`, - ), - eq(chatActions.status, "processing"), - sql`${chatActions.result}->>'attempts' = ${String(publication.attempts)}`, - ), - ); - }); - } - - async function reserveTelegramPublicationDraft(input: { - endpoint: EndpointRow; - conversation: ConversationRow; - publication: typeof chatPublications.$inferSelect; - text: string; - credentialLease: CredentialMutationLeaseGuard; - runtimeContext: RuntimeContext; - }): Promise<{ actionId: string; control: TelegramDraftControl }> { - const endpointRuntime = await runtimeFor(input.endpoint); - const registeredContext = runtimeContexts.get(endpointRuntime as object); - if ( - !registeredContext || - registeredContext.generation !== input.runtimeContext.generation || - registeredContext.credentialFingerprint !== - input.runtimeContext.credentialFingerprint || - registeredContext.version !== input.runtimeContext.version - ) - throw new Error("Telegram draft runtime authority changed"); - const draftRuntimeContext = { ...input.runtimeContext, endpointRuntime }; - const destination = telegramPrivateDraftDestination( - input.conversation.externalThreadId, - ); - if ( - !destination || - !input.conversation.isDirectMessage || - !input.endpoint.botExternalId - ) - throw new Error("Telegram private draft destination is invalid"); - const bindingBase = { - version: 1 as const, - publicationId: input.publication.id, - publicationAttempt: input.publication.attempts + 1, - conversationId: input.conversation.id, - sessionGeneration: input.conversation.sessionGeneration, - runtimeGeneration: input.runtimeContext.generation, - credentialFingerprint: input.runtimeContext.credentialFingerprint, - botUserId: input.endpoint.botExternalId, - ...destination, - textSha256: telegramDraftTextSha256(input.text), - }; - const reserved = await db.transaction(async (tx) => { - await input.credentialLease.assertOwned(tx); - const publication = await tx - .select() - .from(chatPublications) - .where( - and( - eq(chatPublications.id, input.publication.id), - eq(chatPublications.state, "streaming"), - eq(chatPublications.attempts, bindingBase.publicationAttempt), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - if (!publication) - throw new Error("Telegram draft publication ownership changed"); - const providerActionId = `telegram-draft-publication:${publication.id}:${publication.attempts}`; - const existing = await tx - .select({ id: chatActions.id }) - .from(chatActions) - .where( - and( - eq(chatActions.endpointId, input.endpoint.id), - eq(chatActions.providerActionId, providerActionId), - ), - ) - .limit(1); - // A second invocation of the same attempt cannot create another draft or - // replay an uncertain final. A new explicit publication attempt gets a - // fresh, never-before-used bot-global ID. - if (existing.length) - throw new Error("Telegram draft attempt already exists"); - // NO CYCLE and no company/endpoint ownership: rollback, endpoint deletion - // and bot re-binding cannot recycle a provider-visible draft identity. - // Exhaustion is a refusal, never a fallback to a process-local/random ID. - const allocated = await tx.execute( - sql`select nextval('chat_telegram_draft_ids')::integer as "draftId"`, - ); - const binding = parseTelegramDraftBinding({ - ...bindingBase, - draftId: allocated[0]?.draftId, - }); - if (!binding) - throw new Error("Telegram draft identity allocation was invalid"); - const [action] = await tx - .insert(chatActions) - .values({ - companyId: input.endpoint.companyId, - endpointId: input.endpoint.id, - conversationId: input.conversation.id, - kind: TELEGRAM_DRAFT_ACTION_KIND, - providerActionId, - payload: binding, - status: "issued", - result: { phase: "reserved" }, - }) - .returning(); - return { action: action!, binding }; - }); - const before = (final: boolean): Promise<boolean> => - db.transaction(async (tx) => { - await input.credentialLease.assertOwned(tx); - const publication = await tx - .select() - .from(chatPublications) - .where( - and( - eq(chatPublications.companyId, input.endpoint.companyId), - eq(chatPublications.id, input.publication.id), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - const endpoint = await runtimeCallbackEndpoint( - tx, - input.endpoint.id, - draftRuntimeContext, - ["verifying", "active"], - ); - if ( - !endpoint || - endpoint.provider !== "telegram" || - !endpoint.allowDirectMessages || - endpoint.botExternalId !== reserved.binding.botUserId || - runtime.get(endpoint.id) !== endpointRuntime - ) - throw new Error("Telegram draft endpoint authority changed"); - const connection = await tx - .select() - .from(toolConnections) - .where( - and( - eq(toolConnections.companyId, endpoint.companyId), - eq(toolConnections.id, endpoint.connectionId), - ), - ) - .for("share") - .then((rows) => rows[0] ?? null); - if ( - !connection?.enabled || - connection.status !== "active" || - credentialFingerprint(connection.credentialSecretRefs) !== - reserved.binding.credentialFingerprint - ) - throw new Error("Telegram draft credential authority changed"); - const conversation = await tx - .select() - .from(chatConversations) - .where( - and( - eq(chatConversations.companyId, endpoint.companyId), - eq(chatConversations.endpointId, endpoint.id), - eq(chatConversations.id, input.conversation.id), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - const action = await tx - .select() - .from(chatActions) - .where(eq(chatActions.id, reserved.action.id)) - .for("update") - .then((rows) => rows[0] ?? null); - if ( - !action || - JSON.stringify(action.payload) !== - JSON.stringify(reserved.action.payload) || - !publication || - publication.attempts !== reserved.binding.publicationAttempt - ) - throw new Error("Telegram draft attempt authority changed"); - if ( - action.status === "cancelled" && - action.result?.phase === "stopped" && - publication.state === "cancelled" - ) - return false; - if ( - publication.state !== "streaming" || - !conversation || - telegramDraftTextSha256( - renderPublicationTransportText(publication.payload), - ) !== reserved.binding.textSha256 || - conversation.sessionGeneration !== - reserved.binding.sessionGeneration || - conversation.externalThreadId !== - input.conversation.externalThreadId || - !["active", "waiting", "completed"].includes(conversation.state) || - !["reserved", "drafting"].includes(String(action.result?.phase)) || - !["issued", "processing"].includes(action.status) || - !(await authorizeRetainedChatSourcePublication(tx, publication)) - ) - throw new Error("Telegram draft source authority changed"); - await tx - .update(chatActions) - .set({ - status: "processing", - result: { - phase: final ? "final_sending" : "drafting", - }, - updatedAt: new Date(), - }) - .where(eq(chatActions.id, action.id)); - await input.credentialLease.assertOwned(tx); - return true; - }); - return { - actionId: reserved.action.id, - control: { - version: 1, - draftId: reserved.binding.draftId, - beforeDraft: () => before(false), - beforeFinal: () => before(true), - }, - }; - } - async function postSafePublication(input: { endpoint: EndpointRow; conversation: ConversationRow; @@ -27056,967 +24633,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { return result; } - async function lockSlackFileReceiptAuthorization( - tx: DbTransaction, - input: { - actionId: string; - payload: SlackFileUploadReceiptPayload; - }, - ): Promise< - | { - action: typeof chatActions.$inferSelect; - conversation: ConversationRow; - endpoint: EndpointRow; - mode: "delivery_unknown" | "operator_confirmed"; - publication: typeof chatPublications.$inferSelect; - } - | { action: typeof chatActions.$inferSelect; mode: "defer" | "settled" } - | null - > { - const publication = await tx - .select() - .from(chatPublications) - .where( - and( - eq(chatPublications.id, input.payload.publicationId), - eq(chatPublications.attempts, input.payload.publicationAttempt), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - const action = publication - ? await tx - .select() - .from(chatActions) - .where( - and( - eq(chatActions.id, input.actionId), - eq(chatActions.companyId, publication.companyId), - eq(chatActions.endpointId, publication.endpointId), - eq(chatActions.conversationId, publication.conversationId), - eq(chatActions.kind, "slack_file_upload_receipt"), - eq( - chatActions.providerActionId, - `slack-file-receipt:${publication.id}:${publication.attempts}`, - ), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null) - : null; - const currentPayload = action - ? slackFileUploadReceiptPayload(action.payload) - : null; - if ( - !publication || - !action || - !publication.payload.attachmentIds?.length || - publication.payload.card || - publication.payload.interactionId || - publication.idempotencyKey.startsWith("control:") || - !currentPayload || - !sameSlackFileUploadReceipt(currentPayload, input.payload) - ) { - return null; - } - if (publication.state === "streaming") { - return { action, mode: "defer" }; - } - if (publication.state === "published" && publication.providerMessageId) { - const exactLink = await tx - .select({ id: chatMessageLinks.id }) - .from(chatMessageLinks) - .where( - and( - eq(chatMessageLinks.companyId, publication.companyId), - eq(chatMessageLinks.endpointId, publication.endpointId), - eq(chatMessageLinks.conversationId, publication.conversationId), - eq(chatMessageLinks.publicationId, publication.id), - publication.commentId - ? eq(chatMessageLinks.commentId, publication.commentId) - : isNull(chatMessageLinks.commentId), - eq( - chatMessageLinks.providerMessageId, - publication.providerMessageId, - ), - eq(chatMessageLinks.direction, "outbound"), - ), - ) - .limit(1) - .then((rows) => rows[0] ?? null); - return exactLink ? { action, mode: "settled" } : null; - } - const mode = - publication.state === "delivery_unknown" - ? "delivery_unknown" - : publication.state === "published" && - !publication.providerMessageId && - operatorConfirmedSlackFileReceipt(action.result, input.payload) - ? "operator_confirmed" - : null; - if (!mode) return null; - const endpoint = await runtimeCallbackEndpoint( - tx, - publication.endpointId, - { - generation: input.payload.runtimeGeneration, - credentialFingerprint: input.payload.credentialFingerprint, - }, - ["verifying", "active"], - ); - const conversation = endpoint - ? await tx - .select() - .from(chatConversations) - .where( - and( - eq(chatConversations.id, publication.conversationId), - eq(chatConversations.companyId, publication.companyId), - eq(chatConversations.endpointId, publication.endpointId), - eq(chatConversations.externalThreadId, input.payload.threadId), - inArray(chatConversations.state, [ - "active", - "waiting", - "completed", - ]), - ), - ) - .for("no key update") - .then((rows) => rows[0] ?? null) - : null; - const resource = - endpoint && conversation?.resourceId && !conversation.isDirectMessage - ? await tx - .select() - .from(chatEndpointResources) - .where( - and( - eq(chatEndpointResources.id, conversation.resourceId), - eq(chatEndpointResources.companyId, endpoint.companyId), - eq(chatEndpointResources.endpointId, endpoint.id), - ), - ) - .for("no key update") - .then((rows) => rows[0] ?? null) - : null; - if ( - endpoint?.provider !== "slack" || - endpoint.botExternalId !== input.payload.botExternalId || - !conversation || - !(conversation.isDirectMessage - ? endpoint.allowDirectMessages - : nonDirectDestinationAllowed(endpoint, resource)) - ) { - return null; - } - return { action, conversation, endpoint, mode, publication }; - } - - async function processPendingSlackFileUploadReceipts(limit = 25) { - const selectedAt = new Date(); - const actions = await db - .select() - .from(chatActions) - .where( - slackReceiptEligibility(selectedAt), - ) - .orderBy(asc(chatActions.updatedAt), asc(chatActions.id)) - .limit(limit); - let processed = 0; - for (const selected of actions) { - const payload = slackFileUploadReceiptPayload(selected.payload); - if (!payload) { - await db.transaction(async (tx) => { - await notifyChatPublicationWork(tx); - return tx - .update(chatActions) - .set({ - status: "failed", - result: { - code: "slack_file_upload_receipt_payload_invalid", - retryable: false, - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, selected.id), - eq(chatActions.kind, selected.kind), - eq(chatActions.status, selected.status), - sql`${chatActions.payload} = ${JSON.stringify(selected.payload)}::jsonb`, - selected.result === null - ? isNull(chatActions.result) - : sql`${chatActions.result} = ${JSON.stringify(selected.result)}::jsonb`, - ), - ); - }); - continue; - } - const record = await endpointRecord(selected.endpointId); - if (!record) { - await db.transaction(async (tx) => { - await notifyChatPublicationWork(tx); - return tx - .update(chatActions) - .set({ - status: "cancelled", - result: { code: "slack_file_upload_receipt_endpoint_removed" }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, selected.id), - eq(chatActions.kind, selected.kind), - eq(chatActions.status, selected.status), - sql`${chatActions.payload} = ${JSON.stringify(selected.payload)}::jsonb`, - selected.result === null - ? isNull(chatActions.result) - : sql`${chatActions.result} = ${JSON.stringify(selected.result)}::jsonb`, - ), - ); - }); - continue; - } - if (["paused", "attention"].includes(record.endpoint.status)) continue; - const ownerToken = randomUUID(); - let attempt = 1; - try { - await withCredentialMutationLease(record.endpoint, async (lease) => { - const claim = await db.transaction(async (tx) => { - await lease.assertOwned(tx); - const locked = await lockSlackFileReceiptAuthorization(tx, { - actionId: selected.id, - payload, - }); - const decisionAt = new Date(); - const priorAttempts = - typeof locked?.action.result?.attempts === "number" && - Number.isSafeInteger(locked.action.result.attempts) - ? locked.action.result.attempts - : 0; - attempt = priorAttempts + 1; - const retryAt = - typeof locked?.action.result?.retryAt === "string" - ? Date.parse(locked.action.result.retryAt) - : Number.NaN; - const eligible = locked - ? locked.action.status === "received" || - (locked.action.status === "failed" && - locked.action.result?.retryable === true && - Number.isFinite(retryAt) && - retryAt <= decisionAt.getTime()) || - (locked.action.status === "processing" && - locked.action.updatedAt.getTime() <= - decisionAt.getTime() - SLACK_FILE_RECEIPT_STALE_MS) - : false; - if (locked && !eligible) return null; - if (locked?.mode === "defer") { - if (locked.action.status === "processing") { - await notifyChatPublicationWork(tx); - await tx - .update(chatActions) - .set({ - status: "received", - result: { - code: "slack_file_upload_identity_pending", - attempts: priorAttempts, - }, - updatedAt: new Date(), - }) - .where(eq(chatActions.id, locked.action.id)); - } - return null; - } - if (locked?.mode === "settled") { - await notifyChatPublicationWork(tx); - await tx - .update(chatActions) - .set({ - status: "processed", - result: { - code: "slack_file_upload_identity_confirmed", - attempts: priorAttempts, - }, - updatedAt: new Date(), - }) - .where(eq(chatActions.id, locked.action.id)); - return null; - } - if ( - !locked || - decisionAt.getTime() - - (locked?.action.createdAt ?? selected.createdAt).getTime() > - SLACK_FILE_RECEIPT_MAX_AGE_MS || - attempt > SLACK_FILE_RECEIPT_MAX_ATTEMPTS - ) { - await notifyChatPublicationWork(tx); - await tx - .update(chatActions) - .set({ - status: "cancelled", - result: { - code: locked - ? "slack_file_upload_receipt_expired" - : "slack_file_upload_receipt_authorization_changed", - attempts: priorAttempts, - }, - updatedAt: decisionAt, - }) - .where( - and( - eq(chatActions.id, selected.id), - inArray(chatActions.status, [ - "received", - "failed", - "processing", - ]), - ), - ); - return null; - } - if ( - !["received", "failed", "processing"].includes( - locked.action.status, - ) - ) { - return null; - } - await notifyChatPublicationWork(tx); - const [claimed] = await tx - .update(chatActions) - .set({ - status: "processing", - result: { - attempts: attempt, - ownerToken, - ...(locked.mode === "operator_confirmed" - ? { - operatorConfirmedPublicationId: - locked.action.result?.operatorConfirmedPublicationId, - operatorConfirmedAttempt: - locked.action.result?.operatorConfirmedAttempt, - operatorConfirmedAt: - locked.action.result?.operatorConfirmedAt, - } - : {}), - }, - updatedAt: decisionAt, - }) - .where( - and( - eq(chatActions.id, locked.action.id), - eq(chatActions.status, locked.action.status), - ), - ) - .returning({ id: chatActions.id }); - await lease.assertOwned(tx); - return claimed ? locked : null; - }); - if (!claim || !("endpoint" in claim)) return; - processed += 1; - const endpointRuntime = await runtimeFor(claim.endpoint); - await lease.assertOwned(); - const providerMessageId = - await endpointRuntime.resolveSlackFileUploadReceipt( - payload.threadId, - payload.fileIds, - ); - if (!providerMessageId) { - await db.transaction(async (tx) => { - await lease.assertOwned(tx); - await notifyChatPublicationWork(tx); - await tx - .update(chatActions) - .set({ - status: "failed", - result: sql`coalesce(${chatActions.result}, '{}'::jsonb) || ${JSON.stringify( - { - attempts: attempt, - code: "slack_file_upload_identity_pending", - retryable: true, - retryAt: new Date( - Date.now() + Math.min(60_000, 1_000 * 2 ** attempt), - ).toISOString(), - }, - )}::jsonb`, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, selected.id), - eq(chatActions.status, "processing"), - sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, - ), - ); - }); - return; - } - await db.transaction(async (tx) => { - await lease.assertOwned(tx); - const locked = await lockSlackFileReceiptAuthorization(tx, { - actionId: selected.id, - payload, - }); - if ( - !locked || - locked.mode === "defer" || - locked.mode === "settled" || - !("publication" in locked) || - locked.action.status !== "processing" || - locked.action.result?.ownerToken !== ownerToken - ) { - if (!locked) { - await notifyChatPublicationWork(tx); - await tx - .update(chatActions) - .set({ - status: "cancelled", - result: { - attempts: attempt, - code: "slack_file_upload_receipt_authorization_changed", - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, selected.id), - eq(chatActions.status, "processing"), - sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, - ), - ); - } else if (locked.mode === "defer") { - await notifyChatPublicationWork(tx); - await tx - .update(chatActions) - .set({ - status: "received", - result: { - attempts: attempt, - code: "slack_file_upload_identity_pending", - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, selected.id), - eq(chatActions.status, "processing"), - sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, - ), - ); - } else { - await notifyChatPublicationWork(tx); - await tx - .update(chatActions) - .set({ - status: - locked.mode === "settled" ? "processed" : "cancelled", - result: { - attempts: attempt, - code: - locked.mode === "settled" - ? "slack_file_upload_identity_confirmed" - : "slack_file_upload_receipt_superseded", - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, selected.id), - eq(chatActions.status, "processing"), - sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, - ), - ); - } - return; - } - const committedAt = new Date(); - if ( - !(await recordExactOutboundPublicationLink(tx, { - publication: locked.publication, - providerMessageId, - })) - ) { - await notifyChatPublicationWork(tx); - await tx - .update(chatActions) - .set({ - status: "failed", - result: { - attempts: attempt, - code: "slack_file_upload_receipt_message_conflict", - retryable: false, - }, - updatedAt: committedAt, - }) - .where(eq(chatActions.id, selected.id)); - return; - } - if (locked.mode === "delivery_unknown") { - await notifyChatPublicationWork(tx); - const [settled] = await tx - .update(chatPublications) - .set({ - state: "published", - providerMessageId, - publishedAt: committedAt, - nextAttemptAt: null, - redactedError: null, - updatedAt: committedAt, - }) - .where( - and( - eq(chatPublications.id, locked.publication.id), - eq(chatPublications.state, "delivery_unknown"), - eq(chatPublications.attempts, payload.publicationAttempt), - ), - ) - .returning({ id: chatPublications.id }); - if (!settled) { - throw new Error( - "Slack file upload publication changed before receipt commit", - ); - } - await stageSlackFilePublicationSuccess(tx, { - committedAt, - endpoint: locked.endpoint, - publication: locked.publication, - providerMessageId, - runtimeContext: { - generation: payload.runtimeGeneration, - credentialFingerprint: payload.credentialFingerprint, - }, - stageSessionSync: - !isExplicitOperatorPublication(locked.publication) && - !locked.publication.idempotencyKey.startsWith( - "control:status:", - ), - }); - } else { - await notifyChatPublicationWork(tx); - const [enriched] = await tx - .update(chatPublications) - .set({ providerMessageId }) - .where( - and( - eq(chatPublications.id, locked.publication.id), - eq(chatPublications.state, "published"), - eq(chatPublications.attempts, payload.publicationAttempt), - isNull(chatPublications.providerMessageId), - ), - ) - .returning({ id: chatPublications.id }); - if (!enriched) { - throw new Error( - "Slack file upload publication changed before identity enrichment", - ); - } - } - await notifyChatPublicationWork(tx); - await tx - .update(chatActions) - .set({ - status: "processed", - result: { - attempts: attempt, - code: "slack_file_upload_identity_confirmed", - }, - updatedAt: committedAt, - }) - .where( - and( - eq(chatActions.id, selected.id), - eq(chatActions.status, "processing"), - sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, - ), - ); - await lease.assertOwned(tx); - }); - }); - } catch (error) { - const disposition = classifyChatPublicationError(error, attempt); - const retryable = - disposition.kind === "retry" || - disposition.kind === "delivery_unknown"; - const retryMs = - disposition.kind === "retry" - ? disposition.retryAfterMs - : Math.min(60_000, 1_000 * 2 ** Math.min(attempt, 8)); - await db.transaction(async (tx) => { - await notifyChatPublicationWork(tx); - return tx - .update(chatActions) - .set({ - status: retryable ? "failed" : "cancelled", - result: sql`coalesce(${chatActions.result}, '{}'::jsonb) || ${JSON.stringify( - { - attempts: attempt, - code: retryable - ? "slack_file_upload_receipt_lookup_retry" - : "slack_file_upload_receipt_lookup_rejected", - retryable, - ...(retryable - ? { retryAt: new Date(Date.now() + retryMs).toISOString() } - : {}), - }, - )}::jsonb`, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, selected.id), - eq(chatActions.status, "processing"), - sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, - ), - ); - }); - } - } - return processed; - } - - async function processPendingSlackSessionSyncs( - limit = 25, - onlyActionId?: string, - ) { - const now = new Date(); - const actions = await db - .select({ action: chatActions }) - .from(chatActions) - .leftJoin(chatEndpoints, and(eq(chatEndpoints.id, chatActions.endpointId), eq(chatEndpoints.companyId, chatActions.companyId))) - .where(and( - onlyActionId ? eq(chatActions.id, onlyActionId) : undefined, - dueChatAction("slack_session_sync", now.getTime()), - )) - .orderBy(asc(chatActions.updatedAt)) - .limit(limit) - .then(rows => rows.map(row => row.action)); - await options.slackSessionSyncSelectionBarrier?.(); - for (const action of actions) { - const payload = slackSessionSyncPayload(action.payload); - if (!payload || !action.conversationId) { - await db.transaction(async (tx) => { - await notifyChatActionWork(tx, "slack_session_sync"); - return tx - .update(chatActions) - .set({ - status: "failed", - result: { code: "slack_session_payload_invalid" }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, action.id), - eq(chatActions.updatedAt, action.updatedAt), - ), - ); - }); - continue; - } - const revisionWhere = and( - eq(chatActions.id, action.id), - sql`(${chatActions.payload}->>'revision')::bigint = ${payload.revision}`, - ); - const selectedSnapshotWhere = and( - revisionWhere, - eq(chatActions.status, action.status), - eq(chatActions.updatedAt, action.updatedAt), - ); - const record = await endpointRecord(action.endpointId); - if (record && ["paused", "attention"].includes(record.endpoint.status)) continue; - if (!record || record.endpoint.provider !== "slack") { - await db.transaction(async (tx) => { - await notifyChatActionWork(tx, "slack_session_sync"); - return tx - .update(chatActions) - .set({ - status: "cancelled", - result: { code: "slack_session_endpoint_unavailable" }, - updatedAt: new Date(), - }) - .where(selectedSnapshotWhere); - }); - continue; - } - const attempts = - (typeof action.result?.attempts === "number" - ? action.result.attempts - : 0) + 1; - const ownerToken = randomUUID(); - let ownsAttempt = false; - try { - await withCredentialMutationLease(record.endpoint, async (lease) => { - const claim = await db.transaction(async (tx) => { - await lease.assertOwned(tx); - const endpoint = await runtimeCallbackEndpoint( - tx, - action.endpointId, - { - generation: payload.runtimeGeneration, - credentialFingerprint: payload.credentialFingerprint, - }, - ["active", "verifying"], - ); - const conversation = endpoint - ? await tx - .select() - .from(chatConversations) - .where( - and( - eq(chatConversations.id, action.conversationId!), - eq(chatConversations.endpointId, endpoint.id), - eq(chatConversations.companyId, endpoint.companyId), - inArray(chatConversations.state, [ - "active", - "waiting", - "completed", - ]), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null) - : null; - const resource = - endpoint && conversation?.resourceId - ? await tx - .select() - .from(chatEndpointResources) - .where( - and( - eq(chatEndpointResources.id, conversation.resourceId), - eq(chatEndpointResources.companyId, endpoint.companyId), - eq(chatEndpointResources.endpointId, endpoint.id), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null) - : null; - const superseded = conversation - ? await tx - .select({ id: chatConversations.id }) - .from(chatConversations) - .where( - and( - eq(chatConversations.companyId, conversation.companyId), - eq(chatConversations.endpointId, conversation.endpointId), - eq( - chatConversations.externalThreadId, - conversation.externalThreadId, - ), - gt( - chatConversations.sessionGeneration, - conversation.sessionGeneration, - ), - ), - ) - .limit(1) - .then((rows) => rows.length > 0) - : false; - if ( - !endpoint || - !conversation || - superseded || - !(conversation.isDirectMessage - ? endpoint.allowDirectMessages - : nonDirectDestinationAllowed(endpoint, resource)) - ) { - await notifyChatActionWork(tx, "slack_session_sync"); - await tx - .update(chatActions) - .set({ - status: "cancelled", - result: { code: "slack_session_destination_changed" }, - updatedAt: new Date(), - }) - .where(selectedSnapshotWhere); - return null; - } - // Recompute at dispatch: a delayed processing retry must not - // overwrite a newer final response or a later question card. - const publication = await tx - .select() - .from(chatPublications) - .where( - and( - eq(chatPublications.companyId, endpoint.companyId), - eq(chatPublications.endpointId, endpoint.id), - eq(chatPublications.conversationId, conversation.id), - eq(chatPublications.state, "published"), - sql`${chatPublications.idempotencyKey} not like 'explicit%'`, - sql`${chatPublications.idempotencyKey} not like 'control:status:%'`, - sql`${chatPublications.idempotencyKey} not like 'wake:%'`, - ), - ) - .orderBy( - desc(chatPublications.publishedAt), - desc(chatPublications.createdAt), - desc(chatPublications.id), - ) - .limit(1) - .then((rows) => rows[0] ?? null); - if (!publication) { - await notifyChatActionWork(tx, "slack_session_sync"); - await tx - .update(chatActions) - .set({ - status: "cancelled", - result: { code: "slack_session_no_published_state" }, - updatedAt: new Date(), - }) - .where(selectedSnapshotWhere); - return null; - } - let status = slackSessionStatusForPublication( - publication.payload, - conversation.state === "completed", - ); - const runId = runIdFromMilestonePublication(publication); - if (status === "processing" && runId) { - const run = await tx - .select({ status: heartbeatRuns.status }) - .from(heartbeatRuns) - .where( - and( - eq(heartbeatRuns.id, runId), - eq(heartbeatRuns.companyId, endpoint.companyId), - eq(heartbeatRuns.agentId, endpoint.assignedAgentId), - ), - ) - .then((rows) => rows[0] ?? null); - // Stop may finish before its confirmation publication. Never - // resurrect processing from that run's earlier working receipt. - if (!run || !["queued", "running"].includes(run.status)) - status = "active"; - } - await notifyChatActionWork(tx, "slack_session_sync"); - const claimed = await tx - .update(chatActions) - .set({ - status: "processing", - result: { attempts, ownerToken }, - updatedAt: new Date(), - }) - .where( - and( - selectedSnapshotWhere, - or( - and( - eq(chatActions.status, "received"), - sql`(${chatActions.result}->>'retryAt' is null or (${chatActions.result}->>'retryAt')::timestamptz <= ${new Date().toISOString()}::timestamptz)`, - ), - and( - eq(chatActions.status, "processing"), - lte(chatActions.updatedAt, new Date(Date.now() - 60_000)), - ), - ), - ), - ) - .returning({ id: chatActions.id }); - await lease.assertOwned(tx); - return claimed.length ? { endpoint, conversation, status } : null; - }); - if (!claim) return; - ownsAttempt = true; - const credentials = await resolveCredentials(claim.endpoint); - await lease.assertOwned(); - const outcome = await setSlackSessionStatus({ - botToken: credentials.botToken, - threadId: claim.conversation.externalThreadId, - status: claim.status, - fetch: fetchImpl, - }); - await db.transaction(async (tx) => { - await lease.assertOwned(tx); - // Refresh long work before Slack's one-hour processing timeout. - // Unsupported sessions settle until a new publication restages - // them; completed threads must not create perpetual polling work. - const retryMs = - outcome === "updated" && claim.status === "processing" - ? 30 * 60_000 - : null; - await notifyChatActionWork(tx, "slack_session_sync"); - await tx - .update(chatActions) - .set({ - status: retryMs === null ? "processed" : "received", - result: { - attempts, - outcome, - sessionStatus: claim.status, - ...(retryMs === null - ? {} - : { - retryAt: new Date(Date.now() + retryMs).toISOString(), - }), - }, - updatedAt: new Date(), - }) - .where( - and( - revisionWhere, - eq(chatActions.status, "processing"), - sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, - ), - ); - }); - }); - } catch (error) { - const disposition = classifyChatPublicationError(error, attempts); - // Unlike posting a message, repeating setStatus cannot duplicate any - // content. Ambiguous transport/receipt failures remain retryable, but - // a definite scope/auth/destination rejection waits for new activity. - const retryable = - disposition.kind === "retry" || - disposition.kind === "delivery_unknown"; - const retryMs = - disposition.kind === "retry" - ? disposition.retryAfterMs - : Math.min(5 * 60_000, 1_000 * 2 ** Math.min(attempts, 8)); - await db.transaction(async (tx) => { - await notifyChatActionWork(tx, "slack_session_sync"); - return tx - .update(chatActions) - .set({ - status: retryable ? "received" : "failed", - result: { - attempts, - code: retryable - ? "slack_session_sync_retry" - : "slack_session_sync_rejected", - ...(retryable - ? { retryAt: new Date(Date.now() + retryMs).toISOString() } - : {}), - }, - updatedAt: new Date(), - }) - .where( - and( - revisionWhere, - ownsAttempt - ? and( - eq(chatActions.status, "processing"), - sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, - ) - : and( - eq(chatActions.status, "received"), - eq(chatActions.updatedAt, action.updatedAt), - ), - ), - ); - }); - logger.warn( - { - endpointId: action.endpointId, - actionId: action.id, - retryable, - error: redactError(error), - }, - "Slack session status update did not complete; message delivery is unchanged", - ); - } - } - return actions.length; - } - async function withPublicationAttemptLease( publication: typeof chatPublications.$inferSelect, operation: (lease: CredentialMutationLeaseGuard) => Promise<void>, @@ -28958,50 +25574,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { }); } - function slackReceiptEligibility(selectedAt?: Date) { - return and( - eq(chatActions.kind, "slack_file_upload_receipt"), - notExists( - db - .select({ id: chatEndpoints.id }) - .from(chatEndpoints) - .where( - and( - eq(chatEndpoints.id, chatActions.endpointId), - inArray(chatEndpoints.status, ["paused", "attention"]), - ), - ), - ), - // The normal publication owner is still polling Slack. Receipt - // recovery must neither contend with it nor let its not-yet-due - // action occupy the bounded recovery page. - notExists( - db - .select({ id: chatPublications.id }) - .from(chatPublications) - .where( - and( - sql`${chatPublications.id}::text = ${chatActions.payload}->>'publicationId'`, - sql`${chatPublications.attempts}::text = ${chatActions.payload}->>'publicationAttempt'`, - eq(chatPublications.state, "streaming"), - ), - ), - ), - or( - eq(chatActions.status, "received"), - and( - eq(chatActions.status, "failed"), - sql`coalesce(${chatActions.result}->>'retryable', 'false') = 'true'`, - selectedAt ? sql`(${chatActions.result}->>'retryAt')::timestamptz <= ${selectedAt.toISOString()}::timestamptz` : sql`${chatActions.result}->>'retryAt' is not null`, - ), - and( - eq(chatActions.status, "processing"), - selectedAt ? lte(chatActions.updatedAt, new Date(selectedAt.getTime() - SLACK_FILE_RECEIPT_STALE_MS)) : undefined, - ), - ), - ); - } - function publicationEligibility({ now, busyEndpointIds = [], coolingTeamsPublicationIds = [], attemptedIds = [] }: { now?: Date; busyEndpointIds?: string[]; coolingTeamsPublicationIds?: string[]; attemptedIds?: string[] } = {}) { const earlierPublication = alias(chatPublications, "earlier_chat_publications"); return and( @@ -29425,14 +25997,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { return next; } - async function nextSlackReceiptAt(): Promise<number | null> { - const [row] = await db.select({ at: sql<string | null>`min(case - when ${chatActions.status} = 'processing' then ${chatActions.updatedAt} + ${SLACK_FILE_RECEIPT_STALE_MS} * interval '1 millisecond' - when ${chatActions.status} = 'failed' then (${chatActions.result}->>'retryAt')::timestamptz - else now() end)` }).from(chatActions).where(slackReceiptEligibility()); - return row?.at == null ? null : new Date(row.at).getTime(); - } - async function nextPublicationAt(): Promise<number | null> { const cooldowns = Object.fromEntries([...teamsFileRetryAfter].map(([id, at]) => [id, new Date(at).toISOString()])); const [queued, streaming] = await Promise.all([ diff --git a/server/src/services/chat-channels/imessage-photon/events.ts b/server/src/services/chat-channels/imessage-photon/events.ts new file mode 100644 index 00000000000..14a914287fd --- /dev/null +++ b/server/src/services/chat-channels/imessage-photon/events.ts @@ -0,0 +1,903 @@ +import type { EndpointRow, DbTransaction, RuntimeContext, LifecycleRuntimeFence } from "../../chat-channels/runtime-types.js"; + +import { notifyChatPublicationWork } from "../../chat-work-notifications.js"; + +import { nativeSha256 } from "../../native-runtime/canonical.js"; + +import { projectSafeChatPublicationText } from "../../chat-publication-projection.js"; +import { PhotonAnswerValidationError, nativePhotonInteraction, photonResponseCommand, parsePhotonQuestionAnswer, type PhotonPromptReceipt, type PhotonInteractionBinding, type PhotonDraft } from "../../photon/interactions.js"; +import { validateNativeQuestionResponseInput } from "../../native-runtime/native-question-bridge.js"; +import type { AskUserQuestionsAnswer, AskUserQuestionsInteraction, IssueThreadInteraction } from "@paperclipai/shared"; +import { PhotonError, photonFailure } from "../../photon/cloud.js"; +import { PhotonChatAdapter, photonThreadId } from "../../photon/adapter.js"; + +import type { LiveEvent as PhotonEvent } from "@photon-ai/advanced-imessage"; + +import { + and, + eq, + inArray, + isNotNull, + sql, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatActions, + chatConversations, + chatEndpointResources, + chatEndpoints, + chatExternalPrincipals, + chatPublications, + issues, + toolConnections, +} from "@paperclipai/db"; + +import { + HttpError, + forbidden, +} from "../../../errors.js"; + +import { + type ChatSdkMessageCallbackEvent, +} from "../../chat-sdk-runtime.js"; + +import { + logActivity, +} from "../../activity-log.js"; + +import { issueThreadInteractionService } from "../../issue-thread-interactions.js"; +import { + questionResponseDeliveryService, +} from "../../question-response-delivery.js"; + +import type { + Author, +} from "chat"; + +import type { EndpointRecord } from "../runtime-types.js"; + +type PhotonEventsOptions = { + runtimeCallbackRecord: (endpointId: string, context: RuntimeContext, statuses: EndpointRow["status"][]) => Promise<EndpointRecord | null>; + runtimeCallbackEndpoint: (tx: DbTransaction, endpointId: string, context: LifecycleRuntimeFence, statuses: EndpointRow["status"][]) => Promise<EndpointRow | null>; + invalidateRuntime: (endpointId: string) => Promise<boolean>; + ensurePrincipal: (endpoint: EndpointRow, author: Author, raw?: unknown) => Promise<{ principal: Pick<typeof chatExternalPrincipals.$inferSelect, "id" | "kind" | "isBot">; userId: string | null; linkedDenied: boolean }>; + requireCurrentExternalActionAuthorization: (tx: DbTransaction, input: { conversationId: string; endpointId: string; expectedUserId: string; principalId: string; runtimeContext: LifecycleRuntimeFence }) => Promise<void>; + isExternalActionAuthorizationChange: (error: unknown) => boolean; + scheduleMessageProcessing: (task: () => Promise<void>) => void; + processPendingPublications: () => Promise<unknown>; + questionResponses: Pick<ReturnType<typeof questionResponseDeliveryService>, "deliver">; + redactError: (error: unknown) => string; + renewDiscordGatewayOwnershipForMessageAdmission: (tx: DbTransaction, endpointId: string, context: RuntimeContext) => Promise<{ kind: "owned" | "lost" }>; + handleSdkMessage: (event: ChatSdkMessageCallbackEvent, runtimeContext?: RuntimeContext, messageOptions?: { receiptReactionSupported?: boolean; slackSlashControl?: boolean }) => Promise<void>; +}; + +export function createPhotonEvents(db: Db, options: PhotonEventsOptions) { + const { runtimeCallbackRecord, runtimeCallbackEndpoint, invalidateRuntime, ensurePrincipal, requireCurrentExternalActionAuthorization, isExternalActionAuthorizationChange, scheduleMessageProcessing, processPendingPublications, questionResponses, redactError, renewDiscordGatewayOwnershipForMessageAdmission, handleSdkMessage } = options; + + + async function handlePhotonFailure( + endpointId: string, + error: unknown, + context: RuntimeContext, + ) { + if (error instanceof PhotonError && error.code === "attachment_not_ready") + return; + error = photonFailure(error); + const fatal = + error instanceof PhotonError && + [ + "credentials", + "line_unavailable", + "history_gap", + "invalid_response", + ].includes(error.code); + const message = + error instanceof PhotonError + ? error.message + : "Photon connection interrupted; reconnecting"; + await db.transaction(async (tx) => { + const endpoint = await runtimeCallbackEndpoint(tx, endpointId, context, [ + "verifying", + "active", + "attention", + ]); + if (!endpoint) return; + await tx + .update(chatEndpoints) + .set({ + status: "attention", + healthMessage: message, + lastError: message, + updatedAt: new Date(), + }) + .where(eq(chatEndpoints.id, endpointId)); + if (fatal) + await tx + .update(toolConnections) + .set({ + enabled: false, + healthStatus: "error", + healthMessage: message, + }) + .where(eq(toolConnections.id, endpoint.connectionId)); + }); + // Retirement is queued outside the receiver callback to avoid self-joining. + void invalidateRuntime(endpointId).catch(() => undefined); + } + + async function processPhotonResponse( + endpoint: EndpointRow, + event: PhotonEvent, + threadId: string, + adapter: PhotonChatAdapter, + context: RuntimeContext, + ): Promise<boolean> { + const command = + event.type === "message.received" + ? photonResponseCommand(event.message.content.text ?? "") + : null; + const replyGuid = + event.type === "message.received" ? event.message.replyTargetGuid : null; + const prompt = + event.type === "poll.changed" + ? await adapter.state.read<PhotonPromptReceipt>( + `poll-message:${event.pollMessageGuid}`, + ) + : replyGuid + ? ((await adapter.state.read<PhotonPromptReceipt>( + `prompt-message:${replyGuid}`, + )) ?? + (await adapter.state.read<PhotonPromptReceipt>( + `poll-message:${replyGuid}`, + ))) + : null; + const reference = command?.reference ?? prompt?.reference; + if (!reference) { + if ( + !event.isFromMe && + (replyGuid || + (event.type === "poll.changed" && + (event.delta.type === "voted" || event.delta.type === "unvoted"))) + ) { + // A vote can beat the local poll-binding write. Keep it behind the + // checkpoint while a publication in this chat is in flight. + const pending = await db + .select({ id: chatPublications.id }) + .from(chatPublications) + .innerJoin( + chatConversations, + eq(chatConversations.id, chatPublications.conversationId), + ) + .where( + and( + eq(chatPublications.endpointId, endpoint.id), + eq(chatConversations.externalThreadId, threadId), + inArray(chatPublications.state, [ + "pending", + "retry", + "streaming", + "delivery_unknown", + ]), + isNotNull(sql`${chatPublications.payload}->>'interactionId'`), + ), + ) + .limit(1); + if (pending.length) + throw new PhotonError( + "attachment_not_ready", + "Waiting for Photon poll binding", + ); + } + return ( + event.type === "poll.changed" || + (event.type === "message.received" && + /^\/(answer|submit)\b/i.test(event.message.content.text ?? "")) + ); + } + const action = await db + .select() + .from(chatActions) + .where( + and( + eq(chatActions.companyId, endpoint.companyId), + eq(chatActions.endpointId, endpoint.id), + eq(chatActions.kind, "photon_interaction"), + eq(chatActions.providerActionId, `photon:${reference}`), + ), + ) + .then((rows) => rows[0]); + if (!action || action.status !== "issued" || !action.conversationId) + return true; + const binding = action.payload as unknown as PhotonInteractionBinding; + if ( + binding.version !== 1 || + binding.reference !== reference || + !Number.isFinite(Date.parse(binding.expiresAt)) || + Date.parse(binding.expiresAt) <= Date.now() + ) + return true; + const conversation = await db + .select() + .from(chatConversations) + .where( + and( + eq(chatConversations.companyId, endpoint.companyId), + eq(chatConversations.endpointId, endpoint.id), + eq(chatConversations.id, action.conversationId), + ), + ) + .then((rows) => rows[0]); + if ( + !conversation || + !["active", "waiting"].includes(conversation.state) || + conversation.sessionGeneration !== binding.sessionGeneration || + conversation.externalThreadId !== threadId + ) + return true; + const publication = await db + .select() + .from(chatPublications) + .where( + and( + eq(chatPublications.companyId, endpoint.companyId), + eq(chatPublications.endpointId, endpoint.id), + eq(chatPublications.id, binding.publicationId), + eq(chatPublications.conversationId, conversation.id), + ), + ) + .then((rows) => rows[0]); + if ( + !publication || + publication.payload.interactionId !== binding.interactionId || + new Date(event.occurredAt).getTime() < action.createdAt.getTime() + ) + return true; + if ( + ["pending", "retry", "streaming", "delivery_unknown"].includes( + publication.state, + ) + ) + throw new PhotonError( + "attachment_not_ready", + "Waiting for Photon prompt publication", + ); + if (publication.state !== "published") return true; + const interaction = ( + await issueThreadInteractionService(db).listForIssue(conversation.issueId) + ).find((candidate) => candidate.id === binding.interactionId); + if ( + !interaction || + interaction.status !== "pending" || + interaction.companyId !== endpoint.companyId || + interaction.createdByAgentId !== endpoint.assignedAgentId || + !nativePhotonInteraction(interaction) + ) + return true; + const actor = + event.type === "message.received" ? event.message.sender : event.actor; + if (event.isFromMe || !actor?.address || actor.service !== "iMessage") + return true; + const chat = await adapter.chatInfo(threadId); + if ( + chat.isArchived || + !chat.participants.some( + (person) => + person.address === actor.address && person.service === actor.service, + ) + ) + return true; + const principal = await ensurePrincipal( + endpoint, + { + userId: `${actor.service}:${actor.address}`, + userName: actor.address, + fullName: actor.address, + isMe: false, + isBot: false, + }, + event, + ); + if ( + !principal.userId || + principal.linkedDenied || + principal.principal.kind !== "user" || + principal.principal.isBot + ) + return true; + const assertCurrent = async () => + db.transaction((tx) => + requireCurrentExternalActionAuthorization(tx, { + conversationId: conversation.id, + endpointId: endpoint.id, + expectedUserId: principal.userId!, + principalId: principal.principal.id, + runtimeContext: context, + }), + ); + try { + await assertCurrent(); + } catch (error) { + if (isExternalActionAuthorizationChange(error)) return true; + throw error; + } + const draftKey = `draft:${reference}:${principal.principal.id}:${principal.userId}`; + let draft = (await adapter.state.read<PhotonDraft>(draftKey)) ?? { + schema: 1, + interactionId: interaction.id, + principalId: principal.principal.id, + userId: principal.userId, + answers: [], + lastSequence: 0, + }; + if (draft.lastSequence > event.sequence) return true; + const questionIndex = command?.questionIndex ?? prompt?.questionIndex ?? 0; + let answerValue = + command?.value ?? + (event.type === "message.received" + ? (event.message.content.text ?? "") + : ""); + let pollChoice: string | undefined; + if (event.type === "poll.changed") { + if (!prompt || event.pollMessageGuid !== prompt.pollMessageGuid) + return true; + if (event.delta.type !== "voted" && event.delta.type !== "unvoted") + return true; + pollChoice = prompt.options[event.delta.optionIdentifier]; + if (!pollChoice) return true; + if (event.delta.type === "unvoted") { + const questionId = + interaction.kind === "ask_user_questions" + ? interaction.payload.questions[questionIndex]?.id + : null; + await adapter.state.update<PhotonDraft>(draftKey, () => ({ + ...draft, + answers: draft.answers.filter( + (answer) => + answer.questionId !== questionId || + !answer.optionIds.includes(pollChoice!), + ), + decision: draft.decision === pollChoice ? undefined : draft.decision, + lastSequence: event.sequence, + })); + return true; + } + } + const enqueueResponse = async (key: string, text: string) => { + await assertCurrent(); + await db.transaction(async (tx) => { + await tx + .insert(chatActions) + .values({ + companyId: endpoint.companyId, + endpointId: endpoint.id, + conversationId: conversation.id, + kind: "photon_response_notice", + providerActionId: `photon-notice:${key}`, + payload: { + reference, + questionIndex, + publicationId: binding.publicationId, + }, + status: "processed", + }) + .onConflictDoNothing(); + await notifyChatPublicationWork(tx); + await tx + .insert(chatPublications) + .values({ + companyId: endpoint.companyId, + endpointId: endpoint.id, + conversationId: conversation.id, + issueId: conversation.issueId, + idempotencyKey: key, + payload: { + ...publication.payload, + card: undefined, + text: projectSafeChatPublicationText(text), + }, + state: "pending", + }) + .onConflictDoNothing(); + }); + scheduleMessageProcessing(() => + processPendingPublications().then(() => undefined), + ); + }; + const notice = (text: string) => + enqueueResponse(`photon-response:${reference}:${event.sequence}`, text); + try { + if (interaction.kind === "ask_user_questions") { + if (command?.command !== "submit") { + let answer: AskUserQuestionsAnswer; + if (pollChoice) { + const question = interaction.payload.questions[questionIndex]; + if ( + !question || + !question.options.some((option) => option.id === pollChoice) + ) + return true; + answer = { questionId: question.id, optionIds: [pollChoice] }; + } else + answer = parsePhotonQuestionAnswer( + interaction, + questionIndex, + answerValue, + ); + draft = { + ...draft, + answers: [ + ...draft.answers.filter( + (entry) => entry.questionId !== answer.questionId, + ), + answer, + ], + lastSequence: event.sequence, + }; + } + const answers = interaction.payload.questions.flatMap((question) => + draft.answers.filter((answer) => answer.questionId === question.id), + ); + if ( + interaction.payload.questions.length > 1 && + command?.command !== "submit" + ) { + await adapter.state.update<PhotonDraft>(draftKey, () => draft); + const next = interaction.payload.questions.findIndex( + (question) => + !answers.some((answer) => answer.questionId === question.id), + ); + if (next >= 0) + await enqueueResponse( + `photon-question:${reference}:${next}`, + "Input needed", + ); + else + await notice( + `Your answers are saved. Send /submit ${reference} to submit them.`, + ); + return true; + } + await validateNativeQuestionResponseInput(interaction, { answers }); + // Canonical service rechecks required fields, options, audience, and + // pending -> answered atomically with the response-delivery receipt. + const issue = await db + .select() + .from(issues) + .where( + and( + eq(issues.companyId, endpoint.companyId), + eq(issues.id, conversation.issueId), + ), + ) + .then((rows) => rows[0]); + if (!issue) return true; + const answered = await issueThreadInteractionService( + db, + ).answerQuestions( + issue, + interaction.id, + { answers }, + { userId: principal.userId }, + { + beforeResolveInTransaction: async (tx) => { + await requireCurrentExternalActionAuthorization(tx, { + conversationId: conversation.id, + endpointId: endpoint.id, + expectedUserId: principal.userId!, + principalId: principal.principal.id, + runtimeContext: context, + }); + const current = await tx + .select() + .from(chatConversations) + .where(eq(chatConversations.id, conversation.id)) + .then((rows) => rows[0]); + if ( + current?.sessionGeneration !== binding.sessionGeneration || + Date.parse(binding.expiresAt) <= Date.now() + ) + throw forbidden( + "Photon prompt expired or its task generation changed", + ); + }, + afterResolveInTransaction: async (tx, resolved) => { + const claimed = await tx + .update(chatActions) + .set({ + principalId: principal.principal.id, + status: "processed", + result: { + code: "photon_question_answered", + interactionId: resolved.id, + interactionStatus: resolved.status, + answersSha256: nativeSha256( + (resolved as AskUserQuestionsInteraction).result?.answers, + ), + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, action.id), + eq(chatActions.status, "issued"), + ), + ) + .returning({ id: chatActions.id }); + if (!claimed.length) + throw forbidden("Photon prompt has already been resolved"); + await logActivity(tx as unknown as Db, { + companyId: endpoint.companyId, + actorType: "user", + actorId: principal.userId!, + action: "issue.thread_interaction_answered", + entityType: "issue", + entityId: issue.id, + details: { + source: "external_chat", + provider: endpoint.provider, + endpointId: endpoint.id, + conversationId: conversation.id, + interactionId: resolved.id, + }, + }); + }, + }, + ); + scheduleMessageProcessing(async () => { + await questionResponses.deliver(answered.id); + await processPendingPublications(); + }); + } else { + if (command?.command === "submit") return true; + const matched = /^(accept|reject|1|2)(?:\s+([\s\S]+))?$/i.exec( + answerValue.trim(), + ); + const decision = + pollChoice === "accept" || pollChoice === "reject" + ? pollChoice + : matched + ? /^(accept|1)$/i.test(matched[1]) + ? "accept" + : "reject" + : draft.decision; + if (!decision) + throw new PhotonAnswerValidationError( + `Send /answer ${reference} Accept or /answer ${reference} Reject <reason>.`, + ); + const reason = + matched?.[2]?.trim() ?? + (draft.decision === "reject" && !pollChoice + ? answerValue.trim() + : ""); + if ( + decision === "reject" && + interaction.payload.rejectRequiresReason && + !reason + ) { + await adapter.state.update<PhotonDraft>(draftKey, () => ({ + ...draft, + decision, + lastSequence: event.sequence, + })); + await notice( + `Give a rejection reason with /answer ${reference} Reject <reason>.`, + ); + return true; + } + const issue = await db + .select() + .from(issues) + .where( + and( + eq(issues.companyId, endpoint.companyId), + eq(issues.id, conversation.issueId), + ), + ) + .then((rows) => rows[0]); + if (!issue) return true; + const mutation = { + beforeResolveInTransaction: async (tx: DbTransaction) => { + await requireCurrentExternalActionAuthorization(tx, { + conversationId: conversation.id, + endpointId: endpoint.id, + expectedUserId: principal.userId!, + principalId: principal.principal.id, + runtimeContext: context, + }); + const current = await tx + .select() + .from(chatConversations) + .where(eq(chatConversations.id, conversation.id)) + .then((rows) => rows[0]); + if ( + current?.sessionGeneration !== binding.sessionGeneration || + Date.parse(binding.expiresAt) <= Date.now() + ) + throw forbidden( + "Photon prompt expired or its task generation changed", + ); + }, + afterResolveInTransaction: async ( + tx: DbTransaction, + resolved: IssueThreadInteraction, + ) => { + const claimed = await tx + .update(chatActions) + .set({ + principalId: principal.principal.id, + status: "processed", + result: { + code: "photon_confirmation_resolved", + interactionId: resolved.id, + interactionStatus: resolved.status, + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, action.id), + eq(chatActions.status, "issued"), + ), + ) + .returning({ id: chatActions.id }); + if (!claimed.length) + throw forbidden("Photon prompt has already been resolved"); + await logActivity(tx as unknown as Db, { + companyId: endpoint.companyId, + actorType: "user", + actorId: principal.userId!, + action: + decision === "accept" + ? "issue.thread_interaction_accepted" + : "issue.thread_interaction_rejected", + entityType: "issue", + entityId: issue.id, + details: { + source: "external_chat", + provider: endpoint.provider, + endpointId: endpoint.id, + conversationId: conversation.id, + interactionId: resolved.id, + }, + }); + }, + }; + if (decision === "accept") + await issueThreadInteractionService(db).acceptInteraction( + issue, + interaction.id, + {}, + { userId: principal.userId }, + mutation, + ); + else + await issueThreadInteractionService(db).rejectInteraction( + issue, + interaction.id, + { reason }, + { userId: principal.userId }, + mutation, + ); + scheduleMessageProcessing(async () => { + await processPendingPublications(); + }); + } + } catch (error) { + if (error instanceof PhotonError) throw error; + if (isExternalActionAuthorizationChange(error)) return true; + if (error instanceof HttpError && [403, 404, 409].includes(error.status)) + return true; + // Only validator errors become correction copy. Infrastructure errors + // leave the checkpoint unchanged and retry through durable recovery. + if ( + (error instanceof HttpError && error.status === 422) || + error instanceof PhotonAnswerValidationError + ) { + const missingIndex = interaction.kind === "ask_user_questions" && command?.command === "submit" + ? interaction.payload.questions.findIndex((question) => + question.required !== false && !draft.answers.some((answer) => answer.questionId === question.id)) + : -1; + await notice( + `${redactError(error)} Send /answer ${reference}.${(missingIndex >= 0 ? missingIndex : questionIndex) + 1} <answer> to correct it.`, + ); + return true; + } + throw error; + } + return true; + } + + async function handlePhotonEvent( + endpointId: string, + event: PhotonEvent, + context: RuntimeContext, + ) { + const record = await runtimeCallbackRecord(endpointId, context, [ + "verifying", + "active", + "attention", + ]); + if (!record) + throw new Error("Photon receiver no longer owns this endpoint"); + const adapter = context.endpointRuntime!.getProviderAdapter(); + if (!(adapter instanceof PhotonChatAdapter)) + throw new Error("Photon adapter unavailable"); + if (adapter.authentication.identity.allocation === "shared" && event.type === "group.changed") return; + if ( + event.type === "group.changed" && + (event.change.type === "participantRemoved" || + event.change.type === "participantLeft") && + event.change.participant.address === record.endpoint.botExternalId + ) { + const removedThreadId = photonThreadId({ + lineId: adapter.authentication.identity.lineId, + chatGuid: event.chatGuid, + isGroup: true, + }); + await db.transaction(async (tx) => { + if ( + !(await runtimeCallbackEndpoint(tx, endpointId, context, [ + "verifying", + "active", + ])) + ) + throw new Error("Photon receiver is no longer current"); + if ( + ( + await renewDiscordGatewayOwnershipForMessageAdmission( + tx, + endpointId, + context, + ) + ).kind !== "owned" + ) + throw new Error("Photon receiver lease was replaced"); + // Removal can be the first retained event after activation. Preserve a + // tombstone even when the group has never been discovered locally. + await tx + .insert(chatEndpointResources) + .values({ + companyId: record.endpoint.companyId, + endpointId, + providerResourceId: removedThreadId, + type: "group_chat", + label: "Unavailable iMessage group", + enabled: false, + availability: "unavailable", + metadata: { photonRemoved: true }, + }) + .onConflictDoUpdate({ + target: [ + chatEndpointResources.endpointId, + chatEndpointResources.type, + chatEndpointResources.providerResourceId, + ], + set: { + availability: "unavailable", + metadata: sql`jsonb_set(${chatEndpointResources.metadata}, '{photonRemoved}', 'true'::jsonb)`, + updatedAt: new Date(), + }, + }); + }); + await adapter.endTyping(removedThreadId); + return; + } + const groupIdentity = photonThreadId({ lineId: adapter.authentication.identity.lineId, chatGuid: event.chatGuid, isGroup: true }); + const removedGroup = await db.select({ metadata: chatEndpointResources.metadata }).from(chatEndpointResources).where(and(eq(chatEndpointResources.companyId, record.endpoint.companyId), eq(chatEndpointResources.endpointId, endpointId), eq(chatEndpointResources.providerResourceId, groupIdentity))).limit(1).then((rows) => rows[0]); + const readded = event.type === "group.changed" && event.change.type === "participantAdded" && event.change.participant.address === record.endpoint.botExternalId; + // Late events from a removed chat must still advance recovery without a + // now-forbidden chat lookup holding every other conversation behind them. + if (removedGroup?.metadata.photonRemoved === true && !readded) return; + const chat = await adapter.client.chats + .get(event.chatGuid) + .catch((error) => { + throw photonFailure(error); + }); + if (chat.guid !== event.chatGuid || chat.service !== "iMessage" || (chat.isGroup && adapter.authentication.identity.allocation === "shared")) return; + const threadId = photonThreadId({ + lineId: adapter.authentication.identity.lineId, + chatGuid: chat.guid, + isGroup: chat.isGroup, + }); + const removed = + event.type === "group.changed" && + (event.change.type === "participantRemoved" || + event.change.type === "participantLeft") && + event.change.participant.address === record.endpoint.botExternalId; + const available = await db.transaction(async (tx) => { + if ( + !(await runtimeCallbackEndpoint(tx, endpointId, context, [ + "verifying", + "active", + ])) + ) + throw new Error("Photon receiver is no longer current"); + const ownership = await renewDiscordGatewayOwnershipForMessageAdmission( + tx, + endpointId, + context, + ); + if (ownership.kind !== "owned") + throw new Error("Photon receiver lease was replaced"); + const existing = await tx + .select() + .from(chatEndpointResources) + .where( + and( + eq(chatEndpointResources.endpointId, endpointId), + eq(chatEndpointResources.providerResourceId, threadId), + eq( + chatEndpointResources.type, + chat.isGroup ? "group_chat" : "direct_message", + ), + ), + ) + .then((rows) => rows[0]); + const added = + event.type === "group.changed" && + event.change.type === "participantAdded" && + event.change.participant.address === record.endpoint.botExternalId; + const stillRemoved = + removed || (existing?.metadata.photonRemoved === true && !added); + const availability = + stillRemoved || chat.isArchived ? "unavailable" : "available"; + const metadata = { + photonRemoved: stillRemoved, + participants: chat.participants + .slice(0, 256) + .map((p) => ({ address: p.address, service: p.service })), + }; + const label = ( + chat.displayName || chat.participants.map((p) => p.address).join(", ") + ).slice(0, 512); + await tx + .insert(chatEndpointResources) + .values({ + companyId: record.endpoint.companyId, + endpointId, + providerResourceId: threadId, + type: chat.isGroup ? "group_chat" : "direct_message", + label, + enabled: !chat.isGroup, + availability, + metadata, + }) + .onConflictDoUpdate({ + target: [ + chatEndpointResources.endpointId, + chatEndpointResources.type, + chatEndpointResources.providerResourceId, + ], + set: { label, availability, metadata, updatedAt: new Date() }, + }); + return availability === "available"; + }); + if (!available) return; + if ( + !event.isFromMe && + (await processPhotonResponse( + record.endpoint, + event, + threadId, + adapter, + context, + )) + ) + return; + if (event.type === "message.received" && !event.isFromMe) { + await handleSdkMessage( + { + provider: "imessage-photon", + endpointId, + thread: context.endpointRuntime!.thread(threadId), + message: adapter.normalize(event.message, chat), + trigger: chat.isGroup ? "unaddressed_message" : "direct_message", + }, + context, + ); + } + } + return { handlePhotonFailure, handlePhotonEvent }; +} diff --git a/server/src/services/chat-channels/microsoft-teams/file-transfer-authority.ts b/server/src/services/chat-channels/microsoft-teams/file-transfer-authority.ts new file mode 100644 index 00000000000..ff58df90f2f --- /dev/null +++ b/server/src/services/chat-channels/microsoft-teams/file-transfer-authority.ts @@ -0,0 +1,1041 @@ +import type { EndpointRow, DbTransaction, DbOrTransaction, RuntimeContext, LifecycleRuntimeFence } from "../../chat-channels/runtime-types.js"; +import { createChatFailedRunRetry, type ChatFailedRetrySource as FailedChatRetrySource } from "../../chat-channels/failed-run-retry.js"; + +import { type ChatRuntime } from "../../chat-runtime.js"; + +import { + createHash, +} from "node:crypto"; +import { + and, + asc, + eq, + gt, + inArray, + isNotNull, + isNull, + ne, + or, + sql, +} from "drizzle-orm"; + +import { + canonicalTeamsThreadId, +} from "../../chat-control-chronology.js"; + +import type { Db } from "@paperclipai/db"; +import { + createDurableChatWakeupRequest, +} from "../../durable-chat-wakeup.js"; +import { + agentWakeupRequests, + assets, + chatActions, + chatConversations, + chatDeliveries, + chatEndpointResources, + chatEndpoints, + chatExternalPrincipals, + chatIdentityLinks, + chatMessageLinks, + chatPublications, + companyMemberships, + heartbeatRuns, + issueComments, + issueAttachments, + issues, + toolConnections, +} from "@paperclipai/db"; +import type { + ChatEndpointSetupState, + ToolCredentialSecretRef, +} from "@paperclipai/shared"; +import { + isUuidLike, +} from "@paperclipai/shared"; +import { + isAllowedContentType, + MAX_ATTACHMENT_BYTES, +} from "../../../attachment-types.js"; + +import { + bindTeamsPersonalRecipient, + parseTeamsPersonalRecipient, +} from "../../chat-teams-personal-recipient.js"; +import { + type TeamsFileTransferAuthority, +} from "../../chat-teams-file-transfers.js"; + +import { + HttpError, + conflict, +} from "../../../errors.js"; + +import { + type ChatSdkRuntime, +} from "../../chat-sdk-runtime.js"; + +import { + authorizeNativeChatReviewPresentation, + NativeChatReviewPresentationContentionError, +} from "../../native-runtime/native-chat-review-presentation.js"; + +type ConversationRow = typeof chatConversations.$inferSelect; +type TeamsFileAuthorityOptions = { + runtimeGeneration: (setup: ChatEndpointSetupState) => number; + credentialFingerprint: (refs: ToolCredentialSecretRef[]) => string; + runtime: Pick<ChatSdkRuntime | ChatRuntime, "get">; + runtimeContexts: Pick<WeakMap<object, RuntimeContext>, "get">; + localRuntimeEpoch: (endpointId: string) => number; + runtimeVersions: ReadonlyMap<string, string>; + lockCurrentPrincipalAuthorization: (tx: DbOrTransaction, endpoint: EndpointRow, principalId: string) => Promise<{ allowed: boolean; userId: string | null }>; + lifecycleRuntimeFence: (delivery: typeof chatDeliveries.$inferSelect) => LifecycleRuntimeFence | null; + assertFailedRetryReceipt: ReturnType<typeof createChatFailedRunRetry>["assertFailedRetryReceipt"]; + inboundWakeScope: (action: typeof chatActions.$inferSelect) => string; + failedChatRetrySource: ReturnType<typeof createChatFailedRunRetry>["failedChatRetrySource"]; + isExplicitOperatorPublication: (publication: typeof chatPublications.$inferSelect) => boolean; + retryCommentIds: ReturnType<typeof createChatFailedRunRetry>["retryCommentIds"]; +}; + +export function createTeamsFileAuthority(options: TeamsFileAuthorityOptions) { + const { runtimeGeneration, credentialFingerprint, runtime, runtimeContexts, localRuntimeEpoch, runtimeVersions, lockCurrentPrincipalAuthorization, lifecycleRuntimeFence, assertFailedRetryReceipt, inboundWakeScope, failedChatRetrySource, isExplicitOperatorPublication, retryCommentIds } = options; + + + // Native Teams file consent is authority for one personal recipient and one + // immutable file, not authority inferred from the SDK's latest route cache. + const teamsBoardFileIntentKind = "teams_board_file_intent"; + const teamsFileProofHash = (value: unknown) => + createHash("sha256") + .update( + JSON.stringify(value, (_key, entry) => + entry && typeof entry === "object" && !Array.isArray(entry) + ? Object.fromEntries( + Object.keys(entry) + .sort() + .map((key) => [key, entry[key]]), + ) + : entry, + ), + ) + .digest("hex"); + + async function teamsFileCurrentScope( + tx: DbTransaction, + supplied: typeof chatPublications.$inferSelect, + fence?: LifecycleRuntimeFence, + ) { + const [endpoint] = await tx + .select() + .from(chatEndpoints) + .where( + and( + eq(chatEndpoints.companyId, supplied.companyId), + eq(chatEndpoints.id, supplied.endpointId), + ), + ) + .for("no key update", { noWait: true }); + if ( + !endpoint || + endpoint.provider !== "microsoft-teams" || + endpoint.status !== "active" || + !endpoint.allowDirectMessages || + !isUuidLike(endpoint.providerAccountId) || + !isUuidLike(endpoint.botExternalId) + ) + return null; + const [connection] = await tx + .select() + .from(toolConnections) + .where( + and( + eq(toolConnections.companyId, endpoint.companyId), + eq(toolConnections.id, endpoint.connectionId), + ), + ) + .for("share", { noWait: true }); + if (!connection?.enabled || connection.status !== "active") return null; + const currentFence = { + generation: runtimeGeneration(endpoint.setup), + credentialFingerprint: credentialFingerprint( + connection.credentialSecretRefs, + ), + }; + const instance = runtime.get(endpoint.id); + const registration = instance + ? runtimeContexts.get(instance as object) + : null; + if ( + !instance || + !registration || + registration.generation !== currentFence.generation || + registration.credentialFingerprint !== + currentFence.credentialFingerprint || + registration.localEpoch !== localRuntimeEpoch(endpoint.id) || + runtimeVersions.get(endpoint.id) !== registration.version || + (fence && + (fence.generation !== currentFence.generation || + fence.credentialFingerprint !== currentFence.credentialFingerprint)) + ) + return null; + const [conversation] = await tx + .select() + .from(chatConversations) + .where( + and( + eq(chatConversations.companyId, endpoint.companyId), + eq(chatConversations.endpointId, endpoint.id), + eq(chatConversations.id, supplied.conversationId), + eq(chatConversations.issueId, supplied.issueId), + ), + ) + .for("update", { noWait: true }); + if ( + !conversation?.isDirectMessage || + !["active", "waiting"].includes(conversation.state) + ) + return null; + const [superseded] = await tx + .select({ id: chatConversations.id }) + .from(chatConversations) + .where( + and( + eq(chatConversations.companyId, endpoint.companyId), + eq(chatConversations.endpointId, endpoint.id), + eq(chatConversations.externalThreadId, conversation.externalThreadId), + gt( + chatConversations.sessionGeneration, + conversation.sessionGeneration, + ), + ), + ) + .limit(1); + if (superseded) return null; + const [issue] = await tx + .select({ id: issues.id, assigneeAgentId: issues.assigneeAgentId }) + .from(issues) + .where( + and( + eq(issues.companyId, endpoint.companyId), + eq(issues.id, conversation.issueId), + ), + ) + .for("share", { noWait: true }); + if (!issue || issue.assigneeAgentId !== endpoint.assignedAgentId) + return null; + // The conversation lock serializes rollover. The endpoint lock serializes + // reach/credential changes; no lock here waits behind inverse Board order. + if (conversation.resourceId) + await tx + .select({ id: chatEndpointResources.id }) + .from(chatEndpointResources) + .where( + and( + eq(chatEndpointResources.companyId, endpoint.companyId), + eq(chatEndpointResources.endpointId, endpoint.id), + eq(chatEndpointResources.id, conversation.resourceId), + ), + ) + .for("update", { noWait: true }); + const [publication] = await tx + .select() + .from(chatPublications) + .where( + and( + eq(chatPublications.companyId, endpoint.companyId), + eq(chatPublications.endpointId, endpoint.id), + eq(chatPublications.conversationId, conversation.id), + eq(chatPublications.issueId, conversation.issueId), + eq(chatPublications.id, supplied.id), + ), + ) + .for("update", { noWait: true }); + const ids = publication?.payload.attachmentIds; + if ( + !publication?.commentId || + !Array.isArray(ids) || + ids.length !== 1 || + !isUuidLike(ids[0]) || + publication.payload.progressState !== undefined || + publication.payload.interactionId || + publication.payload.card || + publication.idempotencyKey.startsWith("control:") || + publication.idempotencyKey.startsWith("wake:") + ) + return null; + const [comment] = await tx + .select() + .from(issueComments) + .where( + and( + eq(issueComments.companyId, endpoint.companyId), + eq(issueComments.issueId, conversation.issueId), + eq(issueComments.id, publication.commentId), + isNull(issueComments.deletedAt), + ), + ) + .for("share", { noWait: true }); + const [file] = await tx + .select({ attachment: issueAttachments, asset: assets }) + .from(issueAttachments) + .innerJoin( + assets, + and( + eq(assets.id, issueAttachments.assetId), + eq(assets.companyId, endpoint.companyId), + ), + ) + .where( + and( + eq(issueAttachments.companyId, endpoint.companyId), + eq(issueAttachments.issueId, conversation.issueId), + eq(issueAttachments.issueCommentId, publication.commentId), + eq(issueAttachments.id, ids[0]), + ), + ) + .for("share", { noWait: true }); + const filename = + file?.asset.originalFilename ?? + (file ? `attachment-${file.attachment.id}` : ""); + if ( + !comment || + comment.updatedAt.getTime() !== comment.createdAt.getTime() || + !file || + !Number.isSafeInteger(file.asset.byteSize) || + file.asset.byteSize <= 0 || + file.asset.byteSize > MAX_ATTACHMENT_BYTES || + file.asset.byteSize >= 60 * 1024 * 1024 || + !/^[a-f0-9]{64}$/.test(file.asset.sha256) || + !file.asset.objectKey.trim() || + !isAllowedContentType(file.asset.contentType) || + !filename || + filename.length > 255 || + filename !== filename.trim() || + filename.endsWith(".") || + /[<>:"/\\|?*\x00-\x1f\x7f]/.test(filename) + ) + return null; + const byteIdentity = { + attachmentId: file.attachment.id, + assetId: file.asset.id, + sha256: file.asset.sha256, + byteSize: file.asset.byteSize, + filename, + contentType: file.asset.contentType, + originatingRunId: file.attachment.originatingRunId, + }; + return { + endpoint, + conversation, + publication, + comment, + file, + filename, + currentFence, + byteIdentity, + instance, + }; + } + + async function teamsFilePrincipalAuthorization( + tx: DbTransaction, + endpoint: EndpointRow, + principalId: string, + ) { + const identity = await tx.execute( + sql`select pg_try_advisory_xact_lock(hashtextextended(${`chat-identity:${endpoint.companyId}:${principalId}`}, 0)) as locked`, + ); + if (!identity[0]?.locked) + throw new NativeChatReviewPresentationContentionError(); + const [link] = await tx + .select() + .from(chatIdentityLinks) + .where( + and( + eq(chatIdentityLinks.companyId, endpoint.companyId), + eq(chatIdentityLinks.endpointId, endpoint.id), + eq(chatIdentityLinks.principalId, principalId), + ), + ) + .for("update", { noWait: true }); + const userId = + link?.status === "linked" ? link.paperclipUserId : endpoint.sponsorUserId; + if (userId) + await tx + .select() + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, endpoint.companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, userId), + ), + ) + .for("update", { noWait: true }); + // All rows and the identity advisory key used by the common authorization + // helper are already held; the helper cannot introduce an inverse wait. + return lockCurrentPrincipalAuthorization(tx, endpoint, principalId); + } + + async function teamsFileSourceBinding( + tx: DbTransaction, + scope: NonNullable<Awaited<ReturnType<typeof teamsFileCurrentScope>>>, + actionId: string, + ) { + const { endpoint, conversation } = scope; + const [action] = await tx + .select() + .from(chatActions) + .where( + and( + eq(chatActions.companyId, endpoint.companyId), + eq(chatActions.endpointId, endpoint.id), + eq(chatActions.conversationId, conversation.id), + eq(chatActions.id, actionId), + eq(chatActions.kind, "inbound_wakeup"), + eq(chatActions.status, "processed"), + ), + ) + .for("update", { noWait: true }); + if ( + !action?.deliveryId || + !action.principalId || + action.payload.version !== 1 || + action.payload.issueId !== conversation.issueId || + action.payload.agentId !== endpoint.assignedAgentId || + action.payload.sessionGeneration !== conversation.sessionGeneration || + typeof action.payload.commentId !== "string" || + !isUuidLike(action.payload.commentId) + ) + return null; + const [delivery] = await tx + .select() + .from(chatDeliveries) + .where( + and( + eq(chatDeliveries.companyId, endpoint.companyId), + eq(chatDeliveries.endpointId, endpoint.id), + eq(chatDeliveries.conversationId, conversation.id), + eq(chatDeliveries.id, action.deliveryId), + eq(chatDeliveries.principalId, action.principalId), + eq(chatDeliveries.state, "processed"), + inArray(chatDeliveries.eventKind, [ + "direct_message", + "mention", + "message", + ]), + ), + ) + .for("update", { noWait: true }); + const originalFence = delivery ? lifecycleRuntimeFence(delivery) : null; + const [principal] = await tx + .select() + .from(chatExternalPrincipals) + .where( + and( + eq(chatExternalPrincipals.companyId, endpoint.companyId), + eq(chatExternalPrincipals.id, action.principalId), + eq(chatExternalPrincipals.provider, "microsoft-teams"), + eq( + chatExternalPrincipals.providerAccountId, + endpoint.providerAccountId!, + ), + eq(chatExternalPrincipals.kind, "user"), + eq(chatExternalPrincipals.isBot, false), + ), + ) + .for("share", { noWait: true }); + if (!delivery || !originalFence || !principal) return null; + const admission = { + companyId: endpoint.companyId, + endpointId: endpoint.id, + runtimeGeneration: originalFence.generation, + credentialFingerprint: originalFence.credentialFingerprint, + tenantId: endpoint.providerAccountId!, + botAppId: endpoint.botExternalId!, + providerEventId: delivery.providerEventId, + threadId: conversation.externalThreadId, + isDirectMessage: true, + }; + const parsed = parseTeamsPersonalRecipient( + delivery.normalizedEvent.teamsPersonalRecipient, + admission, + ); + const binding = parsed + ? bindTeamsPersonalRecipient(parsed, { + admission, + deliveryId: delivery.id, + principalId: principal.id, + conversationId: conversation.id, + conversationGeneration: conversation.sessionGeneration, + externalPrincipalId: principal.externalId, + }) + : null; + const originalConversation = delivery.normalizedEvent.conversation as + | Record<string, unknown> + | undefined; + const originalMessage = delivery.normalizedEvent.message as + | Record<string, unknown> + | undefined; + const originalPrincipal = delivery.normalizedEvent.principal as + | Record<string, unknown> + | undefined; + if ( + !binding || + originalConversation?.isDirectMessage !== true || + typeof originalConversation.externalThreadId !== "string" || + canonicalTeamsThreadId(originalConversation.externalThreadId) !== + canonicalTeamsThreadId(conversation.externalThreadId) || + originalMessage?.providerMessageId !== + binding.recipient.providerActivityId || + originalPrincipal?.externalId !== principal.externalId + ) + return null; + const [source] = await tx + .select({ comment: issueComments, link: chatMessageLinks }) + .from(chatMessageLinks) + .innerJoin( + issueComments, + and( + eq(issueComments.id, chatMessageLinks.commentId), + eq(issueComments.companyId, endpoint.companyId), + eq(issueComments.issueId, conversation.issueId), + ), + ) + .where( + and( + eq(chatMessageLinks.companyId, endpoint.companyId), + eq(chatMessageLinks.endpointId, endpoint.id), + eq(chatMessageLinks.conversationId, conversation.id), + eq(chatMessageLinks.deliveryId, delivery.id), + eq(chatMessageLinks.commentId, action.payload.commentId), + eq(chatMessageLinks.direction, "inbound"), + ), + ) + .for("share", { noWait: true }); + const expectedUserId = + action.payload.requestedByActorType === "user" + ? action.payload.requestedByActorId + : null; + if ( + !source || + source.link.providerMessageId !== binding.recipient.providerActivityId || + source.comment.deletedAt || + source.comment.createdAt > scope.comment.createdAt || + source.comment.updatedAt.getTime() !== + source.comment.createdAt.getTime() || + source.comment.authorUserId !== expectedUserId || + !["user", "system"].includes( + String(action.payload.requestedByActorType), + ) || + (expectedUserId === null && + action.payload.requestedByActorId !== principal.id) + ) + return null; + const [lifecycle] = await tx + .select({ id: chatDeliveries.id }) + .from(chatDeliveries) + .where( + and( + eq(chatDeliveries.companyId, endpoint.companyId), + eq(chatDeliveries.endpointId, endpoint.id), + or( + isNull(chatDeliveries.conversationId), + eq(chatDeliveries.conversationId, conversation.id), + ), + ne(chatDeliveries.state, "filtered"), + inArray(chatDeliveries.eventKind, [ + "message_updated", + "message_deleted", + "message_restored", + ]), + sql`${chatDeliveries.normalizedEvent}->'runtimeContext' = ${JSON.stringify(originalFence)}::jsonb`, + sql`${chatDeliveries.normalizedEvent}->'message'->>'targetProviderEventId' = ${delivery.providerEventId}`, + ), + ) + .limit(1); + if (lifecycle) return null; + const authorization = await teamsFilePrincipalAuthorization( + tx, + endpoint, + principal.id, + ); + if (!authorization.allowed || authorization.userId !== expectedUserId) + return null; + const [receipt] = await tx + .select() + .from(agentWakeupRequests) + .where( + and( + eq(agentWakeupRequests.companyId, endpoint.companyId), + eq(agentWakeupRequests.id, action.id), + eq(agentWakeupRequests.agentId, endpoint.assignedAgentId), + ), + ) + .for("share", { noWait: true }); + if (!receipt || ["skipped", "cancelled"].includes(receipt.status)) + return null; + try { + assertFailedRetryReceipt( + createDurableChatWakeupRequest({ + id: action.id, + companyId: endpoint.companyId, + agentId: endpoint.assignedAgentId, + issueId: conversation.issueId, + commentId: source.comment.id, + requestedByActorType: action.payload.requestedByActorType as + | "user" + | "system", + requestedByActorId: String(action.payload.requestedByActorId), + requestedAt: action.createdAt, + authorize: async () => {}, + }), + receipt, + ); + } catch (error) { + if ( + error instanceof HttpError && + (error.details as { code?: string } | undefined)?.code === + "chat_failed_run_retry_not_authorized" + ) + return null; + throw error; + } + return { + binding, + authorization, + action, + sourceFacts: { + actionId: action.id, + deliveryId: delivery.id, + commentId: source.comment.id, + principalId: principal.id, + scope: inboundWakeScope(action), + originalFence, + bodySha256: createHash("sha256") + .update(source.comment.body) + .digest("hex"), + receiptId: receipt.id, + recipientBindingSha256: teamsFileProofHash(binding), + }, + }; + } + + async function teamsBoardOriginBinding( + tx: DbTransaction, + scope: NonNullable<Awaited<ReturnType<typeof teamsFileCurrentScope>>>, + ) { + // Pick the task's first admitted source, never the latest DM actor. If the + // durable task contains different recipient principals, consent is ambiguous. + const principals = await tx + .selectDistinct({ id: chatActions.principalId }) + .from(chatActions) + .where( + and( + eq(chatActions.companyId, scope.endpoint.companyId), + eq(chatActions.endpointId, scope.endpoint.id), + eq(chatActions.conversationId, scope.conversation.id), + eq(chatActions.kind, "inbound_wakeup"), + eq(chatActions.status, "processed"), + sql`${chatActions.payload}->>'sessionGeneration' = ${String(scope.conversation.sessionGeneration)}`, + ), + ) + .limit(2); + if (principals.length !== 1 || !principals[0]?.id) return null; + const [origin] = await tx + .select({ id: chatActions.id }) + .from(chatActions) + .where( + and( + eq(chatActions.companyId, scope.endpoint.companyId), + eq(chatActions.endpointId, scope.endpoint.id), + eq(chatActions.conversationId, scope.conversation.id), + eq(chatActions.kind, "inbound_wakeup"), + eq(chatActions.status, "processed"), + sql`${chatActions.payload}->>'sessionGeneration' = ${String(scope.conversation.sessionGeneration)}`, + ), + ) + .orderBy(asc(chatActions.createdAt), asc(chatActions.id)) + .limit(1); + return origin ? teamsFileSourceBinding(tx, scope, origin.id) : null; + } + + async function teamsBoardAuthorIsCurrent( + tx: DbTransaction, + companyId: string, + userId: string, + ) { + const [membership] = await tx + .select() + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, userId), + ), + ) + .for("share", { noWait: true }); + return ( + membership?.status === "active" && membership.membershipRole !== "viewer" + ); + } + + function teamsBoardFileIntentPayload( + scope: NonNullable<Awaited<ReturnType<typeof teamsFileCurrentScope>>>, + source: NonNullable<Awaited<ReturnType<typeof teamsFileSourceBinding>>>, + userId: string, + ) { + return { + schema: "paperclip.teams.board-file-intent.v1", + publicationId: scope.publication.id, + issueId: scope.conversation.issueId, + commentId: scope.comment.id, + attachmentId: scope.file.attachment.id, + userId, + conversationGeneration: scope.conversation.sessionGeneration, + sourceActionId: source.action.id, + sourceScopeSha256: teamsFileProofHash(source.sourceFacts), + recipientBindingSha256: teamsFileProofHash(source.binding), + byteIdentitySha256: teamsFileProofHash(scope.byteIdentity), + commentBodySha256: createHash("sha256") + .update(scope.comment.body) + .digest("hex"), + }; + } + + function teamsFileRuntimeStillCurrent( + scope: NonNullable<Awaited<ReturnType<typeof teamsFileCurrentScope>>>, + ) { + const registration = runtimeContexts.get(scope.instance as object); + return ( + runtime.get(scope.endpoint.id) === scope.instance && + registration?.generation === scope.currentFence.generation && + registration.credentialFingerprint === + scope.currentFence.credentialFingerprint && + registration.localEpoch === localRuntimeEpoch(scope.endpoint.id) && + runtimeVersions.get(scope.endpoint.id) === registration.version + ); + } + + async function stageTeamsBoardFileIntent( + tx: DbTransaction, + publication: typeof chatPublications.$inferSelect, + userId: string, + conversation: ConversationRow, + ): Promise<void> { + if ( + !publication.idempotencyKey.startsWith("explicit-board:") || + !conversation.isDirectMessage + ) + return; + const scope = await teamsFileCurrentScope(tx, publication); + if ( + !scope || + scope.conversation.id !== conversation.id || + scope.conversation.sessionGeneration !== conversation.sessionGeneration || + scope.comment.authorUserId !== userId || + scope.comment.authorAgentId || + scope.comment.createdByRunId || + !(await teamsBoardAuthorIsCurrent(tx, scope.endpoint.companyId, userId)) + ) + return; + const source = await teamsBoardOriginBinding(tx, scope); + if (!source || !teamsFileRuntimeStillCurrent(scope)) return; + const key = `${teamsBoardFileIntentKind}:${publication.id}`; + const payload = teamsBoardFileIntentPayload(scope, source, userId); + const [existing] = await tx + .select() + .from(chatActions) + .where( + and( + eq(chatActions.companyId, scope.endpoint.companyId), + eq(chatActions.endpointId, scope.endpoint.id), + eq(chatActions.providerActionId, key), + ), + ) + .for("update", { noWait: true }); + if (existing) { + if ( + existing.kind !== teamsBoardFileIntentKind || + existing.status !== "processed" || + existing.deliveryId !== source.binding.deliveryId || + existing.principalId !== source.binding.principalId || + existing.conversationId !== scope.conversation.id || + teamsFileProofHash(existing.payload) !== teamsFileProofHash(payload) + ) + throw conflict("The Teams file recipient intent has changed", { + code: "chat_file_transfer_authority_changed", + }); + return; + } + await tx.insert(chatActions).values({ + companyId: scope.endpoint.companyId, + endpointId: scope.endpoint.id, + conversationId: scope.conversation.id, + deliveryId: source.binding.deliveryId, + principalId: source.binding.principalId, + kind: teamsBoardFileIntentKind, + providerActionId: key, + status: "processed", + payload, + }); + } + + async function deriveTeamsFileTransferAuthority( + tx: DbTransaction, + publication: typeof chatPublications.$inferSelect, + fence: LifecycleRuntimeFence, + continuingTeamsFileTransfer?: NonNullable< + NonNullable< + Parameters<typeof failedChatRetrySource>[1]["committedResponse"] + >["continuingTeamsFileTransfer"] + >, + ): Promise<TeamsFileTransferAuthority | null> { + const scope = await teamsFileCurrentScope(tx, publication, fence); + if (!scope) return null; + let source: NonNullable<Awaited<ReturnType<typeof teamsFileSourceBinding>>>; + let causal: Record<string, unknown>; + if (isExplicitOperatorPublication(scope.publication)) { + if ( + !scope.publication.idempotencyKey.startsWith("explicit-board:") || + !scope.comment.authorUserId || + scope.comment.authorAgentId || + scope.comment.createdByRunId || + !(await teamsBoardAuthorIsCurrent( + tx, + scope.endpoint.companyId, + scope.comment.authorUserId, + )) + ) + return null; + const [intent] = await tx + .select() + .from(chatActions) + .where( + and( + eq(chatActions.companyId, scope.endpoint.companyId), + eq(chatActions.endpointId, scope.endpoint.id), + eq(chatActions.conversationId, scope.conversation.id), + eq(chatActions.kind, teamsBoardFileIntentKind), + eq( + chatActions.providerActionId, + `${teamsBoardFileIntentKind}:${scope.publication.id}`, + ), + eq(chatActions.status, "processed"), + ), + ) + .for("share", { noWait: true }); + if ( + !intent || + typeof intent.payload.sourceActionId !== "string" || + !isUuidLike(intent.payload.sourceActionId) + ) + return null; + const bound = await teamsBoardOriginBinding(tx, scope); + if ( + !bound || + bound.action.id !== intent.payload.sourceActionId || + intent.deliveryId !== bound.binding.deliveryId || + intent.principalId !== bound.binding.principalId + ) + return null; + const currentPayload = teamsBoardFileIntentPayload( + scope, + bound, + scope.comment.authorUserId, + ); + if ( + teamsFileProofHash(intent.payload) !== + teamsFileProofHash(currentPayload) + ) + return null; + source = bound; + causal = { + kind: "explicit_board_send", + intentId: intent.id, + ...currentPayload, + }; + } else { + const runId = scope.comment.createdByRunId; + if ( + !runId || + scope.comment.authorAgentId !== scope.endpoint.assignedAgentId || + scope.file.attachment.originatingRunId !== runId + ) + return null; + const [run] = await tx + .select() + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.companyId, scope.endpoint.companyId), + eq(heartbeatRuns.id, runId), + eq(heartbeatRuns.agentId, scope.endpoint.assignedAgentId), + eq(heartbeatRuns.nativeIssueId, scope.conversation.issueId), + eq(heartbeatRuns.runtimeMode, "native"), + eq(heartbeatRuns.status, "succeeded"), + isNotNull(heartbeatRuns.finishedAt), + ), + ) + .for("share", { noWait: true }); + const marker = run?.resultJson?.nativeCommittedChatResponse as + | Record<string, unknown> + | undefined; + if ( + !run || + !marker || + marker.schema !== "paperclip.native_committed_chat_response.v1" || + typeof marker.resultId !== "string" || + !isUuidLike(marker.resultId) || + typeof marker.decisionId !== "string" || + !isUuidLike(marker.decisionId) || + typeof marker.canonicalSha256 !== "string" || + !/^(?:sha256:)?[a-f0-9]{64}$/.test(marker.canonicalSha256) + ) + return null; + // Pre-lock the exact causal source and actor rows before the existing + // complete native publication proof invokes its common authorization. + const originActions = await tx + .select({ id: chatActions.id }) + .from(chatActions) + .where( + and( + eq(chatActions.companyId, scope.endpoint.companyId), + eq(chatActions.conversationId, scope.conversation.id), + eq(chatActions.kind, "inbound_wakeup"), + inArray( + sql<string>`${chatActions.payload}->>'commentId'`, + retryCommentIds(run.contextSnapshot ?? {}), + ), + ), + ); + const bindings: Array< + NonNullable<Awaited<ReturnType<typeof teamsFileSourceBinding>>> + > = []; + for (const action of originActions) { + const binding = await teamsFileSourceBinding(tx, scope, action.id); + if (!binding) return null; + bindings.push(binding); + } + let proved: FailedChatRetrySource; + try { + proved = await failedChatRetrySource(tx, { + companyId: scope.endpoint.companyId, + issueId: scope.conversation.issueId, + agentId: run.agentId, + failedRunId: run.id, + publication: true, + committedResponse: { + resultId: marker.resultId, + canonicalSha256: marker.canonicalSha256, + decisionId: marker.decisionId, + continuingTeamsFileTransfer, + }, + }); + } catch (error) { + if ( + error instanceof HttpError && + (error.details as { code?: string } | undefined)?.code === + "chat_failed_run_retry_not_authorized" + ) + return null; + throw error; + } + if ( + proved.provider !== "microsoft-teams" || + proved.endpointId !== scope.endpoint.id || + proved.conversationId !== scope.conversation.id || + proved.sessionGeneration !== scope.conversation.sessionGeneration || + bindings.length !== proved.sources.length + ) + return null; + const ordered = proved.sources.map((entry) => + bindings.find((binding) => binding.action.id === entry.actionId), + ); + const first = ordered[0]; + if ( + !first || + ordered.some( + (entry) => + !entry || + entry.binding.principalId !== proved.principalId || + entry.binding.recipient.providerConversationId !== + first.binding.recipient.providerConversationId || + entry.binding.recipient.providerUserId !== + first.binding.recipient.providerUserId || + entry.binding.recipient.aadObjectId !== + first.binding.recipient.aadObjectId, + ) + ) + return null; + if ( + run.resultJson?.finalizationReasonCode === + "governed_response_waiting" && + !(await authorizeNativeChatReviewPresentation( + tx as unknown as Db, + { + companyId: scope.endpoint.companyId, + issueId: scope.conversation.issueId, + runId: run.id, + resultJson: run.resultJson, + destination: { + endpointId: scope.endpoint.id, + conversationId: scope.conversation.id, + }, + }, + "nonblocking", + )) + ) + return null; + source = first; + causal = { + kind: "native_committed_response", + runId: run.id, + resultId: marker.resultId, + canonicalSha256: marker.canonicalSha256, + decisionId: marker.decisionId, + sourceScopeSha256: proved.sourceScopeSha256, + sources: ordered.map((entry) => entry!.sourceFacts), + }; + } + // No mutable stage, attempt, receipt, route URL or provider token enters + // this digest. The next authorization derives these facts afresh. + const sourceDigest = teamsFileProofHash({ + schema: "paperclip.teams.file-source.v1", + companyId: scope.endpoint.companyId, + endpointId: scope.endpoint.id, + conversationId: scope.conversation.id, + conversationGeneration: scope.conversation.sessionGeneration, + issueId: scope.conversation.issueId, + publicationId: scope.publication.id, + commentId: scope.comment.id, + causal, + byteIdentity: scope.byteIdentity, + }); + if (!teamsFileRuntimeStillCurrent(scope)) return null; + return { + companyId: scope.endpoint.companyId, + endpointId: scope.endpoint.id, + conversationId: scope.conversation.id, + issueId: scope.conversation.issueId, + publicationId: scope.publication.id, + commentId: scope.comment.id, + attachmentId: scope.file.attachment.id, + principalId: source.binding.principalId, + authorizedUserId: source.authorization.userId, + runtimeGeneration: scope.currentFence.generation, + credentialFingerprint: scope.currentFence.credentialFingerprint, + conversationGeneration: scope.conversation.sessionGeneration, + sourceDigest, + tenantId: source.binding.recipient.tenantId, + botAppId: source.binding.recipient.botAppId, + aadObjectId: source.binding.recipient.aadObjectId, + providerConversationId: source.binding.recipient.providerConversationId, + providerUserId: source.binding.recipient.providerUserId, + sha256: scope.file.asset.sha256, + byteSize: scope.file.asset.byteSize, + filename: scope.filename, + }; + } + return { stageTeamsBoardFileIntent, deriveTeamsFileTransferAuthority }; +} diff --git a/server/src/services/chat-channels/slack/file-receipts.ts b/server/src/services/chat-channels/slack/file-receipts.ts new file mode 100644 index 00000000000..a0cc84c2852 --- /dev/null +++ b/server/src/services/chat-channels/slack/file-receipts.ts @@ -0,0 +1,1000 @@ +import type { EndpointRow, DbTransaction, DbOrTransaction, LifecycleRuntimeFence } from "../../chat-channels/runtime-types.js"; + +import { notifyChatPublicationWork } from "../../chat-work-notifications.js"; + +import type { ChatEndpointRuntime } from "../../voice/voice-runtime.js"; + +import { chatCredentialMutationLease, type CredentialMutationLeaseGuard } from "../../chat-credential-mutation-lease.js"; + +import { + randomUUID, +} from "node:crypto"; +import { + and, + asc, + eq, + inArray, + isNull, + lte, + notExists, + or, + sql, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatActions, + chatConversations, + chatEndpointResources, + chatEndpoints, + chatMessageLinks, + chatPublications, +} from "@paperclipai/db"; + +import { + isUuidLike, +} from "@paperclipai/shared"; + +import { + type SlackFileUploadAcceptedReceipt, +} from "../../chat-sdk-runtime.js"; + +import { + stageSlackSessionSync, +} from "../../chat-slack-session-outbox.js"; + +import { classifyChatPublicationError } from "../../chat-publication-errors.js"; + +import type { EndpointRecord } from "../runtime-types.js"; + +type SlackFileUploadReceiptPayload = { + version: 1; + publicationId: string; + publicationAttempt: number; + threadId: string; + fileIds: string[]; + botExternalId: string; + runtimeGeneration: number; + credentialFingerprint: string; +}; + +function slackFileUploadReceiptPayload( + payload: Record<string, unknown>, +): SlackFileUploadReceiptPayload | null { + if ( + payload.version !== 1 || + typeof payload.publicationId !== "string" || + !isUuidLike(payload.publicationId) || + typeof payload.publicationAttempt !== "number" || + !Number.isSafeInteger(payload.publicationAttempt) || + payload.publicationAttempt < 1 || + typeof payload.threadId !== "string" || + payload.threadId.length < 1 || + payload.threadId.length > 2_048 || + !payload.threadId.startsWith("slack:") || + typeof payload.botExternalId !== "string" || + payload.botExternalId.length < 1 || + payload.botExternalId.length > 255 || + typeof payload.runtimeGeneration !== "number" || + !Number.isSafeInteger(payload.runtimeGeneration) || + payload.runtimeGeneration < 0 || + typeof payload.credentialFingerprint !== "string" || + !/^[a-f0-9]{64}$/.test(payload.credentialFingerprint) || + !Array.isArray(payload.fileIds) || + payload.fileIds.length < 1 || + payload.fileIds.length > 20 || + payload.fileIds.some( + (fileId) => + typeof fileId !== "string" || !/^F[A-Z0-9]{1,254}$/.test(fileId), + ) || + new Set(payload.fileIds).size !== payload.fileIds.length + ) { + return null; + } + return payload as SlackFileUploadReceiptPayload; +} + +function operatorConfirmedSlackFileReceipt( + result: Record<string, unknown> | null, + payload: SlackFileUploadReceiptPayload, +): boolean { + return ( + result?.operatorConfirmedPublicationId === payload.publicationId && + result.operatorConfirmedAttempt === payload.publicationAttempt && + typeof result.operatorConfirmedAt === "string" && + Number.isFinite(Date.parse(result.operatorConfirmedAt)) + ); +} + +function sameSlackFileUploadReceipt( + left: SlackFileUploadReceiptPayload, + right: SlackFileUploadReceiptPayload, +): boolean { + return ( + left.publicationId === right.publicationId && + left.publicationAttempt === right.publicationAttempt && + left.threadId === right.threadId && + left.botExternalId === right.botExternalId && + left.runtimeGeneration === right.runtimeGeneration && + left.credentialFingerprint === right.credentialFingerprint && + left.fileIds.length === right.fileIds.length && + left.fileIds.every((fileId, index) => fileId === right.fileIds[index]) + ); +} +const SLACK_FILE_RECEIPT_MAX_AGE_MS = 24 * 60 * 60_000; +const SLACK_FILE_RECEIPT_MAX_ATTEMPTS = 12; +const SLACK_FILE_RECEIPT_STALE_MS = 60_000; +type ConversationRow = typeof chatConversations.$inferSelect; +type SlackFileReceiptsOptions = { + endpointRecord: (id: string) => Promise<EndpointRecord | null>; + withCredentialMutationLease: ReturnType<typeof chatCredentialMutationLease>; + runtimeCallbackEndpoint: (tx: DbTransaction, endpointId: string, context: LifecycleRuntimeFence, statuses: EndpointRow["status"][]) => Promise<EndpointRow | null>; + nonDirectDestinationAllowed: (endpoint: EndpointRow, resource: typeof chatEndpointResources.$inferSelect | null | undefined) => boolean; + runtimeFor: (endpoint: EndpointRow) => Promise<ChatEndpointRuntime>; + isExplicitOperatorPublication: (publication: typeof chatPublications.$inferSelect) => boolean; + recordExactOutboundPublicationLink: (tx: DbOrTransaction, input: { publication: typeof chatPublications.$inferSelect; providerMessageId: string }) => Promise<boolean>; +}; + +export function createSlackFileReceipts(db: Db, options: SlackFileReceiptsOptions) { + const { endpointRecord, withCredentialMutationLease, runtimeCallbackEndpoint, nonDirectDestinationAllowed, runtimeFor, isExplicitOperatorPublication, recordExactOutboundPublicationLink } = options; + + + async function recordSlackFileUploadReceipt(input: { + credentialLease: CredentialMutationLeaseGuard; + conversation: ConversationRow; + endpoint: EndpointRow; + publication: typeof chatPublications.$inferSelect; + receipt: SlackFileUploadAcceptedReceipt; + runtimeContext: LifecycleRuntimeFence; + }): Promise<string> { + const publicationAttempt = input.publication.attempts + 1; + const botExternalId = input.endpoint.botExternalId; + const expectedThreadId = `slack:${input.receipt.channelId}:${input.receipt.threadTs ?? ""}`; + if ( + input.endpoint.provider !== "slack" || + !botExternalId || + input.receipt.version !== 1 || + expectedThreadId !== input.conversation.externalThreadId || + input.receipt.fileIds.length !== + (input.publication.payload.attachmentIds?.length ?? 0) + ) { + throw new Error( + "Slack file upload receipt did not match its publication", + ); + } + const payload: SlackFileUploadReceiptPayload = { + version: 1, + publicationId: input.publication.id, + publicationAttempt, + threadId: expectedThreadId, + fileIds: [...input.receipt.fileIds], + botExternalId, + runtimeGeneration: input.runtimeContext.generation, + credentialFingerprint: input.runtimeContext.credentialFingerprint, + }; + if (!slackFileUploadReceiptPayload(payload)) { + throw new Error("Slack file upload receipt was malformed"); + } + const providerActionId = `slack-file-receipt:${input.publication.id}:${publicationAttempt}`; + const recordedAt = new Date(); + return db.transaction(async (tx) => { + await input.credentialLease.assertOwned(tx); + const publication = await tx + .select({ id: chatPublications.id }) + .from(chatPublications) + .where( + and( + eq(chatPublications.id, input.publication.id), + eq(chatPublications.companyId, input.publication.companyId), + eq(chatPublications.endpointId, input.publication.endpointId), + eq( + chatPublications.conversationId, + input.publication.conversationId, + ), + eq(chatPublications.state, "streaming"), + eq(chatPublications.attempts, publicationAttempt), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + const endpoint = publication + ? await runtimeCallbackEndpoint( + tx, + input.endpoint.id, + input.runtimeContext, + ["verifying", "active"], + ) + : null; + const conversation = endpoint + ? await tx + .select({ + externalThreadId: chatConversations.externalThreadId, + id: chatConversations.id, + }) + .from(chatConversations) + .where( + and( + eq(chatConversations.id, input.conversation.id), + eq(chatConversations.companyId, input.publication.companyId), + eq(chatConversations.endpointId, input.endpoint.id), + inArray(chatConversations.state, [ + "active", + "waiting", + "completed", + ]), + ), + ) + .for("no key update") + .then((rows) => rows[0] ?? null) + : null; + if ( + !publication || + endpoint?.provider !== "slack" || + endpoint.botExternalId !== botExternalId || + conversation?.externalThreadId !== expectedThreadId + ) { + throw new Error( + "Slack file upload ownership changed before receipt persistence", + ); + } + await notifyChatPublicationWork(tx); + const inserted = await tx + .insert(chatActions) + .values({ + companyId: input.publication.companyId, + endpointId: input.publication.endpointId, + conversationId: input.publication.conversationId, + kind: "slack_file_upload_receipt", + providerActionId, + payload, + status: "received", + result: { code: "slack_file_upload_identity_pending", attempts: 0 }, + createdAt: recordedAt, + updatedAt: recordedAt, + }) + .onConflictDoNothing() + .returning({ id: chatActions.id }); + if (inserted[0]) { + await input.credentialLease.assertOwned(tx); + return inserted[0].id; + } + const existing = await tx + .select({ id: chatActions.id }) + .from(chatActions) + .where( + and( + eq(chatActions.endpointId, input.publication.endpointId), + eq(chatActions.providerActionId, providerActionId), + eq(chatActions.kind, "slack_file_upload_receipt"), + sql`${chatActions.payload} = ${JSON.stringify(payload)}::jsonb`, + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + if (!existing) { + throw new Error( + "Slack file upload receipt conflicted with durable state", + ); + } + await input.credentialLease.assertOwned(tx); + return existing.id; + }); + } + + async function stageSlackFilePublicationSuccess( + tx: DbTransaction, + input: { + committedAt: Date; + endpoint: EndpointRow; + publication: typeof chatPublications.$inferSelect; + providerMessageId: string; + runtimeContext: LifecycleRuntimeFence; + stageSessionSync: boolean; + }, + ): Promise<void> { + if ( + !(await recordExactOutboundPublicationLink(tx, { + publication: input.publication, + providerMessageId: input.providerMessageId, + })) + ) { + throw new Error( + "Slack file upload message identity conflicts with another publication", + ); + } + await tx + .update(chatEndpoints) + .set({ + lastPublicationAt: input.committedAt, + updatedAt: input.committedAt, + }) + .where( + and( + eq(chatEndpoints.id, input.endpoint.id), + inArray(chatEndpoints.status, ["verifying", "active"]), + sql`coalesce((${chatEndpoints.setup}->>'runtimeGeneration')::integer, 0) = ${input.runtimeContext.generation}`, + ), + ); + if (input.stageSessionSync) { + await stageSlackSessionSync(tx, { + companyId: input.publication.companyId, + endpointId: input.publication.endpointId, + conversationId: input.publication.conversationId, + runtimeGeneration: input.runtimeContext.generation, + credentialFingerprint: input.runtimeContext.credentialFingerprint, + }); + } + } + + async function lockSlackFileReceiptAuthorization( + tx: DbTransaction, + input: { + actionId: string; + payload: SlackFileUploadReceiptPayload; + }, + ): Promise< + | { + action: typeof chatActions.$inferSelect; + conversation: ConversationRow; + endpoint: EndpointRow; + mode: "delivery_unknown" | "operator_confirmed"; + publication: typeof chatPublications.$inferSelect; + } + | { action: typeof chatActions.$inferSelect; mode: "defer" | "settled" } + | null + > { + const publication = await tx + .select() + .from(chatPublications) + .where( + and( + eq(chatPublications.id, input.payload.publicationId), + eq(chatPublications.attempts, input.payload.publicationAttempt), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + const action = publication + ? await tx + .select() + .from(chatActions) + .where( + and( + eq(chatActions.id, input.actionId), + eq(chatActions.companyId, publication.companyId), + eq(chatActions.endpointId, publication.endpointId), + eq(chatActions.conversationId, publication.conversationId), + eq(chatActions.kind, "slack_file_upload_receipt"), + eq( + chatActions.providerActionId, + `slack-file-receipt:${publication.id}:${publication.attempts}`, + ), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null) + : null; + const currentPayload = action + ? slackFileUploadReceiptPayload(action.payload) + : null; + if ( + !publication || + !action || + !publication.payload.attachmentIds?.length || + publication.payload.card || + publication.payload.interactionId || + publication.idempotencyKey.startsWith("control:") || + !currentPayload || + !sameSlackFileUploadReceipt(currentPayload, input.payload) + ) { + return null; + } + if (publication.state === "streaming") { + return { action, mode: "defer" }; + } + if (publication.state === "published" && publication.providerMessageId) { + const exactLink = await tx + .select({ id: chatMessageLinks.id }) + .from(chatMessageLinks) + .where( + and( + eq(chatMessageLinks.companyId, publication.companyId), + eq(chatMessageLinks.endpointId, publication.endpointId), + eq(chatMessageLinks.conversationId, publication.conversationId), + eq(chatMessageLinks.publicationId, publication.id), + publication.commentId + ? eq(chatMessageLinks.commentId, publication.commentId) + : isNull(chatMessageLinks.commentId), + eq( + chatMessageLinks.providerMessageId, + publication.providerMessageId, + ), + eq(chatMessageLinks.direction, "outbound"), + ), + ) + .limit(1) + .then((rows) => rows[0] ?? null); + return exactLink ? { action, mode: "settled" } : null; + } + const mode = + publication.state === "delivery_unknown" + ? "delivery_unknown" + : publication.state === "published" && + !publication.providerMessageId && + operatorConfirmedSlackFileReceipt(action.result, input.payload) + ? "operator_confirmed" + : null; + if (!mode) return null; + const endpoint = await runtimeCallbackEndpoint( + tx, + publication.endpointId, + { + generation: input.payload.runtimeGeneration, + credentialFingerprint: input.payload.credentialFingerprint, + }, + ["verifying", "active"], + ); + const conversation = endpoint + ? await tx + .select() + .from(chatConversations) + .where( + and( + eq(chatConversations.id, publication.conversationId), + eq(chatConversations.companyId, publication.companyId), + eq(chatConversations.endpointId, publication.endpointId), + eq(chatConversations.externalThreadId, input.payload.threadId), + inArray(chatConversations.state, [ + "active", + "waiting", + "completed", + ]), + ), + ) + .for("no key update") + .then((rows) => rows[0] ?? null) + : null; + const resource = + endpoint && conversation?.resourceId && !conversation.isDirectMessage + ? await tx + .select() + .from(chatEndpointResources) + .where( + and( + eq(chatEndpointResources.id, conversation.resourceId), + eq(chatEndpointResources.companyId, endpoint.companyId), + eq(chatEndpointResources.endpointId, endpoint.id), + ), + ) + .for("no key update") + .then((rows) => rows[0] ?? null) + : null; + if ( + endpoint?.provider !== "slack" || + endpoint.botExternalId !== input.payload.botExternalId || + !conversation || + !(conversation.isDirectMessage + ? endpoint.allowDirectMessages + : nonDirectDestinationAllowed(endpoint, resource)) + ) { + return null; + } + return { action, conversation, endpoint, mode, publication }; + } + + async function processPendingSlackFileUploadReceipts(limit = 25) { + const selectedAt = new Date(); + const actions = await db + .select() + .from(chatActions) + .where( + slackReceiptEligibility(selectedAt), + ) + .orderBy(asc(chatActions.updatedAt), asc(chatActions.id)) + .limit(limit); + let processed = 0; + for (const selected of actions) { + const payload = slackFileUploadReceiptPayload(selected.payload); + if (!payload) { + await db.transaction(async (tx) => { + await notifyChatPublicationWork(tx); + return tx + .update(chatActions) + .set({ + status: "failed", + result: { + code: "slack_file_upload_receipt_payload_invalid", + retryable: false, + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, selected.id), + eq(chatActions.kind, selected.kind), + eq(chatActions.status, selected.status), + sql`${chatActions.payload} = ${JSON.stringify(selected.payload)}::jsonb`, + selected.result === null + ? isNull(chatActions.result) + : sql`${chatActions.result} = ${JSON.stringify(selected.result)}::jsonb`, + ), + ); + }); + continue; + } + const record = await endpointRecord(selected.endpointId); + if (!record) { + await db.transaction(async (tx) => { + await notifyChatPublicationWork(tx); + return tx + .update(chatActions) + .set({ + status: "cancelled", + result: { code: "slack_file_upload_receipt_endpoint_removed" }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, selected.id), + eq(chatActions.kind, selected.kind), + eq(chatActions.status, selected.status), + sql`${chatActions.payload} = ${JSON.stringify(selected.payload)}::jsonb`, + selected.result === null + ? isNull(chatActions.result) + : sql`${chatActions.result} = ${JSON.stringify(selected.result)}::jsonb`, + ), + ); + }); + continue; + } + if (["paused", "attention"].includes(record.endpoint.status)) continue; + const ownerToken = randomUUID(); + let attempt = 1; + try { + await withCredentialMutationLease(record.endpoint, async (lease) => { + const claim = await db.transaction(async (tx) => { + await lease.assertOwned(tx); + const locked = await lockSlackFileReceiptAuthorization(tx, { + actionId: selected.id, + payload, + }); + const decisionAt = new Date(); + const priorAttempts = + typeof locked?.action.result?.attempts === "number" && + Number.isSafeInteger(locked.action.result.attempts) + ? locked.action.result.attempts + : 0; + attempt = priorAttempts + 1; + const retryAt = + typeof locked?.action.result?.retryAt === "string" + ? Date.parse(locked.action.result.retryAt) + : Number.NaN; + const eligible = locked + ? locked.action.status === "received" || + (locked.action.status === "failed" && + locked.action.result?.retryable === true && + Number.isFinite(retryAt) && + retryAt <= decisionAt.getTime()) || + (locked.action.status === "processing" && + locked.action.updatedAt.getTime() <= + decisionAt.getTime() - SLACK_FILE_RECEIPT_STALE_MS) + : false; + if (locked && !eligible) return null; + if (locked?.mode === "defer") { + if (locked.action.status === "processing") { + await notifyChatPublicationWork(tx); + await tx + .update(chatActions) + .set({ + status: "received", + result: { + code: "slack_file_upload_identity_pending", + attempts: priorAttempts, + }, + updatedAt: new Date(), + }) + .where(eq(chatActions.id, locked.action.id)); + } + return null; + } + if (locked?.mode === "settled") { + await notifyChatPublicationWork(tx); + await tx + .update(chatActions) + .set({ + status: "processed", + result: { + code: "slack_file_upload_identity_confirmed", + attempts: priorAttempts, + }, + updatedAt: new Date(), + }) + .where(eq(chatActions.id, locked.action.id)); + return null; + } + if ( + !locked || + decisionAt.getTime() - + (locked?.action.createdAt ?? selected.createdAt).getTime() > + SLACK_FILE_RECEIPT_MAX_AGE_MS || + attempt > SLACK_FILE_RECEIPT_MAX_ATTEMPTS + ) { + await notifyChatPublicationWork(tx); + await tx + .update(chatActions) + .set({ + status: "cancelled", + result: { + code: locked + ? "slack_file_upload_receipt_expired" + : "slack_file_upload_receipt_authorization_changed", + attempts: priorAttempts, + }, + updatedAt: decisionAt, + }) + .where( + and( + eq(chatActions.id, selected.id), + inArray(chatActions.status, [ + "received", + "failed", + "processing", + ]), + ), + ); + return null; + } + if ( + !["received", "failed", "processing"].includes( + locked.action.status, + ) + ) { + return null; + } + await notifyChatPublicationWork(tx); + const [claimed] = await tx + .update(chatActions) + .set({ + status: "processing", + result: { + attempts: attempt, + ownerToken, + ...(locked.mode === "operator_confirmed" + ? { + operatorConfirmedPublicationId: + locked.action.result?.operatorConfirmedPublicationId, + operatorConfirmedAttempt: + locked.action.result?.operatorConfirmedAttempt, + operatorConfirmedAt: + locked.action.result?.operatorConfirmedAt, + } + : {}), + }, + updatedAt: decisionAt, + }) + .where( + and( + eq(chatActions.id, locked.action.id), + eq(chatActions.status, locked.action.status), + ), + ) + .returning({ id: chatActions.id }); + await lease.assertOwned(tx); + return claimed ? locked : null; + }); + if (!claim || !("endpoint" in claim)) return; + processed += 1; + const endpointRuntime = await runtimeFor(claim.endpoint); + await lease.assertOwned(); + const providerMessageId = + await endpointRuntime.resolveSlackFileUploadReceipt( + payload.threadId, + payload.fileIds, + ); + if (!providerMessageId) { + await db.transaction(async (tx) => { + await lease.assertOwned(tx); + await notifyChatPublicationWork(tx); + await tx + .update(chatActions) + .set({ + status: "failed", + result: sql`coalesce(${chatActions.result}, '{}'::jsonb) || ${JSON.stringify( + { + attempts: attempt, + code: "slack_file_upload_identity_pending", + retryable: true, + retryAt: new Date( + Date.now() + Math.min(60_000, 1_000 * 2 ** attempt), + ).toISOString(), + }, + )}::jsonb`, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, selected.id), + eq(chatActions.status, "processing"), + sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, + ), + ); + }); + return; + } + await db.transaction(async (tx) => { + await lease.assertOwned(tx); + const locked = await lockSlackFileReceiptAuthorization(tx, { + actionId: selected.id, + payload, + }); + if ( + !locked || + locked.mode === "defer" || + locked.mode === "settled" || + !("publication" in locked) || + locked.action.status !== "processing" || + locked.action.result?.ownerToken !== ownerToken + ) { + if (!locked) { + await notifyChatPublicationWork(tx); + await tx + .update(chatActions) + .set({ + status: "cancelled", + result: { + attempts: attempt, + code: "slack_file_upload_receipt_authorization_changed", + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, selected.id), + eq(chatActions.status, "processing"), + sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, + ), + ); + } else if (locked.mode === "defer") { + await notifyChatPublicationWork(tx); + await tx + .update(chatActions) + .set({ + status: "received", + result: { + attempts: attempt, + code: "slack_file_upload_identity_pending", + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, selected.id), + eq(chatActions.status, "processing"), + sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, + ), + ); + } else { + await notifyChatPublicationWork(tx); + await tx + .update(chatActions) + .set({ + status: + locked.mode === "settled" ? "processed" : "cancelled", + result: { + attempts: attempt, + code: + locked.mode === "settled" + ? "slack_file_upload_identity_confirmed" + : "slack_file_upload_receipt_superseded", + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, selected.id), + eq(chatActions.status, "processing"), + sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, + ), + ); + } + return; + } + const committedAt = new Date(); + if ( + !(await recordExactOutboundPublicationLink(tx, { + publication: locked.publication, + providerMessageId, + })) + ) { + await notifyChatPublicationWork(tx); + await tx + .update(chatActions) + .set({ + status: "failed", + result: { + attempts: attempt, + code: "slack_file_upload_receipt_message_conflict", + retryable: false, + }, + updatedAt: committedAt, + }) + .where(eq(chatActions.id, selected.id)); + return; + } + if (locked.mode === "delivery_unknown") { + await notifyChatPublicationWork(tx); + const [settled] = await tx + .update(chatPublications) + .set({ + state: "published", + providerMessageId, + publishedAt: committedAt, + nextAttemptAt: null, + redactedError: null, + updatedAt: committedAt, + }) + .where( + and( + eq(chatPublications.id, locked.publication.id), + eq(chatPublications.state, "delivery_unknown"), + eq(chatPublications.attempts, payload.publicationAttempt), + ), + ) + .returning({ id: chatPublications.id }); + if (!settled) { + throw new Error( + "Slack file upload publication changed before receipt commit", + ); + } + await stageSlackFilePublicationSuccess(tx, { + committedAt, + endpoint: locked.endpoint, + publication: locked.publication, + providerMessageId, + runtimeContext: { + generation: payload.runtimeGeneration, + credentialFingerprint: payload.credentialFingerprint, + }, + stageSessionSync: + !isExplicitOperatorPublication(locked.publication) && + !locked.publication.idempotencyKey.startsWith( + "control:status:", + ), + }); + } else { + await notifyChatPublicationWork(tx); + const [enriched] = await tx + .update(chatPublications) + .set({ providerMessageId }) + .where( + and( + eq(chatPublications.id, locked.publication.id), + eq(chatPublications.state, "published"), + eq(chatPublications.attempts, payload.publicationAttempt), + isNull(chatPublications.providerMessageId), + ), + ) + .returning({ id: chatPublications.id }); + if (!enriched) { + throw new Error( + "Slack file upload publication changed before identity enrichment", + ); + } + } + await notifyChatPublicationWork(tx); + await tx + .update(chatActions) + .set({ + status: "processed", + result: { + attempts: attempt, + code: "slack_file_upload_identity_confirmed", + }, + updatedAt: committedAt, + }) + .where( + and( + eq(chatActions.id, selected.id), + eq(chatActions.status, "processing"), + sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, + ), + ); + await lease.assertOwned(tx); + }); + }); + } catch (error) { + const disposition = classifyChatPublicationError(error, attempt); + const retryable = + disposition.kind === "retry" || + disposition.kind === "delivery_unknown"; + const retryMs = + disposition.kind === "retry" + ? disposition.retryAfterMs + : Math.min(60_000, 1_000 * 2 ** Math.min(attempt, 8)); + await db.transaction(async (tx) => { + await notifyChatPublicationWork(tx); + return tx + .update(chatActions) + .set({ + status: retryable ? "failed" : "cancelled", + result: sql`coalesce(${chatActions.result}, '{}'::jsonb) || ${JSON.stringify( + { + attempts: attempt, + code: retryable + ? "slack_file_upload_receipt_lookup_retry" + : "slack_file_upload_receipt_lookup_rejected", + retryable, + ...(retryable + ? { retryAt: new Date(Date.now() + retryMs).toISOString() } + : {}), + }, + )}::jsonb`, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, selected.id), + eq(chatActions.status, "processing"), + sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, + ), + ); + }); + } + } + return processed; + } + + function slackReceiptEligibility(selectedAt?: Date) { + return and( + eq(chatActions.kind, "slack_file_upload_receipt"), + notExists( + db + .select({ id: chatEndpoints.id }) + .from(chatEndpoints) + .where( + and( + eq(chatEndpoints.id, chatActions.endpointId), + inArray(chatEndpoints.status, ["paused", "attention"]), + ), + ), + ), + // The normal publication owner is still polling Slack. Receipt + // recovery must neither contend with it nor let its not-yet-due + // action occupy the bounded recovery page. + notExists( + db + .select({ id: chatPublications.id }) + .from(chatPublications) + .where( + and( + sql`${chatPublications.id}::text = ${chatActions.payload}->>'publicationId'`, + sql`${chatPublications.attempts}::text = ${chatActions.payload}->>'publicationAttempt'`, + eq(chatPublications.state, "streaming"), + ), + ), + ), + or( + eq(chatActions.status, "received"), + and( + eq(chatActions.status, "failed"), + sql`coalesce(${chatActions.result}->>'retryable', 'false') = 'true'`, + selectedAt ? sql`(${chatActions.result}->>'retryAt')::timestamptz <= ${selectedAt.toISOString()}::timestamptz` : sql`${chatActions.result}->>'retryAt' is not null`, + ), + and( + eq(chatActions.status, "processing"), + selectedAt ? lte(chatActions.updatedAt, new Date(selectedAt.getTime() - SLACK_FILE_RECEIPT_STALE_MS)) : undefined, + ), + ), + ); + } + + async function nextSlackReceiptAt(): Promise<number | null> { + const [row] = await db.select({ at: sql<string | null>`min(case + when ${chatActions.status} = 'processing' then ${chatActions.updatedAt} + ${SLACK_FILE_RECEIPT_STALE_MS} * interval '1 millisecond' + when ${chatActions.status} = 'failed' then (${chatActions.result}->>'retryAt')::timestamptz + else now() end)` }).from(chatActions).where(slackReceiptEligibility()); + return row?.at == null ? null : new Date(row.at).getTime(); + } + return { recordSlackFileUploadReceipt, stageSlackFilePublicationSuccess, processPendingSlackFileUploadReceipts, nextSlackReceiptAt }; +} diff --git a/server/src/services/chat-channels/slack/session-status.ts b/server/src/services/chat-channels/slack/session-status.ts new file mode 100644 index 00000000000..ccdf67e8412 --- /dev/null +++ b/server/src/services/chat-channels/slack/session-status.ts @@ -0,0 +1,408 @@ +import type { EndpointRow, DbTransaction, DbOrTransaction, LifecycleRuntimeFence } from "../../chat-channels/runtime-types.js"; + +import { dueChatAction } from "../../chat-action-work.js"; + +import { notifyChatActionWork } from "../../chat-work-notifications.js"; + +import { chatCredentialMutationLease } from "../../chat-credential-mutation-lease.js"; + +import { + randomUUID, +} from "node:crypto"; +import { + and, + asc, + desc, + eq, + gt, + inArray, + lte, + or, + sql, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatActions, + chatConversations, + chatEndpointResources, + chatEndpoints, + chatPublications, + heartbeatRuns, +} from "@paperclipai/db"; + +import { logger } from "../../../middleware/logger.js"; + +import { + setSlackSessionStatus, + slackSessionStatusForPublication, +} from "../../chat-slack-sessions.js"; +import { + slackSessionSyncPayload, +} from "../../chat-slack-session-outbox.js"; + +import { classifyChatPublicationError } from "../../chat-publication-errors.js"; + +import type { EndpointRecord } from "../runtime-types.js"; + +type SlackSessionStatusOptions = { + endpointRecord: (id: string) => Promise<EndpointRecord | null>; + withCredentialMutationLease: ReturnType<typeof chatCredentialMutationLease>; + runtimeCallbackEndpoint: (tx: DbTransaction, endpointId: string, context: LifecycleRuntimeFence, statuses: EndpointRow["status"][]) => Promise<EndpointRow | null>; + nonDirectDestinationAllowed: (endpoint: EndpointRow, resource: typeof chatEndpointResources.$inferSelect | null | undefined) => boolean; + runIdFromMilestonePublication: (publication: typeof chatPublications.$inferSelect) => string | null; + resolveCredentials: (endpoint: EndpointRow, database?: DbOrTransaction) => Promise<Record<string, string>>; + fetchImpl: typeof globalThis.fetch; + redactError: (error: unknown) => string; + slackSessionSyncSelectionBarrier?: () => Promise<void>; +}; + +export function createSlackSessionStatus(db: Db, options: SlackSessionStatusOptions) { + const { endpointRecord, withCredentialMutationLease, runtimeCallbackEndpoint, nonDirectDestinationAllowed, runIdFromMilestonePublication, resolveCredentials, fetchImpl, redactError } = options; + + + async function processPendingSlackSessionSyncs( + limit = 25, + onlyActionId?: string, + ) { + const now = new Date(); + const actions = await db + .select({ action: chatActions }) + .from(chatActions) + .leftJoin(chatEndpoints, and(eq(chatEndpoints.id, chatActions.endpointId), eq(chatEndpoints.companyId, chatActions.companyId))) + .where(and( + onlyActionId ? eq(chatActions.id, onlyActionId) : undefined, + dueChatAction("slack_session_sync", now.getTime()), + )) + .orderBy(asc(chatActions.updatedAt)) + .limit(limit) + .then(rows => rows.map(row => row.action)); + await options.slackSessionSyncSelectionBarrier?.(); + for (const action of actions) { + const payload = slackSessionSyncPayload(action.payload); + if (!payload || !action.conversationId) { + await db.transaction(async (tx) => { + await notifyChatActionWork(tx, "slack_session_sync"); + return tx + .update(chatActions) + .set({ + status: "failed", + result: { code: "slack_session_payload_invalid" }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, action.id), + eq(chatActions.updatedAt, action.updatedAt), + ), + ); + }); + continue; + } + const revisionWhere = and( + eq(chatActions.id, action.id), + sql`(${chatActions.payload}->>'revision')::bigint = ${payload.revision}`, + ); + const selectedSnapshotWhere = and( + revisionWhere, + eq(chatActions.status, action.status), + eq(chatActions.updatedAt, action.updatedAt), + ); + const record = await endpointRecord(action.endpointId); + if (record && ["paused", "attention"].includes(record.endpoint.status)) continue; + if (!record || record.endpoint.provider !== "slack") { + await db.transaction(async (tx) => { + await notifyChatActionWork(tx, "slack_session_sync"); + return tx + .update(chatActions) + .set({ + status: "cancelled", + result: { code: "slack_session_endpoint_unavailable" }, + updatedAt: new Date(), + }) + .where(selectedSnapshotWhere); + }); + continue; + } + const attempts = + (typeof action.result?.attempts === "number" + ? action.result.attempts + : 0) + 1; + const ownerToken = randomUUID(); + let ownsAttempt = false; + try { + await withCredentialMutationLease(record.endpoint, async (lease) => { + const claim = await db.transaction(async (tx) => { + await lease.assertOwned(tx); + const endpoint = await runtimeCallbackEndpoint( + tx, + action.endpointId, + { + generation: payload.runtimeGeneration, + credentialFingerprint: payload.credentialFingerprint, + }, + ["active", "verifying"], + ); + const conversation = endpoint + ? await tx + .select() + .from(chatConversations) + .where( + and( + eq(chatConversations.id, action.conversationId!), + eq(chatConversations.endpointId, endpoint.id), + eq(chatConversations.companyId, endpoint.companyId), + inArray(chatConversations.state, [ + "active", + "waiting", + "completed", + ]), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null) + : null; + const resource = + endpoint && conversation?.resourceId + ? await tx + .select() + .from(chatEndpointResources) + .where( + and( + eq(chatEndpointResources.id, conversation.resourceId), + eq(chatEndpointResources.companyId, endpoint.companyId), + eq(chatEndpointResources.endpointId, endpoint.id), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null) + : null; + const superseded = conversation + ? await tx + .select({ id: chatConversations.id }) + .from(chatConversations) + .where( + and( + eq(chatConversations.companyId, conversation.companyId), + eq(chatConversations.endpointId, conversation.endpointId), + eq( + chatConversations.externalThreadId, + conversation.externalThreadId, + ), + gt( + chatConversations.sessionGeneration, + conversation.sessionGeneration, + ), + ), + ) + .limit(1) + .then((rows) => rows.length > 0) + : false; + if ( + !endpoint || + !conversation || + superseded || + !(conversation.isDirectMessage + ? endpoint.allowDirectMessages + : nonDirectDestinationAllowed(endpoint, resource)) + ) { + await notifyChatActionWork(tx, "slack_session_sync"); + await tx + .update(chatActions) + .set({ + status: "cancelled", + result: { code: "slack_session_destination_changed" }, + updatedAt: new Date(), + }) + .where(selectedSnapshotWhere); + return null; + } + // Recompute at dispatch: a delayed processing retry must not + // overwrite a newer final response or a later question card. + const publication = await tx + .select() + .from(chatPublications) + .where( + and( + eq(chatPublications.companyId, endpoint.companyId), + eq(chatPublications.endpointId, endpoint.id), + eq(chatPublications.conversationId, conversation.id), + eq(chatPublications.state, "published"), + sql`${chatPublications.idempotencyKey} not like 'explicit%'`, + sql`${chatPublications.idempotencyKey} not like 'control:status:%'`, + sql`${chatPublications.idempotencyKey} not like 'wake:%'`, + ), + ) + .orderBy( + desc(chatPublications.publishedAt), + desc(chatPublications.createdAt), + desc(chatPublications.id), + ) + .limit(1) + .then((rows) => rows[0] ?? null); + if (!publication) { + await notifyChatActionWork(tx, "slack_session_sync"); + await tx + .update(chatActions) + .set({ + status: "cancelled", + result: { code: "slack_session_no_published_state" }, + updatedAt: new Date(), + }) + .where(selectedSnapshotWhere); + return null; + } + let status = slackSessionStatusForPublication( + publication.payload, + conversation.state === "completed", + ); + const runId = runIdFromMilestonePublication(publication); + if (status === "processing" && runId) { + const run = await tx + .select({ status: heartbeatRuns.status }) + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.id, runId), + eq(heartbeatRuns.companyId, endpoint.companyId), + eq(heartbeatRuns.agentId, endpoint.assignedAgentId), + ), + ) + .then((rows) => rows[0] ?? null); + // Stop may finish before its confirmation publication. Never + // resurrect processing from that run's earlier working receipt. + if (!run || !["queued", "running"].includes(run.status)) + status = "active"; + } + await notifyChatActionWork(tx, "slack_session_sync"); + const claimed = await tx + .update(chatActions) + .set({ + status: "processing", + result: { attempts, ownerToken }, + updatedAt: new Date(), + }) + .where( + and( + selectedSnapshotWhere, + or( + and( + eq(chatActions.status, "received"), + sql`(${chatActions.result}->>'retryAt' is null or (${chatActions.result}->>'retryAt')::timestamptz <= ${new Date().toISOString()}::timestamptz)`, + ), + and( + eq(chatActions.status, "processing"), + lte(chatActions.updatedAt, new Date(Date.now() - 60_000)), + ), + ), + ), + ) + .returning({ id: chatActions.id }); + await lease.assertOwned(tx); + return claimed.length ? { endpoint, conversation, status } : null; + }); + if (!claim) return; + ownsAttempt = true; + const credentials = await resolveCredentials(claim.endpoint); + await lease.assertOwned(); + const outcome = await setSlackSessionStatus({ + botToken: credentials.botToken, + threadId: claim.conversation.externalThreadId, + status: claim.status, + fetch: fetchImpl, + }); + await db.transaction(async (tx) => { + await lease.assertOwned(tx); + // Refresh long work before Slack's one-hour processing timeout. + // Unsupported sessions settle until a new publication restages + // them; completed threads must not create perpetual polling work. + const retryMs = + outcome === "updated" && claim.status === "processing" + ? 30 * 60_000 + : null; + await notifyChatActionWork(tx, "slack_session_sync"); + await tx + .update(chatActions) + .set({ + status: retryMs === null ? "processed" : "received", + result: { + attempts, + outcome, + sessionStatus: claim.status, + ...(retryMs === null + ? {} + : { + retryAt: new Date(Date.now() + retryMs).toISOString(), + }), + }, + updatedAt: new Date(), + }) + .where( + and( + revisionWhere, + eq(chatActions.status, "processing"), + sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, + ), + ); + }); + }); + } catch (error) { + const disposition = classifyChatPublicationError(error, attempts); + // Unlike posting a message, repeating setStatus cannot duplicate any + // content. Ambiguous transport/receipt failures remain retryable, but + // a definite scope/auth/destination rejection waits for new activity. + const retryable = + disposition.kind === "retry" || + disposition.kind === "delivery_unknown"; + const retryMs = + disposition.kind === "retry" + ? disposition.retryAfterMs + : Math.min(5 * 60_000, 1_000 * 2 ** Math.min(attempts, 8)); + await db.transaction(async (tx) => { + await notifyChatActionWork(tx, "slack_session_sync"); + return tx + .update(chatActions) + .set({ + status: retryable ? "received" : "failed", + result: { + attempts, + code: retryable + ? "slack_session_sync_retry" + : "slack_session_sync_rejected", + ...(retryable + ? { retryAt: new Date(Date.now() + retryMs).toISOString() } + : {}), + }, + updatedAt: new Date(), + }) + .where( + and( + revisionWhere, + ownsAttempt + ? and( + eq(chatActions.status, "processing"), + sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, + ) + : and( + eq(chatActions.status, "received"), + eq(chatActions.updatedAt, action.updatedAt), + ), + ), + ); + }); + logger.warn( + { + endpointId: action.endpointId, + actionId: action.id, + retryable, + error: redactError(error), + }, + "Slack session status update did not complete; message delivery is unchanged", + ); + } + } + return actions.length; + } + return { processPendingSlackSessionSyncs }; +} diff --git a/server/src/services/chat-channels/telegram/publication-drafts.ts b/server/src/services/chat-channels/telegram/publication-drafts.ts new file mode 100644 index 00000000000..c469155c736 --- /dev/null +++ b/server/src/services/chat-channels/telegram/publication-drafts.ts @@ -0,0 +1,461 @@ +import type { EndpointRow, DbTransaction, DbOrTransaction, RuntimeContext, LifecycleRuntimeFence } from "../../chat-channels/runtime-types.js"; + +import { notifyChatPublicationWork } from "../../chat-work-notifications.js"; + +import { type ChatRuntime } from "../../chat-runtime.js"; +import type { ChatEndpointRuntime } from "../../voice/voice-runtime.js"; + +import { type CredentialMutationLeaseGuard } from "../../chat-credential-mutation-lease.js"; + +import { + and, + eq, + sql, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatActions, + chatConversations, + chatPublications, + toolConnections, +} from "@paperclipai/db"; +import type { + ToolCredentialSecretRef, +} from "@paperclipai/shared"; + +import { + TELEGRAM_DRAFT_ACTION_KIND, + TELEGRAM_DRAFT_STOPPED_REASON, + parseTelegramDraftBinding, + telegramDraftTextSha256, + telegramGenerationStoppedReceipt, + telegramPrivateDraftDestination, + type TelegramDraftControl, + type TelegramGenerationStoppedProof, +} from "../../chat-telegram-draft-stop.js"; + +import { + type ChatSdkCallbackEvent, + type ChatSdkRuntime, +} from "../../chat-sdk-runtime.js"; + +import { + renderPublicationTransportText, +} from "../../chat-publication-text-parts.js"; + +import type { EndpointRecord } from "../runtime-types.js"; + +type ConversationRow = typeof chatConversations.$inferSelect; +type TelegramPublicationDraftsOptions = { + runtimeCallbackRecord: (endpointId: string, context: RuntimeContext, statuses: EndpointRow["status"][]) => Promise<EndpointRecord | null>; + runtimeCallbackEndpoint: (tx: DbTransaction, endpointId: string, context: LifecycleRuntimeFence, statuses: EndpointRow["status"][]) => Promise<EndpointRow | null>; + runtimeFor: (endpoint: EndpointRow) => Promise<ChatEndpointRuntime>; + runtime: Pick<ChatSdkRuntime | ChatRuntime, "get">; + runtimeContexts: WeakMap<object, RuntimeContext>; + credentialFingerprint: (refs: ToolCredentialSecretRef[]) => string; + authorizeRetainedChatSourcePublication: (tx: DbOrTransaction, publication: typeof chatPublications.$inferSelect) => Promise<boolean>; +}; + +export function createTelegramPublicationDrafts(db: Db, options: TelegramPublicationDraftsOptions) { + const { runtimeCallbackRecord, runtimeCallbackEndpoint, runtimeFor, runtime, runtimeContexts, credentialFingerprint, authorizeRetainedChatSourcePublication } = options; + + + async function handleTelegramGenerationStopped( + callback: ChatSdkCallbackEvent<TelegramGenerationStoppedProof>, + context: RuntimeContext, + ): Promise<void> { + const receipt = telegramGenerationStoppedReceipt(callback.event); + if (callback.provider !== "telegram" || !receipt) return; + const record = await runtimeCallbackRecord(callback.endpointId, context, [ + "verifying", + "active", + ]); + if ( + record?.endpoint.provider !== "telegram" || + record.endpoint.botExternalId !== receipt.botUserId + ) + return; + const candidates = await db + .select() + .from(chatActions) + .where( + and( + eq(chatActions.companyId, record.endpoint.companyId), + eq(chatActions.endpointId, callback.endpointId), + eq(chatActions.kind, TELEGRAM_DRAFT_ACTION_KIND), + sql`${chatActions.payload}->>'botUserId' = ${receipt.botUserId}`, + sql`${chatActions.payload}->>'draftId' = ${String(receipt.draftId)}`, + ), + ) + .limit(2); + if (candidates.length !== 1) return; + const candidate = candidates[0]!; + const binding = parseTelegramDraftBinding(candidate.payload); + if ( + !binding || + binding.chatId !== receipt.chatId || + binding.messageThreadId !== receipt.messageThreadId || + binding.runtimeGeneration !== context.generation || + binding.credentialFingerprint !== context.credentialFingerprint + ) + return; + await db.transaction(async (tx) => { + // The sender holds a renewable credential lease during draft HTTP. Do + // not acquire that lease here: a Stop can arrive inside the first RPC. + // Both Stop and final-send use the publication→endpoint→draft lock order. + const publication = await tx + .select() + .from(chatPublications) + .where( + and( + eq(chatPublications.companyId, record.endpoint.companyId), + eq(chatPublications.endpointId, callback.endpointId), + eq(chatPublications.conversationId, binding.conversationId), + eq(chatPublications.id, binding.publicationId), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + const endpoint = await runtimeCallbackEndpoint( + tx, + callback.endpointId, + context, + ["verifying", "active"], + ); + if ( + !publication || + !endpoint || + endpoint.provider !== "telegram" || + endpoint.botExternalId !== receipt.botUserId || + runtime.get(endpoint.id) !== context.endpointRuntime || + publication.attempts !== binding.publicationAttempt || + telegramDraftTextSha256( + renderPublicationTransportText(publication.payload), + ) !== binding.textSha256 + ) + return; + const connection = await tx + .select() + .from(toolConnections) + .where( + and( + eq(toolConnections.companyId, endpoint.companyId), + eq(toolConnections.id, endpoint.connectionId), + ), + ) + .for("share") + .then((rows) => rows[0] ?? null); + if ( + !connection?.enabled || + connection.status !== "active" || + credentialFingerprint(connection.credentialSecretRefs) !== + binding.credentialFingerprint + ) + return; + const conversation = await tx + .select() + .from(chatConversations) + .where( + and( + eq(chatConversations.companyId, endpoint.companyId), + eq(chatConversations.endpointId, endpoint.id), + eq(chatConversations.id, binding.conversationId), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + if ( + !conversation || + !conversation.isDirectMessage || + conversation.sessionGeneration !== binding.sessionGeneration || + conversation.externalThreadId !== + `telegram:${binding.chatId}${binding.messageThreadId === null ? "" : `:${binding.messageThreadId}`}` + ) + return; + const action = await tx + .select() + .from(chatActions) + .where(eq(chatActions.id, candidate.id)) + .for("update") + .then((rows) => rows[0] ?? null); + if ( + !action || + JSON.stringify(action.payload) !== JSON.stringify(candidate.payload) + ) + return; + // final_sending is an irreversible local I/O claim, not a delivery + // receipt. A late Stop must not claim that an in-flight final was undone. + if ( + action.result?.phase !== "drafting" || + action.status !== "processing" || + !["streaming", "delivery_unknown"].includes(publication.state) + ) + return; + const stoppedAt = new Date(); + await tx + .update(chatActions) + .set({ + status: "cancelled", + result: { + phase: "stopped", + updateId: receipt.updateId, + stoppedAt: stoppedAt.toISOString(), + }, + updatedAt: stoppedAt, + }) + .where(eq(chatActions.id, action.id)); + await notifyChatPublicationWork(tx); + await tx + .update(chatPublications) + .set({ + state: "cancelled", + nextAttemptAt: null, + redactedError: TELEGRAM_DRAFT_STOPPED_REASON, + updatedAt: stoppedAt, + }) + .where(eq(chatPublications.id, publication.id)); + await tx + .update(chatActions) + .set({ + status: "cancelled", + result: { + code: "telegram_draft_presentation_stopped", + attempts: publication.attempts, + }, + updatedAt: stoppedAt, + }) + .where( + and( + eq(chatActions.companyId, publication.companyId), + eq(chatActions.endpointId, publication.endpointId), + eq( + chatActions.providerActionId, + `task-control-authorization:${publication.id}`, + ), + eq(chatActions.status, "processing"), + sql`${chatActions.result}->>'attempts' = ${String(publication.attempts)}`, + ), + ); + }); + } + + async function reserveTelegramPublicationDraft(input: { + endpoint: EndpointRow; + conversation: ConversationRow; + publication: typeof chatPublications.$inferSelect; + text: string; + credentialLease: CredentialMutationLeaseGuard; + runtimeContext: RuntimeContext; + }): Promise<{ actionId: string; control: TelegramDraftControl }> { + const endpointRuntime = await runtimeFor(input.endpoint); + const registeredContext = runtimeContexts.get(endpointRuntime as object); + if ( + !registeredContext || + registeredContext.generation !== input.runtimeContext.generation || + registeredContext.credentialFingerprint !== + input.runtimeContext.credentialFingerprint || + registeredContext.version !== input.runtimeContext.version + ) + throw new Error("Telegram draft runtime authority changed"); + const draftRuntimeContext = { ...input.runtimeContext, endpointRuntime }; + const destination = telegramPrivateDraftDestination( + input.conversation.externalThreadId, + ); + if ( + !destination || + !input.conversation.isDirectMessage || + !input.endpoint.botExternalId + ) + throw new Error("Telegram private draft destination is invalid"); + const bindingBase = { + version: 1 as const, + publicationId: input.publication.id, + publicationAttempt: input.publication.attempts + 1, + conversationId: input.conversation.id, + sessionGeneration: input.conversation.sessionGeneration, + runtimeGeneration: input.runtimeContext.generation, + credentialFingerprint: input.runtimeContext.credentialFingerprint, + botUserId: input.endpoint.botExternalId, + ...destination, + textSha256: telegramDraftTextSha256(input.text), + }; + const reserved = await db.transaction(async (tx) => { + await input.credentialLease.assertOwned(tx); + const publication = await tx + .select() + .from(chatPublications) + .where( + and( + eq(chatPublications.id, input.publication.id), + eq(chatPublications.state, "streaming"), + eq(chatPublications.attempts, bindingBase.publicationAttempt), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + if (!publication) + throw new Error("Telegram draft publication ownership changed"); + const providerActionId = `telegram-draft-publication:${publication.id}:${publication.attempts}`; + const existing = await tx + .select({ id: chatActions.id }) + .from(chatActions) + .where( + and( + eq(chatActions.endpointId, input.endpoint.id), + eq(chatActions.providerActionId, providerActionId), + ), + ) + .limit(1); + // A second invocation of the same attempt cannot create another draft or + // replay an uncertain final. A new explicit publication attempt gets a + // fresh, never-before-used bot-global ID. + if (existing.length) + throw new Error("Telegram draft attempt already exists"); + // NO CYCLE and no company/endpoint ownership: rollback, endpoint deletion + // and bot re-binding cannot recycle a provider-visible draft identity. + // Exhaustion is a refusal, never a fallback to a process-local/random ID. + const allocated = await tx.execute( + sql`select nextval('chat_telegram_draft_ids')::integer as "draftId"`, + ); + const binding = parseTelegramDraftBinding({ + ...bindingBase, + draftId: allocated[0]?.draftId, + }); + if (!binding) + throw new Error("Telegram draft identity allocation was invalid"); + const [action] = await tx + .insert(chatActions) + .values({ + companyId: input.endpoint.companyId, + endpointId: input.endpoint.id, + conversationId: input.conversation.id, + kind: TELEGRAM_DRAFT_ACTION_KIND, + providerActionId, + payload: binding, + status: "issued", + result: { phase: "reserved" }, + }) + .returning(); + return { action: action!, binding }; + }); + const before = (final: boolean): Promise<boolean> => + db.transaction(async (tx) => { + await input.credentialLease.assertOwned(tx); + const publication = await tx + .select() + .from(chatPublications) + .where( + and( + eq(chatPublications.companyId, input.endpoint.companyId), + eq(chatPublications.id, input.publication.id), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + const endpoint = await runtimeCallbackEndpoint( + tx, + input.endpoint.id, + draftRuntimeContext, + ["verifying", "active"], + ); + if ( + !endpoint || + endpoint.provider !== "telegram" || + !endpoint.allowDirectMessages || + endpoint.botExternalId !== reserved.binding.botUserId || + runtime.get(endpoint.id) !== endpointRuntime + ) + throw new Error("Telegram draft endpoint authority changed"); + const connection = await tx + .select() + .from(toolConnections) + .where( + and( + eq(toolConnections.companyId, endpoint.companyId), + eq(toolConnections.id, endpoint.connectionId), + ), + ) + .for("share") + .then((rows) => rows[0] ?? null); + if ( + !connection?.enabled || + connection.status !== "active" || + credentialFingerprint(connection.credentialSecretRefs) !== + reserved.binding.credentialFingerprint + ) + throw new Error("Telegram draft credential authority changed"); + const conversation = await tx + .select() + .from(chatConversations) + .where( + and( + eq(chatConversations.companyId, endpoint.companyId), + eq(chatConversations.endpointId, endpoint.id), + eq(chatConversations.id, input.conversation.id), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + const action = await tx + .select() + .from(chatActions) + .where(eq(chatActions.id, reserved.action.id)) + .for("update") + .then((rows) => rows[0] ?? null); + if ( + !action || + JSON.stringify(action.payload) !== + JSON.stringify(reserved.action.payload) || + !publication || + publication.attempts !== reserved.binding.publicationAttempt + ) + throw new Error("Telegram draft attempt authority changed"); + if ( + action.status === "cancelled" && + action.result?.phase === "stopped" && + publication.state === "cancelled" + ) + return false; + if ( + publication.state !== "streaming" || + !conversation || + telegramDraftTextSha256( + renderPublicationTransportText(publication.payload), + ) !== reserved.binding.textSha256 || + conversation.sessionGeneration !== + reserved.binding.sessionGeneration || + conversation.externalThreadId !== + input.conversation.externalThreadId || + !["active", "waiting", "completed"].includes(conversation.state) || + !["reserved", "drafting"].includes(String(action.result?.phase)) || + !["issued", "processing"].includes(action.status) || + !(await authorizeRetainedChatSourcePublication(tx, publication)) + ) + throw new Error("Telegram draft source authority changed"); + await tx + .update(chatActions) + .set({ + status: "processing", + result: { + phase: final ? "final_sending" : "drafting", + }, + updatedAt: new Date(), + }) + .where(eq(chatActions.id, action.id)); + await input.credentialLease.assertOwned(tx); + return true; + }); + return { + actionId: reserved.action.id, + control: { + version: 1, + draftId: reserved.binding.draftId, + beforeDraft: () => before(false), + beforeFinal: () => before(true), + }, + }; + } + return { handleTelegramGenerationStopped, reserveTelegramPublicationDraft }; +} diff --git a/server/src/services/native-runtime/native-codex-runner.test.ts b/server/src/services/native-runtime/native-codex-runner.test.ts index c93d1b739e9..f5341c8a73e 100644 --- a/server/src/services/native-runtime/native-codex-runner.test.ts +++ b/server/src/services/native-runtime/native-codex-runner.test.ts @@ -12,7 +12,7 @@ import { resolveRemotePaperclipRunnerBinary, } from "./native-codex-runner.js"; -afterEach(() => vi.restoreAllMocks()); +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.unstubAllEnvs(); }); describe("installed runner selection", () => { it("uses the runner's platform-aware packaged selection", async () => { @@ -46,7 +46,7 @@ describe("installed runner selection", () => { }); }); -describe("selected SSH daemon target", () => { +describe("selected remote daemon target", () => { function header(target: string) { const bytes = Buffer.alloc(64); if (target.startsWith("darwin")) { @@ -72,10 +72,72 @@ describe("selected SSH daemon target", () => { expect(target.execute).toHaveBeenCalledExactlyOnceWith({ command: "sh", args: ["-c", "uname -s; uname -m"], cwd: "/selected-workspace", bypassSession: true, timeoutMs: 10_000 }); } finally { await rm(root, { recursive: true, force: true }); } }); + it.each(["arm64", "x64"].flatMap(controllerArch => ["arm64", "x64"].map(targetArch => ({ controllerArch, targetArch }))))( + "selects the actual Linux $targetArch sandbox bytes on a Mac $controllerArch controller", async ({ controllerArch, targetArch }) => { + const root = await mkdtemp(join(tmpdir(), "paperclip-sandbox-daemon-")); + const binary = join(root, "paperclip-runnerd"); + try { + vi.stubGlobal("process", { ...process, platform: "darwin", arch: controllerArch }); + await writeFile(binary, header(`linux-${targetArch}`)); + const controllerBinary = join(root, "controller-runnerd"); + await writeFile(controllerBinary, header(`darwin-${controllerArch}`), { mode: 0o700 }); + vi.stubEnv("PAPERCLIP_RUNNER_BINARY", controllerBinary); + const select = vi.spyOn(runner, "defaultCapabilityRunnerdBinary").mockReturnValue(binary); + const target = remote(`Linux\n${targetArch === "arm64" ? "aarch64" : "x86_64"}\n`); + expect(await resolveRemotePaperclipRunnerBinary({ runner: target, remoteCwd: "/sandbox-workspace" })).toBe(binary); + expect(select).toHaveBeenCalledExactlyOnceWith("linux", targetArch); + expect(target.execute).toHaveBeenCalledExactlyOnceWith({ command: "sh", args: ["-c", "uname -s; uname -m"], cwd: "/sandbox-workspace", bypassSession: true, timeoutMs: 10_000 }); + } finally { await rm(root, { recursive: true, force: true }); } + }, + ); + it.each(["linux", "darwin"].flatMap(platform => ["arm64", "x64"].map(architecture => ({ platform, architecture }))))( + "prefers the supervisor's fresh $platform/$architecture override for the same-host target", async ({ platform, architecture }) => { + const root = await mkdtemp(join(tmpdir(), "paperclip-same-host-daemon-")); + try { + vi.stubGlobal("process", { ...process, platform, arch: architecture }); + const fresh = join(root, "fresh-runnerd"), stale = join(root, "stale-packaged-runnerd"); + await writeFile(fresh, Buffer.concat([header(`${platform}-${architecture}`), Buffer.from("fresh")]), { mode: 0o700 }); + await writeFile(stale, Buffer.concat([header(`${platform}-${architecture}`), Buffer.from("stale")]), { mode: 0o700 }); + vi.stubEnv("PAPERCLIP_RUNNER_BINARY", fresh); + const select = vi.spyOn(runner, "defaultCapabilityRunnerdBinary").mockReturnValue(stale); + const target = remote(`${platform === "linux" ? "Linux" : "Darwin"}\n${architecture === "arm64" ? "arm64" : "x86_64"}\n`); + expect(await resolveRemotePaperclipRunnerBinary({ runner: target, remoteCwd: "/workspace" })).toBe(fresh); + expect(select).not.toHaveBeenCalled(); + } finally { await rm(root, { recursive: true, force: true }); } + }, + ); + it.each(["missing", "relative", "malformed", "wrong-architecture"])("retains same-host override policy and target validation (%s)", async scenario => { + const root = await mkdtemp(join(tmpdir(), "paperclip-same-host-daemon-")); + try { + vi.stubGlobal("process", { ...process, platform: "linux", arch: "x64" }); + const override = join(root, "configured-runnerd"), fallback = join(root, "packaged-runnerd"); + if (scenario !== "missing") await writeFile(override, scenario === "malformed" ? Buffer.alloc(64) : header("linux-arm64"), { mode: 0o700 }); + await writeFile(fallback, header("linux-x64"), { mode: 0o700 }); + vi.stubEnv("PAPERCLIP_RUNNER_BINARY", scenario === "relative" ? "relative-runnerd" : override); + vi.spyOn(runner, "defaultCapabilityRunnerdBinary").mockReturnValue(fallback); + const request = resolveRemotePaperclipRunnerBinary({ runner: remote("Linux\nx86_64\n"), remoteCwd: "/workspace" }); + if (scenario === "missing") expect(await request).toBe(fallback); + else await expect(request).rejects.toThrow(scenario === "relative" ? "PAPERCLIP_RUNNER_BINARY must be an absolute path" : "runner_remote_artifact_platform_mismatch"); + } finally { await rm(root, { recursive: true, force: true }); } + }); + it("ignores a relative controller override when the actual target differs", async () => { + const root = await mkdtemp(join(tmpdir(), "paperclip-cross-host-daemon-")); + try { + vi.stubGlobal("process", { ...process, platform: "darwin", arch: "arm64" }); + vi.stubEnv("PAPERCLIP_RUNNER_BINARY", "relative-controller-runnerd"); + const binary = join(root, "linux-runnerd"); + await writeFile(binary, header("linux-arm64")); + const select = vi.spyOn(runner, "defaultCapabilityRunnerdBinary").mockReturnValue(binary); + expect(await resolveRemotePaperclipRunnerBinary({ runner: remote("Linux\naarch64\n"), remoteCwd: "/workspace" })).toBe(binary); + expect(select).toHaveBeenCalledExactlyOnceWith("linux", "arm64"); + } finally { await rm(root, { recursive: true, force: true }); } + }); it.each(["matching", "wrong-architecture", "malformed", "missing"])("verifies an explicit controller-owned remote artifact (%s)", async scenario => { const root = await mkdtemp(join(tmpdir(), "paperclip-ssh-daemon-")); const binary = join(root, "configured-runnerd"); try { + vi.stubGlobal("process", { ...process, platform: "linux", arch: "arm64" }); + vi.stubEnv("PAPERCLIP_RUNNER_BINARY", "invalid-relative-controller-override"); if (scenario !== "missing") await writeFile(binary, scenario === "malformed" ? Buffer.alloc(64) : header(scenario === "matching" ? "linux-arm64" : "linux-x64")); const select = vi.spyOn(runner, "defaultCapabilityRunnerdBinary"); const request = resolveRemotePaperclipRunnerBinary({ runner: remote("Linux\naarch64\n"), remoteCwd: "/workspace", configuredPath: binary }); diff --git a/server/src/services/native-runtime/native-codex-runner.ts b/server/src/services/native-runtime/native-codex-runner.ts index 01edfbbe3c8..c5cda8a9942 100644 --- a/server/src/services/native-runtime/native-codex-runner.ts +++ b/server/src/services/native-runtime/native-codex-runner.ts @@ -208,7 +208,7 @@ export function resolvePaperclipRunnerBinary( ); } -/** Select controller-owned bytes for the selected SSH host, never its controller. */ +/** Select controller-owned bytes for the actual remote host, never its controller. */ export async function resolveRemotePaperclipRunnerBinary(input: { runner: CommandManagedRuntimeRunner; remoteCwd: string; @@ -220,11 +220,17 @@ export async function resolveRemotePaperclipRunnerBinary(input: { const platform = os === "Linux" ? "linux" : os === "Darwin" ? "darwin" : null; const architecture = arch === "x86_64" ? "x64" : arch === "aarch64" || arch === "arm64" ? "arm64" : null; if (probe.exitCode !== 0 || probe.timedOut || !platform || !architecture) { - throw new Error("runner_remote_artifact_platform_mismatch: could not identify a supported remote OS and architecture; check the SSH host and retry"); + throw new Error("runner_remote_artifact_platform_mismatch: could not identify a supported remote OS and architecture; check the selected execution environment and retry"); } const configured = input.configuredPath?.trim(); if (configured && !isAbsolute(configured)) throw new Error("PAPERCLIP_RUNNER_REMOTE_BINARY_PATH must be an absolute path"); - const binary = configured || defaultCapabilityRunnerdBinary(platform, architecture); + // A same-host sandbox can use the supervisor's fresh development artifact. + // Cross-platform targets must never inherit the controller's override. + const controllerOverride = platform === process.platform && architecture === process.arch + ? process.env.PAPERCLIP_RUNNER_BINARY : undefined; + const binary = configured || (controllerOverride + ? resolvePaperclipRunnerBinary(controllerOverride) + : defaultCapabilityRunnerdBinary(platform, architecture)); let bytes: Buffer; try { bytes = readFileSync(binary); } catch (cause) { throw new Error(`runner_remote_artifact_unavailable: install the Paperclip release containing the ${platform}-${architecture} daemon or configure PAPERCLIP_RUNNER_REMOTE_BINARY_PATH`, { cause }); } diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index 6d55a13594c..ccfc4524173 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -12256,10 +12256,8 @@ describe("runnerd provider runtime wiring", () => { // The mocked remote executes metadata probes; artifact staging only needs bytes. // Keep this regression independent of a locally compiled Rust runner binary. const controllerArtifact = join(isolatedStateDirectory, "paperclip-runnerd"); - if (needsReplacement || retained || image === "preinstalled-exact") { - await writeFile(controllerArtifact, "fixture runner artifact"); - state.resolveRunnerBinary.mockReturnValueOnce(controllerArtifact); - } + await writeFile(controllerArtifact, "fixture runner artifact"); + state.resolveRemoteRunnerBinary.mockResolvedValue(controllerArtifact); const onLog = vi.fn(async () => undefined); const syncIn = vi.fn(async () => undefined); const remoteExecute = vi.fn( @@ -12468,21 +12466,27 @@ describe("runnerd provider runtime wiring", () => { }); - it.each(["explicit", "linux-arm64", "linux-x64"])("binds an SSH launch to the selected controller-owned runner artifact (%s)", async mode => { + it.each(["ssh", "sandbox"].flatMap(transport => ["explicit", "linux-arm64", "linux-x64", "missing", "mismatch"].map(mode => ({ transport, mode })))) + ("binds a $transport launch to the selected controller-owned runner artifact ($mode)", async ({ transport, mode }) => { const remoteCwd = "/home/daytona/paperclip-workspace"; const controllerArtifact = `/controller/artifacts/${mode}/paperclip-runnerd`; - state.resolveRemoteRunnerBinary.mockResolvedValue(controllerArtifact); + const explicit = mode === "explicit" || mode === "missing" || mode === "mismatch"; + const error = mode === "missing" ? "runner_remote_artifact_unavailable" : mode === "mismatch" ? "runner_remote_artifact_platform_mismatch" : null; + if (error) state.resolveRemoteRunnerBinary.mockRejectedValue(new Error(error)); + else state.resolveRemoteRunnerBinary.mockResolvedValue(controllerArtifact); const remoteExecution = { ...execution, binding: { ...execution.binding, runId: "run-remote-runner-artifact" }, workspace: { ...execution.workspace, cwd: "/host/paperclip-workspace" }, } as NativeExecutionInputV1; - await createRunnerdBackend({ + const backend = createRunnerdBackend({ db: leaseDb(remoteExecution), execution: remoteExecution, runnerInstanceId: "runner", - runnerExecutionTarget: { + runnerExecutionTarget: transport === "sandbox" ? { + kind: "remote", transport: "sandbox", providerKey: "test", remoteCwd, runner: { execute: vi.fn() }, + } : { kind: "remote", transport: "ssh", remoteCwd, @@ -12497,15 +12501,25 @@ describe("runnerd provider runtime wiring", () => { strictHostKeyChecking: true, }, }, - ...(mode === "explicit" ? { runnerRemoteBinaryPath: controllerArtifact } : {}), + ...(explicit ? { runnerRemoteBinaryPath: controllerArtifact } : {}), }); + if (error) { + await expect(backend).rejects.toThrow(error); + expect(state.resolveRemoteRunnerBinary).toHaveBeenCalledWith(expect.objectContaining({ remoteCwd, configuredPath: controllerArtifact })); + expect(state.resolveRunnerBinary).not.toHaveBeenCalled(); + expect(state.createBackend).not.toHaveBeenCalled(); + expect(state.createTransport).not.toHaveBeenCalled(); + return; + } + await backend; + state.createTransport.mockClear(); state.createBackend.mock.calls.at(-1)![1].codexTransportFactory!(); expect(state.createTransport).toHaveBeenCalledWith( expect.objectContaining({ runnerBinary: controllerArtifact }), ); - expect(state.resolveRemoteRunnerBinary).toHaveBeenCalledWith(expect.objectContaining({ remoteCwd, configuredPath: mode === "explicit" ? controllerArtifact : undefined })); + expect(state.resolveRemoteRunnerBinary).toHaveBeenCalledWith(expect.objectContaining({ remoteCwd, configuredPath: explicit ? controllerArtifact : undefined })); expect(state.resolveRunnerBinary).not.toHaveBeenCalled(); }); diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 48a7022feab..d0761478359 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -11575,7 +11575,7 @@ async function createRunnerdBackendWithinSessionClaim( // filesystem; the remote launcher separately owns the sandbox command path. // When an explicit remote artifact is configured, prepareRemoteRunner stages // these exact bytes at remoteBinary before launch. - const targetMatchedCodexArtifact = remoteTarget?.transport === "ssh" && input.execution.provider.kind === "codex"; + const targetMatchedCodexArtifact = remoteTarget !== null && input.execution.provider.kind === "codex"; const controllerRunnerBinary = targetMatchedCodexArtifact ? await resolveRemotePaperclipRunnerBinary({ runner: remoteCommandRunner!, remoteCwd: remoteTarget.remoteCwd, configuredPath: input.runnerRemoteBinaryPath }) : remoteTarget diff --git a/server/src/services/native-runtime/setup-readiness.test.ts b/server/src/services/native-runtime/setup-readiness.test.ts index 97275712784..8a4d2dcf849 100644 --- a/server/src/services/native-runtime/setup-readiness.test.ts +++ b/server/src/services/native-runtime/setup-readiness.test.ts @@ -35,6 +35,7 @@ describe("Codex selected native account verification", () => { vi.stubEnv("PAPERCLIP_RUNNER_REMOTE_BINARY_PATH", ""); }); afterEach(async () => { + vi.unstubAllGlobals(); vi.unstubAllEnvs(); for (const [input] of nativeProbe.mock.calls) if (input.runtimeDirectory) await rm(input.runtimeDirectory, { recursive: true, force: true }); }); @@ -238,7 +239,12 @@ describe("Codex selected native account verification", () => { if (confirmed) await expect(stat(root)).rejects.toThrow(); else expect((await stat(root)).isDirectory()).toBe(true); }); - it("uses the same preparation and controller PRP for a sandbox, without a guest provider pack", async () => { + it.each(["arm64", "x64"].flatMap(controllerArch => ["arm64", "x64"].flatMap(targetArch => [false, true].map(explicit => ({ controllerArch, targetArch, explicit }))))) + ("uses target $targetArch artifacts and controller PRP from Mac $controllerArch for sandbox setup (override=$explicit)", async ({ controllerArch, targetArch, explicit }) => { + vi.stubGlobal("process", { ...process, platform: "darwin", arch: controllerArch }); + const selectedBinary = `/controller/artifacts/linux-${targetArch}/paperclip-runnerd`; + selectedRemoteRunner.mockResolvedValue(selectedBinary); + if (explicit) vi.stubEnv("PAPERCLIP_RUNNER_REMOTE_BINARY_PATH", selectedBinary); nativeProbe.mockImplementation(async input => { const registration = await input.transportOptions.controlPlaneRegistration({}); expect(registration.connection.mode).toBe("connect"); @@ -248,10 +254,23 @@ describe("Codex selected native account verification", () => { const target = { ...context.executionTarget, runner: { execute: sshExecute } }; expect((await testNativeRunnerAuthentication({ ...context, executionTarget: target }, "codex", "gpt-6.1-sol")).status).toBe("pass"); expect(sshRunner).not.toHaveBeenCalled(); - expect(nativeArtifacts).toHaveBeenCalledWith(expect.objectContaining({ target, runner: target.runner })); + expect(selectedRemoteRunner).toHaveBeenCalledExactlyOnceWith({ runner: target.runner, remoteCwd: target.remoteCwd, configuredPath: explicit ? selectedBinary : null }); + expect(selectedRunner).not.toHaveBeenCalled(); + expect(nativeArtifacts).toHaveBeenCalledWith(expect.objectContaining({ target, runner: target.runner, controllerRunnerBinary: selectedBinary, targetPlatformArtifactVerified: true })); + expect(nativeProbe.mock.calls[0][0].transportOptions.runnerBinary).toBe(selectedBinary); expect(nativeArtifacts.mock.results[0].value.prepare).toHaveBeenCalledWith("dial_wss"); expect(execute).not.toHaveBeenCalled(); }); + it.each(["runner_remote_artifact_unavailable", "runner_remote_artifact_platform_mismatch"])("fails sandbox %s before setup or a controller fallback", async code => { + selectedRemoteRunner.mockRejectedValue(new Error(code)); + const target = { ...context.executionTarget, runner: { execute: sshExecute } }; + const result = await testNativeRunnerAuthentication({ ...context, executionTarget: target }, "codex", "gpt-6.1-sol"); + expect(result.status).toBe("fail"); + expect(result.checks[0].message).toContain(code); + expect(selectedRunner).not.toHaveBeenCalled(); expect(nativeArtifacts).not.toHaveBeenCalled(); + expect(nativeProbe).not.toHaveBeenCalled(); expect(sshExecute).not.toHaveBeenCalled(); + expect(execute).not.toHaveBeenCalled(); + }); it("prepares listener artifacts before acquiring sandbox ingress and owns its release", async () => { const order: string[] = []; const close = vi.fn(); diff --git a/server/src/services/native-runtime/setup-readiness.ts b/server/src/services/native-runtime/setup-readiness.ts index c7afbc45abc..8cffbc9c13e 100644 --- a/server/src/services/native-runtime/setup-readiness.ts +++ b/server/src/services/native-runtime/setup-readiness.ts @@ -63,11 +63,9 @@ async function probeRemoteNativeCodex(options: { const remoteCodexNpmSpec = process.env.PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC?.trim() || null; const remoteCodexBinary = remoteCodexNpmSpec ? posix.join(remoteRoot, "harnesses", "codex", "node_modules", ".bin", "codex") : posix.join(remoteRoot, "bin", "codex"); const explicitRunner = process.env.PAPERCLIP_RUNNER_REMOTE_BINARY_PATH?.trim() || null; - const controllerRunnerBinary = target.transport === "ssh" - ? await resolveRemotePaperclipRunnerBinary({ runner, remoteCwd: target.remoteCwd, configuredPath: explicitRunner }) - : explicitRunner || resolvePaperclipRunnerBinary(); + const controllerRunnerBinary = await resolveRemotePaperclipRunnerBinary({ runner, remoteCwd: target.remoteCwd, configuredPath: explicitRunner }); const artifacts = createRemoteNativeArtifactPreparation({ target, runner, remoteBinary, controllerRunnerBinary, remoteRuntimeRoot: remoteRoot, remoteCodexBinary, - targetPlatformArtifactVerified: target.transport === "ssh", + targetPlatformArtifactVerified: true, runnerRemoteBinaryPath: explicitRunner, runnerRemoteCodexPath: process.env.PAPERCLIP_RUNNER_REMOTE_CODEX_PATH?.trim(), runnerRemoteCodexNpmSpec: remoteCodexNpmSpec, model: options.model }); const requireSuccess = (result: { timedOut: boolean; exitCode: number | null }, message: string) => { if (result.timedOut || result.exitCode !== 0) throw new Error(message); diff --git a/server/src/services/run-failure-diagnostics.ts b/server/src/services/run-failure-diagnostics.ts index 7a14c7e3d84..ae9614a09af 100644 --- a/server/src/services/run-failure-diagnostics.ts +++ b/server/src/services/run-failure-diagnostics.ts @@ -6,6 +6,7 @@ import { readProcessLossDiagnostic } from "./process-loss-diagnostics.js"; import { WORKSPACE_RESTORE_FAILURE_CODES } from "@paperclipai/shared"; import { redactDiagnosticText } from "@paperclipai/adapter-utils/command-redaction"; import { sanitizeWorkspaceRestoreDiagnostic } from "@paperclipai/adapter-utils/workspace-restore-diagnostics"; +import { ACP_TOOL_KINDS } from "@paperclipai/adapter-utils/acpx-engine/constants"; import { redactCurrentUserText } from "../log-redaction.js"; import { redactSensitiveText, REDACTED_EVENT_VALUE } from "../redaction.js"; import { readNativeModelRejectionDiagnostic } from "./native-runtime/native-provider-failure.js"; @@ -41,6 +42,15 @@ export interface RunFailureDiagnostics { truncatedFields: string[]; } +const ACP_PENDING_TOOL_KIND_LABELS: ReadonlySet<string> = new Set([...ACP_TOOL_KINDS, "unknown"]); + +/** Closed ACP tool-kind labels only; any other content drops the whole field. */ +function readAcpPendingToolKinds(value: unknown): string | null { + if (typeof value !== "string" || value.length === 0 || value.length > 200) return null; + const kinds = value.split(","); + return kinds.every((kind) => ACP_PENDING_TOOL_KIND_LABELS.has(kind)) ? value : null; +} + function read(value: unknown, key: string): unknown { if (!value || typeof value !== "object") return undefined; try { @@ -197,10 +207,15 @@ export function collectRunFailureDiagnostics(run: Run, options: RunFailureReport "mode", "stopReason", "timeoutFired", "timeoutSource", "timeoutConfigured", "effectiveTimeoutSec", "errorFamily", ])); - for (const field of ["acpLastEventAgeMs", "acpObservedEventCount", "acpPendingToolCount"]) { + for (const field of [ + "acpLastEventAgeMs", "acpObservedEventCount", "acpPendingToolCount", + "acpLastEventAgeAtStopMs", "acpOldestPendingToolAgeAtStopMs", + ]) { const value = read(result, field); if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) execution[field] = value; } + const pendingToolKinds = readAcpPendingToolKinds(read(result, "acpPendingToolKinds")); + if (pendingToolKinds) execution.acpPendingToolKinds = pendingToolKinds; const toolInventoryComplete = read(result, "acpToolInventoryComplete"); if (typeof toolInventoryComplete === "boolean") execution.acpToolInventoryComplete = toolInventoryComplete; const restoreFailure = read(result, "workspaceRestoreFailure");