From cc88b61c0918f45f6d3215f5d2d2368c88297046 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:31:44 -0500 Subject: [PATCH 1/8] feat: add portable verification skills and refresh the feature map (#15983) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Contributors use the feature map to select verification recipes. > - The repository needs separate skills to create a verifier, maintain recipes, and run checks. > - Shared recipes must match current routes, controls, and prerequisites. > - This pull request adds portable verification skills and updates the existing map. > - The benefit is a repeatable verification workflow with clear ownership and evidence limits. ## Linked Issues or Issue Description Refs: #15288, which added the existing feature map. The related test cleanup in #15978 changes test ownership; this PR changes skill instructions and recipes. **Issue type** Missing documentation and outdated verification instructions. **Where is the issue?** The project-local skills in `.agents/skills/` and the recipes in `feature-map/`. **What's wrong?** The repository lacks the portable creation and maintenance skills. The verifier mixes live verification with map editing. Some recipes describe old controls or omit required flags, identities, and provider setup. **Suggested fix** Import the skills and their complete references from Paperclip's pstack fork. Keep the existing map in its current directory. Let the verifier read recipes and report drift. Let the maintenance skill update the map. ## What Changed - Add `create-verification-skill` and `maintain-verification-skill` from the portable fork of Poteto's pstack. Include invocation policies, all feature-map examples, self-contained attribution notices and the full MIT license in each skill folder. - Use the existing `feature-map/` directory and Poteto's recipe format. Omit the fork's separate runtime skill and runtime-skill dependencies because Paperclip supplies execution. - Add `verify-paperclip` with executable launch, readiness, onboarding, and cleanup helpers. Use isolated temporary data and separate evidence directories. - Make `verify-paperclip` consume the map and report recipe drift. Give map editing to `maintain-verification-skill`. - Review all 35 feature files and correct 30 recipes. Clarify routes, flags, current controls, persistence scope, and the limits of linked tests. - Remove the unrelated Conference Room flag change from the onboarding smoke helper. - Add a standard-library ownership test. It proves that doctor and stop reject a foreign checkout without stopping its live server. A delayed-exit fixture also proves cleanup waits for runtime exit. - Keep audit screenshots and scratch reports outside the repository. No product code, migrations, lockfile, or workflow changes. ## Verification - Passed after rebase: metadata validation for all three skills, Python syntax and executable checks, JavaScript syntax, 35-entry map/index consistency, relative links, license preservation, and `git diff --check`. - Ran one isolated live audit before rebase. Checked task persistence, document upload/download bytes, goal hierarchy, project edits, search, local process Agent Chat, question replies, queued-message discard/stop, and a harmless loopback MCP call. - Checked local settings and draft flows. Synthetic cost events reached a small budget limit and paused the test agent. These checks do not prove external model quality or provider billing. - Checked cleanup of the audit server, database, local MCP fixture, and workspace service inventory. - Passed: `pnpm -r typecheck`. - Greptile: 5/5 on commit `4344ac4df7`. The cleanup finding is fixed and its review thread is resolved. A fresh review is pending for the notice relocation. - Passed: `python3 -m unittest discover -s .agents/skills/verify-paperclip/tests -v`. Both tests pass: the foreign fixture stayed alive after rejection, and owned cleanup waited for delayed runtime exit. - Passed: `pnpm build`. - All GitHub CI checks were green on commit `4344ac4df7`. The cloud-auth browser shard passed after one retry for a disposed mocked API response. The notice relocation passed skill metadata, relative-link, and license-preservation checks; fresh CI/review is pending. - The full local `pnpm test:run` did not complete. It was interrupted after nearly 30 minutes once the complete CI suite passed on the same commit. Exit code: 130. The new helper safety tests passed locally. The local full-suite checklist item stays unchecked. - Full live coverage is incomplete. Authenticated multi-user checks and external provider journeys need their own identities, credentials, and targets. - The corrected onboarding helper could not be re-tested after removing the flag mutation. Two fresh launches failed before server startup. Direct PostgreSQL initialization reported exhausted shared-memory IDs or limits. The host had free disk space. No host-wide limits or unrelated instances were changed. ## Risks - Recipe drift can return as the UI changes. The map records verification scope, not proof of a passed journey. - The new helpers assume a source checkout with installed dependencies, Python, Node, and `lsof`. The onboarding helper also needs Playwright. - The local audit found observations that need separate investigation: an action test returned fixture data while the connection control showed Off and native profiles existed; a failed status-card setup task left the card showing Setting up. Workspace service start also did not reach a running state. This PR does not change product behavior or redefine those outcomes as success. - The main audit trace did not finalize during browser shutdown. Screenshots, text captures, and iteration results were retained. - Live evidence was captured before rebase. The onboarding helper re-test and full provider/auth qualification remain incomplete. ## Model Used OpenAI GPT-6 through Codex assisted with this change. The session does not expose the exact serving model ID, configured context-window size, or reasoning setting. Capabilities used: source analysis, read-only source subagents, file edits, shell commands, and browser automation. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../create-verification-skill/NOTICE.md | 46 +++++ .../skills/create-verification-skill/SKILL.md | 45 +++++ .../agents/openai.yaml | 2 + .../references/feature-map-example/README.md | 47 +++++ .../feature-map-example/create-note.md | 39 ++++ .../references/feature-map-example/search.md | 45 +++++ .../maintain-verification-skill/NOTICE.md | 46 +++++ .../maintain-verification-skill/SKILL.md | 40 +++++ .../agents/openai.yaml | 2 + .agents/skills/verify-paperclip/SKILL.md | 166 ++++++++++++++++++ .../skills/verify-paperclip/scripts/doctor.py | 60 +++++++ .../skills/verify-paperclip/scripts/launch.py | 52 ++++++ .../scripts/onboarding-smoke.mjs | 79 +++++++++ .../skills/verify-paperclip/scripts/stop.py | 65 +++++++ .../verify-paperclip/tests/test_ownership.py | 124 +++++++++++++ feature-map/README.md | 14 +- feature-map/access.md | 9 +- feature-map/activity.md | 7 +- feature-map/agent-chat.md | 11 +- feature-map/agents.md | 10 +- feature-map/budgets-costs.md | 7 +- feature-map/cases.md | 12 +- feature-map/chat-channels.md | 11 +- feature-map/cli-operations.md | 7 +- feature-map/companies.md | 6 +- feature-map/connection-setup.md | 6 +- feature-map/developer-labs.md | 9 +- feature-map/documents-artifacts.md | 15 +- feature-map/execution-environments.md | 10 +- feature-map/gateways-profiles.md | 9 +- feature-map/goals.md | 9 +- feature-map/inbox-search.md | 6 +- feature-map/instance-operations.md | 7 +- feature-map/navigation-preferences.md | 7 +- feature-map/onboarding.md | 20 ++- feature-map/pipelines.md | 6 +- feature-map/plugins.md | 7 +- feature-map/projects.md | 9 +- feature-map/routines.md | 10 +- feature-map/runs-adapters.md | 6 +- feature-map/secrets.md | 6 +- feature-map/skills.md | 8 +- feature-map/task-coordination.md | 6 +- feature-map/tasks.md | 13 +- feature-map/teams.md | 6 +- feature-map/workspaces.md | 7 +- 46 files changed, 1086 insertions(+), 48 deletions(-) create mode 100644 .agents/skills/create-verification-skill/NOTICE.md create mode 100644 .agents/skills/create-verification-skill/SKILL.md create mode 100644 .agents/skills/create-verification-skill/agents/openai.yaml create mode 100644 .agents/skills/create-verification-skill/references/feature-map-example/README.md create mode 100644 .agents/skills/create-verification-skill/references/feature-map-example/create-note.md create mode 100644 .agents/skills/create-verification-skill/references/feature-map-example/search.md create mode 100644 .agents/skills/maintain-verification-skill/NOTICE.md create mode 100644 .agents/skills/maintain-verification-skill/SKILL.md create mode 100644 .agents/skills/maintain-verification-skill/agents/openai.yaml create mode 100644 .agents/skills/verify-paperclip/SKILL.md create mode 100755 .agents/skills/verify-paperclip/scripts/doctor.py create mode 100755 .agents/skills/verify-paperclip/scripts/launch.py create mode 100755 .agents/skills/verify-paperclip/scripts/onboarding-smoke.mjs create mode 100755 .agents/skills/verify-paperclip/scripts/stop.py create mode 100644 .agents/skills/verify-paperclip/tests/test_ownership.py diff --git a/.agents/skills/create-verification-skill/NOTICE.md b/.agents/skills/create-verification-skill/NOTICE.md new file mode 100644 index 00000000000..d887f754207 --- /dev/null +++ b/.agents/skills/create-verification-skill/NOTICE.md @@ -0,0 +1,46 @@ +# 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 existing repo-root `feature-map/`. +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..de10a42c2d4 --- /dev/null +++ b/.agents/skills/create-verification-skill/SKILL.md @@ -0,0 +1,45 @@ +--- +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, use the existing repo-root `feature-map/` directory and its `README.md`; have the generated verification skill reference that map instead of creating a second map inside the skill directory. Reuse existing feature files and use Poteto's format below for new or updated entries. 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..0c7b0648ebc --- /dev/null +++ b/.agents/skills/maintain-verification-skill/NOTICE.md @@ -0,0 +1,46 @@ +# 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 existing repo-root `feature-map/`. +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..d95e6e8842e --- /dev/null +++ b/.agents/skills/maintain-verification-skill/SKILL.md @@ -0,0 +1,40 @@ +--- +name: maintain-verification-skill +description: "Periodic pass that keeps a project's verification skill and feature map honest: parallel source readers per feature, one live session driving every feature, at most one PR of proven corrections. Use for /maintain-verification-skill or \"audit the verify skill\"." +--- + +# Maintain a verification skill + +A feature map rots the moment the app changes. This skill is the upkeep loop for a skill generated by `/create-verification-skill` (or any project-local verification skill with a feature map). The unit of rigor is the feature, not every sentence: cover every feature file from source and exercise every feature live, without terminalising every bullet. + +## Outcomes + +Pick one, and say which: + +- **clean** — every feature got source and live coverage; nothing worth shipping. No branch, no PR. +- **changed** — one PR ships proven doc, harness, or map corrections. +- **blocked** — coverage could not finish or a proven fix could not ship safely. Say exactly what blocked it. + +## Edit scope + +Only edit the verification skill's own directory (its SKILL.md and any harness scripts it owns) and its feature-map directory. For Paperclip, the map is the existing repo-root `feature-map/`; maintain it in place instead of creating or moving it into a skill directory. Use [Poteto's feature-map format](../create-verification-skill/references/feature-map-example/) for new or updated entries. Never edit product code during a run: a behavior the map describes that the app no longer does is either doc drift (fix the map) or a product regression (report it, don't paper over it in docs). + +## Pass + +0. **Locate the target.** Find the verification skill to maintain: the project-local skill whose body has launch/drive sections and a feature map (usually `.agents/skills/verify-*/`). Several candidates → ask which one; none → stop and point at `/create-verification-skill` instead of inventing a target. + +1. **Index hygiene.** Read the feature map README and glob its sibling files. Fix missing, extra, duplicate, or dead entries. Lightweight; no generated inventory. + +2. **Source wave.** One read-only subagent per feature file, launched concurrently. Each explains "how does this user-facing feature work?" from source, flags likely doc drift with citations, and returns one concise live-verification recipe. Children never drive the app and never edit files. Return shape: feature summary / source entry points / likely drift or none / one recipe. + +3. **Reconcile.** Every feature file has a returned summary. Merge overlapping recipes into as few app states as practical. Spot-check cited drift; don't re-prove clean claims. Sweep recent churn for user-facing surfaces missing from the map — require a concrete source path before calling one missing. + +4. **Live pass.** Required even when source looks clean. The coordinator owns all driving; follow the verification skill's own launch model — one long-lived instance driven serially for servers and UIs, or a fresh isolated session per drive for short-lived CLIs (the skill's Launch section decides, not this one). Exercise every feature at least once, and hold three invariants the whole pass, whatever the failure: (1) never drive an instance you haven't health-checked since it last did something surprising — doctor before first drive, doctor on each fresh session where sessions are the unit, doctor again after any failed drive, and where doctor can't see the failure (a wedged UI state on a healthy process), reset to a known state or relaunch rather than hoping; (2) evidence captured so far survives every cleanup, checked at its named location, not assumed; (3) nothing a drive started outlives that drive's usefulness — failed-iteration residue is cleaned whether the session is stuck, exited, or shared (for a shared instance, clean the residue, not the instance). A doctor failure caused by skill drift is drift: fix it under edit scope and retry once — restart whatever the fix invalidated, nothing more — before calling the pass `blocked`. A feature that can't be reached is `verified-unreachable` only with the concrete prerequisite (auth, entitlement, OS, external state) and the route attempted; if the map omits that prerequisite, that's drift. Any harness fix from triage gets re-driven live before it ships. Final teardown happens after the last drive of the run — including those re-proofs — so nothing outlives the run (evidence stays, per the skill). + +5. **Triage.** Wrong or missing user-POV description → doc drift, fix it. Working behavior the harness can't drive → harness gap, fix it; a harness fix follows the same helpers rule as generation (scripts executable, invocation documented in the skill body). App behavior that's actually broken → product gap; record it for the user, keep it out of this PR. + +6. **Ship or stop.** For changed: one PR of proven corrections, re-read every changed file first. For clean or blocked: no PR, report the outcome and the coverage honestly. + +Keep concise run notes (features covered, unreachable prerequisites, confirmed drift, outcome) in a scratch location; don't commit them. + +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..2728797cff8 --- /dev/null +++ b/.agents/skills/verify-paperclip/SKILL.md @@ -0,0 +1,166 @@ +--- +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 + +Use the repo-root [feature map](../../../feature-map/README.md) to select +verification recipes. Read the relevant feature file before driving it. Report +any recipe 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/feature-map/README.md b/feature-map/README.md index b38d7fb650c..623b4617465 100644 --- a/feature-map/README.md +++ b/feature-map/README.md @@ -9,7 +9,7 @@ Product behavior is still governed by [the implementation spec](../doc/SPEC-impl 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. @@ -113,7 +113,10 @@ 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 +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 @@ -168,10 +171,13 @@ 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/feature-map/access.md index d7248c8b63a..5d61c99ad2e 100644 --- a/feature-map/access.md +++ b/feature-map/access.md @@ -21,12 +21,15 @@ 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. @@ -48,6 +51,10 @@ Automated: [board auth client](../cli/src/__tests__/board-auth.test.ts) and [tok 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/feature-map/activity.md index a50206a9fca..fea542d988a 100644 --- a/feature-map/activity.md +++ b/feature-map/activity.md @@ -22,7 +22,7 @@ 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. @@ -38,6 +38,11 @@ Automated: [activity service](../server/src/__tests__/activity-service.test.ts), 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/feature-map/agent-chat.md index e47de311b56..e53725def70 100644 --- a/feature-map/agent-chat.md +++ b/feature-map/agent-chat.md @@ -25,7 +25,7 @@ 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. @@ -33,7 +33,10 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Enab 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` @@ -45,6 +48,10 @@ Manual: Ask the configured agent to create bounded project work, inspect the pro 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. ## Gotchas diff --git a/feature-map/agents.md b/feature-map/agents.md index a1633a52da5..021505b41c0 100644 --- a/feature-map/agents.md +++ b/feature-map/agents.md @@ -6,7 +6,8 @@ Implementation: [hiring](../ui/src/pages/NewAgent.tsx), [configuration](../ui/sr ## 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,7 +27,7 @@ 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. @@ -36,6 +37,11 @@ Automated: [new agent page](../ui/src/pages/NewAgent.test.tsx) and [configuratio 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. diff --git a/feature-map/budgets-costs.md b/feature-map/budgets-costs.md index e1a1efc5734..cd0bb3b2b99 100644 --- a/feature-map/budgets-costs.md +++ b/feature-map/budgets-costs.md @@ -21,7 +21,7 @@ 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. @@ -29,7 +29,10 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use 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` diff --git a/feature-map/cases.md b/feature-map/cases.md index 10cde78e5eb..f1c12ccb7a0 100644 --- a/feature-map/cases.md +++ b/feature-map/cases.md @@ -21,7 +21,7 @@ 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. @@ -35,9 +35,15 @@ Manual: Search for a child, verify its parent context remains understandable, ch 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/feature-map/chat-channels.md index 3399965dca4..071e53c1867 100644 --- a/feature-map/chat-channels.md +++ b/feature-map/chat-channels.md @@ -22,9 +22,13 @@ 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` @@ -40,6 +44,9 @@ Manual: Inspect the bound agent and identity, recent activity, and delivery fail ## 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/feature-map/cli-operations.md index 34c95f01c6c..b9d77696af9 100644 --- a/feature-map/cli-operations.md +++ b/feature-map/cli-operations.md @@ -25,10 +25,15 @@ 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` diff --git a/feature-map/companies.md b/feature-map/companies.md index d0ce6853189..585a0588a1a 100644 --- a/feature-map/companies.md +++ b/feature-map/companies.md @@ -21,7 +21,7 @@ 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. @@ -39,6 +39,10 @@ Manual: Export a small company, inspect the package preview, and import into a d ## 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/feature-map/connection-setup.md index 00c9328a9ae..de51d3c6b7d 100644 --- a/feature-map/connection-setup.md +++ b/feature-map/connection-setup.md @@ -57,7 +57,7 @@ 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, @@ -138,6 +138,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/developer-labs.md b/feature-map/developer-labs.md index dc9a7fbec05..41c24ed1dbb 100644 --- a/feature-map/developer-labs.md +++ b/feature-map/developer-labs.md @@ -21,7 +21,7 @@ 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. @@ -35,9 +35,14 @@ Manual: Open the specific fixture state, inspect controls and layout, then repea 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/feature-map/documents-artifacts.md b/feature-map/documents-artifacts.md index 39d96ade8bd..e445c9cddb1 100644 --- a/feature-map/documents-artifacts.md +++ b/feature-map/documents-artifacts.md @@ -21,12 +21,15 @@ 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 +## 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. @@ -38,9 +41,15 @@ Manual: Edit a paragraph, save, reload, and compare revisions. Add an annotation ### `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. +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 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. +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` diff --git a/feature-map/execution-environments.md b/feature-map/execution-environments.md index feb89f353d7..df145e17d3e 100644 --- a/feature-map/execution-environments.md +++ b/feature-map/execution-environments.md @@ -17,12 +17,16 @@ 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. @@ -32,6 +36,10 @@ Automated: [environment UI](../ui/src/pages/CompanyEnvironments.test.tsx), [driv 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. diff --git a/feature-map/gateways-profiles.md b/feature-map/gateways-profiles.md index 616c73d8c33..e00b5a03ac3 100644 --- a/feature-map/gateways-profiles.md +++ b/feature-map/gateways-profiles.md @@ -6,7 +6,7 @@ Implementation: [gateways](../ui/src/pages/apps/gateways/GatewaysList.tsx), [gat ## 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,10 +22,15 @@ 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. diff --git a/feature-map/goals.md b/feature-map/goals.md index a282d353454..1e83cf608e3 100644 --- a/feature-map/goals.md +++ b/feature-map/goals.md @@ -20,7 +20,7 @@ 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. @@ -28,7 +28,12 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Prep 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` diff --git a/feature-map/inbox-search.md b/feature-map/inbox-search.md index 2ef31ac7c7d..cdcaab7f44c 100644 --- a/feature-map/inbox-search.md +++ b/feature-map/inbox-search.md @@ -25,7 +25,7 @@ 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. @@ -49,6 +49,10 @@ Manual: Search a unique task title and content phrase, open the result, and comp ## 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/feature-map/instance-operations.md index dbed2230885..bb1c572621b 100644 --- a/feature-map/instance-operations.md +++ b/feature-map/instance-operations.md @@ -22,7 +22,7 @@ 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. @@ -40,6 +40,11 @@ Manual: Change a harmless experimental setting, reload, and confirm the affected ## 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/feature-map/navigation-preferences.md index 66794028f8f..7d957567dbc 100644 --- a/feature-map/navigation-preferences.md +++ b/feature-map/navigation-preferences.md @@ -22,7 +22,7 @@ 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. @@ -32,6 +32,11 @@ Automated: [sidebar](../ui/src/components/Sidebar.test.tsx) and [sidebar prefere 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. diff --git a/feature-map/onboarding.md b/feature-map/onboarding.md index 4a47e0a9fd4..a49cbb0f01b 100644 --- a/feature-map/onboarding.md +++ b/feature-map/onboarding.md @@ -17,12 +17,15 @@ Implementation: [web wizard](../ui/src/components/OnboardingWizard.tsx), [CLI se ### `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 +## 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. @@ -30,7 +33,20 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use 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. +- **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` diff --git a/feature-map/pipelines.md b/feature-map/pipelines.md index ac0bd5d48bf..efd59e34048 100644 --- a/feature-map/pipelines.md +++ b/feature-map/pipelines.md @@ -21,7 +21,7 @@ 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. @@ -39,6 +39,10 @@ Manual: Open a pending review, inspect its source output and revision, accept or ## 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/feature-map/plugins.md index b97f79758e2..4c6788cdd7b 100644 --- a/feature-map/plugins.md +++ b/feature-map/plugins.md @@ -21,7 +21,7 @@ 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. @@ -39,6 +39,11 @@ Manual: Open the contributed page by navigation and cold deep link, perform a ha ## 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/feature-map/projects.md index f2c7f455913..ff2cb3c2539 100644 --- a/feature-map/projects.md +++ b/feature-map/projects.md @@ -21,7 +21,7 @@ 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. @@ -39,6 +39,13 @@ Manual: Attach a small repository, save, and create a project task. Inspect the ## 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/routines.md b/feature-map/routines.md index c951e170912..99e437da265 100644 --- a/feature-map/routines.md +++ b/feature-map/routines.md @@ -20,9 +20,10 @@ 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. @@ -32,6 +33,11 @@ Automated: [routine detail helper](../ui/src/pages/RoutineDetail.test.tsx) tests 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. diff --git a/feature-map/runs-adapters.md b/feature-map/runs-adapters.md index 6b1f1a4f1e1..93651b8fd89 100644 --- a/feature-map/runs-adapters.md +++ b/feature-map/runs-adapters.md @@ -21,7 +21,7 @@ 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. @@ -39,6 +39,10 @@ Manual: Start a disposable task, open its running transcript, and follow it to a ## 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/feature-map/secrets.md index 71dcfb7c262..d9d6d42ff26 100644 --- a/feature-map/secrets.md +++ b/feature-map/secrets.md @@ -22,7 +22,7 @@ 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. @@ -40,6 +40,10 @@ Manual: Review a dummy proposal, reject one and accept another, then inspect the ## 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/feature-map/skills.md index c8565f2b2a7..ed289935fef 100644 --- a/feature-map/skills.md +++ b/feature-map/skills.md @@ -21,12 +21,15 @@ 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. @@ -50,6 +53,9 @@ Manual: Enable the skill for one test agent and leave another without access. Ru ## 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/task-coordination.md b/feature-map/task-coordination.md index 9549f7606d3..609c86dc9f8 100644 --- a/feature-map/task-coordination.md +++ b/feature-map/task-coordination.md @@ -21,10 +21,14 @@ 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. diff --git a/feature-map/tasks.md b/feature-map/tasks.md index a2a7e42bc39..b32039dbdd6 100644 --- a/feature-map/tasks.md +++ b/feature-map/tasks.md @@ -26,9 +26,12 @@ 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` @@ -36,6 +39,10 @@ Automated: [creation dialog](../ui/src/components/NewIssueDialog.test.tsx) is co 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. @@ -50,6 +57,8 @@ Manual: Add a comment and edit authorized properties. Reload and verify history, ## 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/feature-map/teams.md index a1370c75ebd..f5822ab4645 100644 --- a/feature-map/teams.md +++ b/feature-map/teams.md @@ -21,7 +21,7 @@ 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. @@ -39,6 +39,10 @@ Manual: When changing or mounting these components, exercise preview, manager/so ## 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/feature-map/workspaces.md index d7b24fca7c1..acf92476222 100644 --- a/feature-map/workspaces.md +++ b/feature-map/workspaces.md @@ -22,7 +22,7 @@ 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. @@ -40,6 +40,11 @@ Manual: Provision a disposable workspace, wait for readiness, start an available ## 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. From e79254b2fa5fbd796fb4bd4e8324a134904d565c Mon Sep 17 00:00:00 2001 From: Dotta <bippadotta@protonmail.com> Date: Mon, 12 Oct 2026 14:12:14 -0500 Subject: [PATCH 2/8] fix: preserve saved setup and match sandbox runner targets Share actual-target artifact selection across remote Codex setup and execution. Keep unchanged saved setup choices outside new-selection rules while retaining runtime and authentication validation. Update the served platform guide. Co-Authored-By: Paperclip <noreply@paperclip.ing> --- doc/plans/2026-10-06-default-agent-runner.md | 29 +++++++- packages/adapters/codex-local/src/index.ts | 5 +- .../agent-adapter-validation-routes.test.ts | 72 +++++++++++++++++++ server/src/routes/agents.ts | 18 +++-- .../native-codex-runner.test.ts | 19 ++++- .../native-runtime/native-codex-runner.ts | 4 +- .../native-session-executor.test.ts | 34 ++++++--- .../native-runtime/native-session-executor.ts | 2 +- .../native-runtime/setup-readiness.test.ts | 23 +++++- .../native-runtime/setup-readiness.ts | 6 +- 10 files changed, 182 insertions(+), 30 deletions(-) diff --git a/doc/plans/2026-10-06-default-agent-runner.md b/doc/plans/2026-10-06-default-agent-runner.md index 0ff7292e611..199012a25ac 100644 --- a/doc/plans/2026-10-06-default-agent-runner.md +++ b/doc/plans/2026-10-06-default-agent-runner.md @@ -4,7 +4,7 @@ Updated 2026-10-12. ## Latest checkpoint: ARM qualification and master integration -The native runtime, protocol, Codex adapter/driver, selection code, and provider +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 +53,32 @@ 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. + +Next action: require fresh CI/review on the corrected integrated 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/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/server/src/__tests__/agent-adapter-validation-routes.test.ts b/server/src/__tests__/agent-adapter-validation-routes.test.ts index a834223c890..d48b1e8080b 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 4672e4b8ea3..4d1b6ec2c31 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/native-runtime/native-codex-runner.test.ts b/server/src/services/native-runtime/native-codex-runner.test.ts index c93d1b739e9..2648d68b9e5 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(); }); 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,6 +72,21 @@ 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 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(["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"); diff --git a/server/src/services/native-runtime/native-codex-runner.ts b/server/src/services/native-runtime/native-codex-runner.ts index 01edfbbe3c8..962b7fb30e7 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,7 +220,7 @@ 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"); 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 824b7ba82b5..d49205ae65b 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -12218,10 +12218,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( @@ -12430,21 +12428,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, @@ -12459,15 +12463,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 0dedc9812d8..b0fb8b56c98 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -11551,7 +11551,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); From 0d0e138dadd006a7e5597f519e888f4c470da497 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:26:54 -0500 Subject: [PATCH 3/8] refactor(chat): extract channel status, receipts, drafts and authority workflows (#15995) ## Thinking Path > - Paperclip manages AI agents and their work. > - Chat channels connect that work to provider conversations. > - Provider workflows still fill the shared service. > - Their authority and recovery rules need clear homes. > - This PR lands five reviewed extractions into channel folders. > - Shared authorization, state and worker lifetime stay explicit. ## Linked Issues or Issue Description Refs #15976, #15996, #15999, #16001 and #16002. This root PR now contains all five extractions from batch three. The four upper PRs were merged into their parents. The combined tree matches reviewed stack head `789e918b5af49ec1958076ce4d22d31006205302` exactly. ## What Changed - Extract Slack session status and accepted-file receipt recovery. - Extract Telegram publication drafts and Stop handling. - Extract Photon events and native responses. - Extract Teams file-transfer authority. - Reduce `chat-channels.ts` from 29,897 to 26,461 lines (3,436 lines, 11.5%). - Preserve function bodies, runtime-map identity, leases and transaction boundaries. ## Verification - All 117 selected public-service cases passed before and after their respective extractions. - All 30 moved declarations and remaining service statements preserve normalized syntax across the five slices. - Server typecheck, module boundaries and whitespace checks passed. - Cumulative `pnpm -r typecheck` and `pnpm build` passed. Combined-head CI and review are pending on `5de1b0b99d71049a72440e8c131bf4bb30bdf3f3`. - Fixtures exercise assembled services and disposable databases, not live third-party providers. - Cumulative local `pnpm test:run` on `789e918b5af49ec1958076ce4d22d31006205302` exited 1: 32,414 passed, 125 skipped, one failure. `company-skills-routes.test.ts:1691` (`stars, forks, and comments on skills through company-scoped endpoints`) failed with `socket hang up`; that test and its route are unchanged by this stack. The same 58-case suite then passed in isolation without code changes. The original command did not reach 111 later serialized suites; its failure is retained, so the full-local-test checklist remains unchecked. ```sh pnpm exec vitest run server/src/__tests__/chat-channels.integration.test.ts -t 'Slack session|Slack turns to Idle|signed Slack session stop|denies guest Slack stops' ``` - Combined-head CI attempt 1: five runners received a shutdown signal at 19:13:08 UTC (server shard 10/12, serialized shards 2/9 and 7/9, E2E shards 3/8 and 7/8). These jobs stopped during execution without a failing test assertion. [Original workflow](https://github.com/paperclipai/paperclip/actions/runs/38293566028). The affected jobs will receive one unchanged retry after the other jobs finish. ## Risks - Changes cross durable provider recovery and authority boundaries. The moved bodies preserve syntax, and 117 focused public-service cases passed before and after their extractions. - Shared workers, timers, runtime maps and generic authorization remain in the service. - The full local test run failed in an unchanged company-skills route test; its result and the unrun suites are recorded above. ## Model Used OpenAI Codex, GPT-6 family, with reasoning and repository tools. The exact runtime model ID and context-window size are not exposed in this session. No separate agent was used. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing> --- ...-10-12-chat-channel-transports-refactor.md | 124 + server/src/services/chat-channels.ts | 3472 +---------------- .../chat-channels/imessage-photon/events.ts | 903 +++++ .../file-transfer-authority.ts | 1041 +++++ .../chat-channels/slack/file-receipts.ts | 1000 +++++ .../chat-channels/slack/session-status.ts | 408 ++ .../telegram/publication-drafts.ts | 461 +++ 7 files changed, 3955 insertions(+), 3454 deletions(-) create mode 100644 doc/plans/2026-10-12-chat-channel-transports-refactor.md create mode 100644 server/src/services/chat-channels/imessage-photon/events.ts create mode 100644 server/src/services/chat-channels/microsoft-teams/file-transfer-authority.ts create mode 100644 server/src/services/chat-channels/slack/file-receipts.ts create mode 100644 server/src/services/chat-channels/slack/session-status.ts create mode 100644 server/src/services/chat-channels/telegram/publication-drafts.ts 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/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 }; +} From 4b766b872993045a2e787404f0475503683e5ded Mon Sep 17 00:00:00 2001 From: Devin Foley <devin@devinfoley.com> Date: Mon, 12 Oct 2026 12:33:59 -0700 Subject: [PATCH 4/8] fix(acpx): record turn activity at the moment a stop fires (#15962) Record stop-time ACP turn diagnostics (last-event age, oldest pending tool age, pending tool kinds) so wall-clock timeout events show whether the turn was stalled. Diagnostics only. Co-Authored-By: Paperclip <Paperclip-Paperclip@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- doc/observability.md | 11 ++ .../src/acpx-engine/constants.ts | 7 ++ .../src/acpx-engine/execute.test.ts | 113 ++++++++++++++++++ .../adapter-utils/src/acpx-engine/execute.ts | 64 +++++++++- .../__tests__/run-failure-diagnostics.test.ts | 20 +++- .../src/services/run-failure-diagnostics.ts | 17 ++- 6 files changed, 226 insertions(+), 6 deletions(-) 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/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/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/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"); From bb7591dfd0c4c8569e2a0b4303ff06de8ca3e9e2 Mon Sep 17 00:00:00 2001 From: Dotta <bippadotta@protonmail.com> Date: Mon, 12 Oct 2026 14:34:41 -0500 Subject: [PATCH 5/8] fix: retain matching-host runner development overrides Use the controller override only for matching remote OS and CPU, preserving explicit remote priority and strict target header verification. Co-Authored-By: Paperclip <noreply@paperclip.ing> --- doc/plans/2026-10-06-default-agent-runner.md | 21 +++++++- .../native-codex-runner.test.ts | 49 ++++++++++++++++++- .../native-runtime/native-codex-runner.ts | 8 ++- 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/doc/plans/2026-10-06-default-agent-runner.md b/doc/plans/2026-10-06-default-agent-runner.md index 199012a25ac..a4e67d93a1f 100644 --- a/doc/plans/2026-10-06-default-agent-runner.md +++ b/doc/plans/2026-10-06-default-agent-runner.md @@ -78,7 +78,26 @@ 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. -Next action: require fresh CI/review on the corrected integrated candidate, refresh the +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/server/src/services/native-runtime/native-codex-runner.test.ts b/server/src/services/native-runtime/native-codex-runner.test.ts index 2648d68b9e5..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(); vi.unstubAllGlobals(); }); +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.unstubAllEnvs(); }); describe("installed runner selection", () => { it("uses the runner's platform-aware packaged selection", async () => { @@ -79,6 +79,9 @@ describe("selected remote daemon target", () => { 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); @@ -87,10 +90,54 @@ describe("selected remote daemon target", () => { } 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 962b7fb30e7..c5cda8a9942 100644 --- a/server/src/services/native-runtime/native-codex-runner.ts +++ b/server/src/services/native-runtime/native-codex-runner.ts @@ -224,7 +224,13 @@ export async function resolveRemotePaperclipRunnerBinary(input: { } 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 }); } From 7d9b93aee2b79b2f035c5838741ce0b94168ec83 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:52:28 -0500 Subject: [PATCH 6/8] docs: establish paired feature and agent access maps (#16025) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Contributors use the feature map to understand and verify product capabilities. > - Agents need APIs and usable entry points within their permission boundaries. > - Product recipes and agent access need separate records with shared capability IDs. > - This pull request establishes that contract and gives maintenance ownership of both maps. > - The benefit is repeatable access review without changing product behavior or permissions. ## Linked Issues or Issue Description Refs: #15983, which added the portable verification skills and updated the feature map. This is its contract-only follow-up. Related: #15288 added the original feature map. **Issue type** Missing documentation and maintenance contract. **Where is the issue?** The product map, its missing agent access companions, and the verification skills. **What's wrong?** There is no shared convention for API presence, agent entry points, permission boundaries, access gaps, or evidence. An API declaration does not prove that a permitted native or remote agent can use it. **Suggested fix** Keep the product and agent access maps as siblings under `maps/`. Reuse capability IDs. Record representative source inventory separately from operation review and live proof. Keep both maps aligned through the maintenance skill. ## What Changed - Move the existing product map to `maps/feature-map/`. Preserve its recipes and Poteto format. Update relative links and add companion links. - Add `maps/agent-access-map/` with 35 area files and 171 matching capability IDs. Seed representative API operations, agent paths, policies and known gaps from the reviewed source inventory. Mark entries as `baseline`, `source-only`, and live verification `not-run`. - Define the shared contract in `maps/README.md`. Track API presence, agent availability, policy, evidence and review depth separately. Require operation enumeration before marking a broad capability operation-reviewed. - Extend `maintain-verification-skill` to reconcile both maps and inspect actual route policy and production tool admission. Update creation, verification, notices and contributor references to use the shared contract. - Add `scripts/check-maps.mjs` and ten regression tests. Check matching areas/IDs, indexes, references, contract fields, API method/path examples, justified exceptions and evidence-state consistency. - Keep the coverage inventory unchanged. Preserve skill invocation policies and both MIT license blocks. No product API, permission, UI, runtime helper, migration, dependency or workflow changes. ## Verification - Passed: `node scripts/check-maps.mjs` reports 35 paired areas and 171 matching capability IDs. All local map references resolve. - Passed: `node --test scripts/check-maps.test.mjs` runs ten tests. Fixtures cover missing companions, renamed/orphan/duplicate IDs, broken references, missing operation fields, empty exceptions, missing API examples, empty policy fields, and verified evidence without passed verification. - Passed: metadata validation for all three verification skills. Skill and contributor links resolve. Invocation policies and both license blocks are unchanged. - Compared relocated recipes with current master. Contents differ only in relative links and companion links. `coverage.json` is byte-for-byte unchanged. Master's updated read-only interaction test reference is retained. - Passed: `git diff --check`. The branch starts from current master and contains only this contract change. - Repository-wide typecheck, tests and build were not run locally for this documentation and Node structural-check scope. Live agent access was not exercised. Those gaps are explicit in the baseline entries; the structural check does not certify behavior. CI and automated review are pending. ## Risks - The companions are representative source baselines. They are not complete operation inventories or passed agent journeys. - The checker cannot establish actual permissions, native tool admission, operation completeness or truthful live evidence. Maintenance must inspect source and verify with the actual permitted agent context. - External links to the old product-map directory may need updates. Repository references have been updated. - Existing access gaps and board/user restrictions remain recorded. This contract does not change authority or implement missing paths. ## Model Used OpenAI GPT-6 through Codex assisted with this change. The session does not expose the exact serving model ID, configured context-window size, or reasoning setting. Capabilities used: source analysis, file edits, shell commands, and structural regression tests. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip <noreply@paperclip.ing> --- .../create-verification-skill/NOTICE.md | 3 +- .../skills/create-verification-skill/SKILL.md | 14 +- .../maintain-verification-skill/NOTICE.md | 3 +- .../maintain-verification-skill/SKILL.md | 116 ++++++++-- .agents/skills/verify-paperclip/SKILL.md | 10 +- AGENTS.md | 8 +- doc/DEVELOPING.md | 8 +- maps/README.md | 135 +++++++++++ maps/agent-access-map/README.md | 69 ++++++ maps/agent-access-map/access.md | 136 +++++++++++ maps/agent-access-map/activity.md | 129 +++++++++++ maps/agent-access-map/agent-chat.md | 113 ++++++++++ maps/agent-access-map/agents.md | 140 ++++++++++++ maps/agent-access-map/app-permissions.md | 132 +++++++++++ maps/agent-access-map/budgets-costs.md | 108 +++++++++ maps/agent-access-map/cases.md | 108 +++++++++ maps/agent-access-map/chat-channels.md | 132 +++++++++++ maps/agent-access-map/cli-operations.md | 134 +++++++++++ maps/agent-access-map/companies.md | 115 ++++++++++ maps/agent-access-map/connection-setup.md | 207 +++++++++++++++++ maps/agent-access-map/developer-labs.md | 99 ++++++++ maps/agent-access-map/documents-artifacts.md | 139 ++++++++++++ .../execution-environments.md | 132 +++++++++++ maps/agent-access-map/gateways-profiles.md | 131 +++++++++++ maps/agent-access-map/goals.md | 87 +++++++ maps/agent-access-map/inbox-search.md | 112 +++++++++ maps/agent-access-map/instance-operations.md | 130 +++++++++++ .../navigation-preferences.md | 138 ++++++++++++ maps/agent-access-map/onboarding.md | 136 +++++++++++ maps/agent-access-map/pipelines.md | 109 +++++++++ maps/agent-access-map/plugins.md | 107 +++++++++ maps/agent-access-map/projects.md | 113 ++++++++++ .../questions-and-approvals.md | 212 ++++++++++++++++++ maps/agent-access-map/recovery.md | 207 +++++++++++++++++ maps/agent-access-map/routines.md | 135 +++++++++++ maps/agent-access-map/runs-adapters.md | 111 +++++++++ maps/agent-access-map/secrets.md | 138 ++++++++++++ maps/agent-access-map/skills.md | 141 ++++++++++++ maps/agent-access-map/status-cards.md | 110 +++++++++ maps/agent-access-map/steering.md | 180 +++++++++++++++ maps/agent-access-map/task-coordination.md | 114 ++++++++++ maps/agent-access-map/tasks.md | 136 +++++++++++ maps/agent-access-map/teams.md | 108 +++++++++ maps/agent-access-map/workspaces.md | 144 ++++++++++++ {feature-map => maps/feature-map}/README.md | 32 ++- {feature-map => maps/feature-map}/access.md | 10 +- {feature-map => maps/feature-map}/activity.md | 8 +- .../feature-map}/agent-chat.md | 10 +- {feature-map => maps/feature-map}/agents.md | 10 +- .../feature-map}/app-permissions.md | 8 +- .../feature-map}/budgets-costs.md | 8 +- {feature-map => maps/feature-map}/cases.md | 8 +- .../feature-map}/chat-channels.md | 8 +- .../feature-map}/cli-operations.md | 10 +- .../feature-map}/companies.md | 8 +- .../feature-map}/connection-setup.md | 24 +- .../feature-map}/coverage.json | 0 .../feature-map}/developer-labs.md | 8 +- .../feature-map}/documents-artifacts.md | 10 +- .../feature-map}/execution-environments.md | 8 +- .../feature-map}/gateways-profiles.md | 8 +- {feature-map => maps/feature-map}/goals.md | 8 +- .../feature-map}/inbox-search.md | 12 +- .../feature-map}/instance-operations.md | 8 +- .../feature-map}/navigation-preferences.md | 8 +- .../feature-map}/onboarding.md | 10 +- .../feature-map}/pipelines.md | 8 +- {feature-map => maps/feature-map}/plugins.md | 8 +- {feature-map => maps/feature-map}/projects.md | 8 +- .../feature-map}/questions-and-approvals.md | 32 +-- {feature-map => maps/feature-map}/recovery.md | 26 ++- {feature-map => maps/feature-map}/routines.md | 8 +- .../feature-map}/runs-adapters.md | 8 +- {feature-map => maps/feature-map}/secrets.md | 8 +- {feature-map => maps/feature-map}/skills.md | 10 +- .../feature-map}/status-cards.md | 8 +- {feature-map => maps/feature-map}/steering.md | 20 +- .../feature-map}/task-coordination.md | 8 +- {feature-map => maps/feature-map}/tasks.md | 10 +- {feature-map => maps/feature-map}/teams.md | 8 +- .../feature-map}/workspaces.md | 8 +- scripts/check-maps.mjs | 131 +++++++++++ scripts/check-maps.test.mjs | 89 ++++++++ 83 files changed, 5425 insertions(+), 186 deletions(-) create mode 100644 maps/README.md create mode 100644 maps/agent-access-map/README.md create mode 100644 maps/agent-access-map/access.md create mode 100644 maps/agent-access-map/activity.md create mode 100644 maps/agent-access-map/agent-chat.md create mode 100644 maps/agent-access-map/agents.md create mode 100644 maps/agent-access-map/app-permissions.md create mode 100644 maps/agent-access-map/budgets-costs.md create mode 100644 maps/agent-access-map/cases.md create mode 100644 maps/agent-access-map/chat-channels.md create mode 100644 maps/agent-access-map/cli-operations.md create mode 100644 maps/agent-access-map/companies.md create mode 100644 maps/agent-access-map/connection-setup.md create mode 100644 maps/agent-access-map/developer-labs.md create mode 100644 maps/agent-access-map/documents-artifacts.md create mode 100644 maps/agent-access-map/execution-environments.md create mode 100644 maps/agent-access-map/gateways-profiles.md create mode 100644 maps/agent-access-map/goals.md create mode 100644 maps/agent-access-map/inbox-search.md create mode 100644 maps/agent-access-map/instance-operations.md create mode 100644 maps/agent-access-map/navigation-preferences.md create mode 100644 maps/agent-access-map/onboarding.md create mode 100644 maps/agent-access-map/pipelines.md create mode 100644 maps/agent-access-map/plugins.md create mode 100644 maps/agent-access-map/projects.md create mode 100644 maps/agent-access-map/questions-and-approvals.md create mode 100644 maps/agent-access-map/recovery.md create mode 100644 maps/agent-access-map/routines.md create mode 100644 maps/agent-access-map/runs-adapters.md create mode 100644 maps/agent-access-map/secrets.md create mode 100644 maps/agent-access-map/skills.md create mode 100644 maps/agent-access-map/status-cards.md create mode 100644 maps/agent-access-map/steering.md create mode 100644 maps/agent-access-map/task-coordination.md create mode 100644 maps/agent-access-map/tasks.md create mode 100644 maps/agent-access-map/teams.md create mode 100644 maps/agent-access-map/workspaces.md rename {feature-map => maps/feature-map}/README.md (87%) rename {feature-map => maps/feature-map}/access.md (78%) rename {feature-map => maps/feature-map}/activity.md (75%) rename {feature-map => maps/feature-map}/agent-chat.md (77%) rename {feature-map => maps/feature-map}/agents.md (76%) rename {feature-map => maps/feature-map}/app-permissions.md (76%) rename {feature-map => maps/feature-map}/budgets-costs.md (79%) rename {feature-map => maps/feature-map}/cases.md (78%) rename {feature-map => maps/feature-map}/chat-channels.md (79%) rename {feature-map => maps/feature-map}/cli-operations.md (77%) rename {feature-map => maps/feature-map}/companies.md (76%) rename {feature-map => maps/feature-map}/connection-setup.md (88%) rename {feature-map => maps/feature-map}/coverage.json (100%) rename {feature-map => maps/feature-map}/developer-labs.md (76%) rename {feature-map => maps/feature-map}/documents-artifacts.md (71%) rename {feature-map => maps/feature-map}/execution-environments.md (76%) rename {feature-map => maps/feature-map}/gateways-profiles.md (74%) rename {feature-map => maps/feature-map}/goals.md (73%) rename {feature-map => maps/feature-map}/inbox-search.md (68%) rename {feature-map => maps/feature-map}/instance-operations.md (78%) rename {feature-map => maps/feature-map}/navigation-preferences.md (77%) rename {feature-map => maps/feature-map}/onboarding.md (79%) rename {feature-map => maps/feature-map}/pipelines.md (77%) rename {feature-map => maps/feature-map}/plugins.md (78%) rename {feature-map => maps/feature-map}/projects.md (80%) rename {feature-map => maps/feature-map}/questions-and-approvals.md (84%) rename {feature-map => maps/feature-map}/recovery.md (87%) rename {feature-map => maps/feature-map}/routines.md (73%) rename {feature-map => maps/feature-map}/runs-adapters.md (76%) rename {feature-map => maps/feature-map}/secrets.md (75%) rename {feature-map => maps/feature-map}/skills.md (76%) rename {feature-map => maps/feature-map}/status-cards.md (74%) rename {feature-map => maps/feature-map}/steering.md (87%) rename {feature-map => maps/feature-map}/task-coordination.md (75%) rename {feature-map => maps/feature-map}/tasks.md (76%) rename {feature-map => maps/feature-map}/teams.md (80%) rename {feature-map => maps/feature-map}/workspaces.md (77%) create mode 100644 scripts/check-maps.mjs create mode 100644 scripts/check-maps.test.mjs diff --git a/.agents/skills/create-verification-skill/NOTICE.md b/.agents/skills/create-verification-skill/NOTICE.md index d887f754207..68ab4a95a19 100644 --- a/.agents/skills/create-verification-skill/NOTICE.md +++ b/.agents/skills/create-verification-skill/NOTICE.md @@ -7,7 +7,8 @@ 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 existing repo-root `feature-map/`. +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. diff --git a/.agents/skills/create-verification-skill/SKILL.md b/.agents/skills/create-verification-skill/SKILL.md index de10a42c2d4..782513a549b 100644 --- a/.agents/skills/create-verification-skill/SKILL.md +++ b/.agents/skills/create-verification-skill/SKILL.md @@ -32,7 +32,19 @@ Write `.agents/skills/verify-<app>/SKILL.md` with YAML frontmatter (`name: verif ## 3. Seed the feature map -For Paperclip, use the existing repo-root `feature-map/` directory and its `README.md`; have the generated verification skill reference that map instead of creating a second map inside the skill directory. Reuse existing feature files and use Poteto's format below for new or updated entries. For other projects, create `.agents/skills/verify-<app>/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 <harness>`, 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. +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-<app>/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 <harness>`, 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 diff --git a/.agents/skills/maintain-verification-skill/NOTICE.md b/.agents/skills/maintain-verification-skill/NOTICE.md index 0c7b0648ebc..120888c9db5 100644 --- a/.agents/skills/maintain-verification-skill/NOTICE.md +++ b/.agents/skills/maintain-verification-skill/NOTICE.md @@ -7,7 +7,8 @@ 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 existing repo-root `feature-map/`. +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. diff --git a/.agents/skills/maintain-verification-skill/SKILL.md b/.agents/skills/maintain-verification-skill/SKILL.md index d95e6e8842e..8d261ef3fb4 100644 --- a/.agents/skills/maintain-verification-skill/SKILL.md +++ b/.agents/skills/maintain-verification-skill/SKILL.md @@ -1,40 +1,124 @@ --- name: maintain-verification-skill -description: "Periodic pass that keeps a project's verification skill and feature map honest: parallel source readers per feature, one live session driving every feature, at most one PR of proven corrections. Use for /maintain-verification-skill or \"audit the verify 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 +# Maintain a verification skill and its maps -A feature map rots the moment the app changes. This skill is the upkeep loop for a skill generated by `/create-verification-skill` (or any project-local verification skill with a feature map). The unit of rigor is the feature, not every sentence: cover every feature file from source and exercise every feature live, without terminalising every bullet. +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 say which: +Pick one and report source scope, live scope and remaining gaps separately: -- **clean** — every feature got source and live coverage; nothing worth shipping. No branch, no PR. -- **changed** — one PR ships proven doc, harness, or map corrections. -- **blocked** — coverage could not finish or a proven fix could not ship safely. Say exactly what blocked it. +- **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 -Only edit the verification skill's own directory (its SKILL.md and any harness scripts it owns) and its feature-map directory. For Paperclip, the map is the existing repo-root `feature-map/`; maintain it in place instead of creating or moving it into a skill directory. Use [Poteto's feature-map format](../create-verification-skill/references/feature-map-example/) for new or updated entries. Never edit product code during a run: a behavior the map describes that the app no longer does is either doc drift (fix the map) or a product regression (report it, don't paper over it in docs). +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.** Find the verification skill to maintain: the project-local skill whose body has launch/drive sections and a feature map (usually `.agents/skills/verify-*/`). Several candidates → ask which one; none → stop and point at `/create-verification-skill` instead of inventing a target. +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. **Index hygiene.** Read the feature map README and glob its sibling files. Fix missing, extra, duplicate, or dead entries. Lightweight; no generated inventory. +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.** One read-only subagent per feature file, launched concurrently. Each explains "how does this user-facing feature work?" from source, flags likely doc drift with citations, and returns one concise live-verification recipe. Children never drive the app and never edit files. Return shape: feature summary / source entry points / likely drift or none / one recipe. +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.** Every feature file has a returned summary. Merge overlapping recipes into as few app states as practical. Spot-check cited drift; don't re-prove clean claims. Sweep recent churn for user-facing surfaces missing from the map — require a concrete source path before calling one missing. +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 even when source looks clean. The coordinator owns all driving; follow the verification skill's own launch model — one long-lived instance driven serially for servers and UIs, or a fresh isolated session per drive for short-lived CLIs (the skill's Launch section decides, not this one). Exercise every feature at least once, and hold three invariants the whole pass, whatever the failure: (1) never drive an instance you haven't health-checked since it last did something surprising — doctor before first drive, doctor on each fresh session where sessions are the unit, doctor again after any failed drive, and where doctor can't see the failure (a wedged UI state on a healthy process), reset to a known state or relaunch rather than hoping; (2) evidence captured so far survives every cleanup, checked at its named location, not assumed; (3) nothing a drive started outlives that drive's usefulness — failed-iteration residue is cleaned whether the session is stuck, exited, or shared (for a shared instance, clean the residue, not the instance). A doctor failure caused by skill drift is drift: fix it under edit scope and retry once — restart whatever the fix invalidated, nothing more — before calling the pass `blocked`. A feature that can't be reached is `verified-unreachable` only with the concrete prerequisite (auth, entitlement, OS, external state) and the route attempted; if the map omits that prerequisite, that's drift. Any harness fix from triage gets re-driven live before it ships. Final teardown happens after the last drive of the run — including those re-proofs — so nothing outlives the run (evidence stays, per the skill). +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. -5. **Triage.** Wrong or missing user-POV description → doc drift, fix it. Working behavior the harness can't drive → harness gap, fix it; a harness fix follows the same helpers rule as generation (scripts executable, invocation documented in the skill body). App behavior that's actually broken → product gap; record it for the user, keep it out of this PR. + 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. -6. **Ship or stop.** For changed: one PR of proven corrections, re-read every changed file first. For clean or blocked: no PR, report the outcome and the coverage honestly. +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. -Keep concise run notes (features covered, unreachable prerequisites, confirmed drift, outcome) in a scratch location; don't commit them. +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/verify-paperclip/SKILL.md b/.agents/skills/verify-paperclip/SKILL.md index 2728797cff8..b16467140a0 100644 --- a/.agents/skills/verify-paperclip/SKILL.md +++ b/.agents/skills/verify-paperclip/SKILL.md @@ -5,9 +5,13 @@ description: Launch an isolated Paperclip instance, drive its real web UI or CLI # Verify Paperclip -Use the repo-root [feature map](../../../feature-map/README.md) to select -verification recipes. Read the relevant feature file before driving it. Report -any recipe drift with evidence; map updates belong to +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. 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 feef1880754..233c2e5cf66 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/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 87% rename from feature-map/README.md rename to maps/feature-map/README.md index 623b4617465..0ac2ab9a5b9 100644 --- a/feature-map/README.md +++ b/maps/feature-map/README.md @@ -3,7 +3,11 @@ 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 @@ -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, @@ -114,11 +118,11 @@ fixture integration. Record executed results separately from the map. evidence; creating state through the API does not prove the creation UI. For agent-driven acceptance work, start with the -[Paperclip verification skill](../.agents/skills/verify-paperclip/SKILL.md) for an +[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 +[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. @@ -134,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 @@ -148,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, @@ -159,13 +163,17 @@ 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: @@ -176,7 +184,7 @@ Each recipe starts with an H1 and a user-visible description, then exactly: 4. **Gotchas** — misleading look-alikes, feature gates, and invalid evidence. New or updated entries follow -[Poteto's feature-map examples](../.agents/skills/create-verification-skill/references/feature-map-example/). +[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). diff --git a/feature-map/access.md b/maps/feature-map/access.md similarity index 78% rename from feature-map/access.md rename to maps/feature-map/access.md index 5d61c99ad2e..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 @@ -35,19 +37,19 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). For ### `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. diff --git a/feature-map/activity.md b/maps/feature-map/activity.md similarity index 75% rename from feature-map/activity.md rename to maps/feature-map/activity.md index fea542d988a..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 @@ -28,13 +30,13 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Prep ### `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. diff --git a/feature-map/agent-chat.md b/maps/feature-map/agent-chat.md similarity index 77% rename from feature-map/agent-chat.md rename to maps/feature-map/agent-chat.md index e53725def70..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 @@ -31,7 +33,7 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Enab ### `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. Use `/new` for a fresh session within @@ -40,13 +42,13 @@ inaccessible saved conversation; verify no cross-user or cross-company history a ### `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 diff --git a/feature-map/agents.md b/maps/feature-map/agents.md similarity index 76% rename from feature-map/agents.md rename to maps/feature-map/agents.md index 021505b41c0..673d7c45982 100644 --- a/feature-map/agents.md +++ b/maps/feature-map/agents.md @@ -2,7 +2,9 @@ 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 @@ -33,7 +35,7 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use ### `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. @@ -44,13 +46,13 @@ 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 79% rename from feature-map/budgets-costs.md rename to maps/feature-map/budgets-costs.md index cd0bb3b2b99..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 @@ -27,7 +29,7 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use ### `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 recorded spend with the chosen period and resource. Switch the report period/company and inspect the **By @@ -36,7 +38,7 @@ 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 78% rename from feature-map/cases.md rename to maps/feature-map/cases.md index f1c12ccb7a0..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 @@ -27,13 +29,13 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Enab ### `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: Change status or labels, reload, inspect activity, and follow child/task links. Edit a case document and inspect its document revisions separately. diff --git a/feature-map/chat-channels.md b/maps/feature-map/chat-channels.md similarity index 79% rename from feature-map/chat-channels.md rename to maps/feature-map/chat-channels.md index 071e53c1867..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 @@ -32,13 +34,13 @@ 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. diff --git a/feature-map/cli-operations.md b/maps/feature-map/cli-operations.md similarity index 77% rename from feature-map/cli-operations.md rename to maps/feature-map/cli-operations.md index b9d77696af9..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 @@ -37,19 +39,19 @@ even when a profile stores a company. A persona label is not authentication proo ### `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 76% rename from feature-map/companies.md rename to maps/feature-map/companies.md index 585a0588a1a..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 @@ -27,13 +29,13 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use ### `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. diff --git a/feature-map/connection-setup.md b/maps/feature-map/connection-setup.md similarity index 88% rename from feature-map/connection-setup.md rename to maps/feature-map/connection-setup.md index de51d3c6b7d..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; @@ -63,16 +65,16 @@ 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 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 76% rename from feature-map/developer-labs.md rename to maps/feature-map/developer-labs.md index 41c24ed1dbb..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 @@ -27,13 +29,13 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use ### `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 rendering in the long-thread fixture, and retain timing or trace evidence. Its composer is diff --git a/feature-map/documents-artifacts.md b/maps/feature-map/documents-artifacts.md similarity index 71% rename from feature-map/documents-artifacts.md rename to maps/feature-map/documents-artifacts.md index e445c9cddb1..c3ae6f1c3cd 100644 --- a/feature-map/documents-artifacts.md +++ b/maps/feature-map/documents-artifacts.md @@ -2,7 +2,9 @@ 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). +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 @@ -35,13 +37,13 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use ### `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. +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. +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, @@ -53,7 +55,7 @@ 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. +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. diff --git a/feature-map/execution-environments.md b/maps/feature-map/execution-environments.md similarity index 76% rename from feature-map/execution-environments.md rename to maps/feature-map/execution-environments.md index df145e17d3e..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 @@ -32,7 +34,7 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use ### `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. @@ -42,7 +44,7 @@ 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 74% rename from feature-map/gateways-profiles.md rename to maps/feature-map/gateways-profiles.md index e00b5a03ac3..de5f4b58e9d 100644 --- a/feature-map/gateways-profiles.md +++ b/maps/feature-map/gateways-profiles.md @@ -2,7 +2,9 @@ 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 @@ -33,13 +35,13 @@ 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 73% rename from feature-map/goals.md rename to maps/feature-map/goals.md index 1e83cf608e3..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 @@ -26,7 +28,7 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Prep ### `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: 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 @@ -37,7 +39,7 @@ 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 68% rename from feature-map/inbox-search.md rename to maps/feature-map/inbox-search.md index cdcaab7f44c..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 @@ -31,26 +33,26 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Seed ### `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) + `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. diff --git a/feature-map/instance-operations.md b/maps/feature-map/instance-operations.md similarity index 78% rename from feature-map/instance-operations.md rename to maps/feature-map/instance-operations.md index bb1c572621b..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 @@ -28,13 +30,13 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use ### `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. diff --git a/feature-map/navigation-preferences.md b/maps/feature-map/navigation-preferences.md similarity index 77% rename from feature-map/navigation-preferences.md rename to maps/feature-map/navigation-preferences.md index 7d957567dbc..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 @@ -28,7 +30,7 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use ### `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. @@ -39,7 +41,7 @@ ordering are saved server-side. Local-trusted mode does not prove two-user isola ### `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/feature-map/onboarding.md b/maps/feature-map/onboarding.md similarity index 79% rename from feature-map/onboarding.md rename to maps/feature-map/onboarding.md index a49cbb0f01b..ecfaeefaaef 100644 --- a/feature-map/onboarding.md +++ b/maps/feature-map/onboarding.md @@ -2,7 +2,9 @@ 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). +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 @@ -31,10 +33,10 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use ### `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. +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 + 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 @@ -50,7 +52,7 @@ 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. +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. diff --git a/feature-map/pipelines.md b/maps/feature-map/pipelines.md similarity index 77% rename from feature-map/pipelines.md rename to maps/feature-map/pipelines.md index efd59e34048..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 @@ -27,13 +29,13 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Enab ### `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. diff --git a/feature-map/plugins.md b/maps/feature-map/plugins.md similarity index 78% rename from feature-map/plugins.md rename to maps/feature-map/plugins.md index 4c6788cdd7b..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 @@ -27,13 +29,13 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use ### `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. diff --git a/feature-map/projects.md b/maps/feature-map/projects.md similarity index 80% rename from feature-map/projects.md rename to maps/feature-map/projects.md index ff2cb3c2539..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 @@ -27,13 +29,13 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use ### `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. 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 73% rename from feature-map/routines.md rename to maps/feature-map/routines.md index 99e437da265..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 @@ -29,7 +31,7 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use ### `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. @@ -40,7 +42,7 @@ 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 76% rename from feature-map/runs-adapters.md rename to maps/feature-map/runs-adapters.md index 93651b8fd89..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 @@ -27,13 +29,13 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Reco ### `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. diff --git a/feature-map/secrets.md b/maps/feature-map/secrets.md similarity index 75% rename from feature-map/secrets.md rename to maps/feature-map/secrets.md index d9d6d42ff26..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 @@ -28,13 +30,13 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use ### `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. diff --git a/feature-map/skills.md b/maps/feature-map/skills.md similarity index 76% rename from feature-map/skills.md rename to maps/feature-map/skills.md index ed289935fef..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 @@ -35,19 +37,19 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use ### `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. 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 75% rename from feature-map/task-coordination.md rename to maps/feature-map/task-coordination.md index 609c86dc9f8..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 @@ -31,13 +33,13 @@ 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 76% rename from feature-map/tasks.md rename to maps/feature-map/tasks.md index b32039dbdd6..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 @@ -35,7 +37,7 @@ 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. @@ -45,13 +47,13 @@ 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. diff --git a/feature-map/teams.md b/maps/feature-map/teams.md similarity index 80% rename from feature-map/teams.md rename to maps/feature-map/teams.md index f5822ab4645..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 @@ -27,13 +29,13 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use ### `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. diff --git a/feature-map/workspaces.md b/maps/feature-map/workspaces.md similarity index 77% rename from feature-map/workspaces.md rename to maps/feature-map/workspaces.md index acf92476222..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 @@ -28,13 +30,13 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Enab ### `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. 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"))); +}); From ddcd746eb6a01f2bbe9b4adeec238ba35bde5adb Mon Sep 17 00:00:00 2001 From: Dotta <bippadotta@protonmail.com> Date: Mon, 12 Oct 2026 15:15:01 -0500 Subject: [PATCH 7/8] feat(runner): qualify native Codex platforms and cache hosted builds Extract the tested runtime and installed-package prerequisite without changing agent creation defaults or experimental gates. Co-Authored-By: Paperclip <noreply@paperclip.ing> --- .github/workflows/release-smoke.yml | 174 +++++ .github/workflows/release-verify.yml | 175 +++++ .github/workflows/release.yml | 120 ++- doc/plans/2026-10-06-default-agent-runner.md | 51 +- packages/paperclip-runner/README.md | 55 +- .../src/bin/fake-codex-app-server.rs | 6 +- .../crates/runner-core/src/codex_provider.rs | 59 +- .../runner-core/src/provider_backend.rs | 29 +- .../runner-core/tests/codex_provider.rs | 1 + .../scripts/release-provider-pack.test.mjs | 27 +- .../scripts/stage-release-runner-binaries.mjs | 168 ++-- .../scripts/stage-runner-binary.mjs | 15 +- .../scripts/stage-runner-binary.test.mjs | 146 +++- .../drivers/codex/codex-startup-trust.test.ts | 62 +- .../src/drivers/codex/codex-startup-trust.ts | 13 +- packages/paperclip-runner/src/index.ts | 2 + .../src/live/runner-binary.test.ts | 14 +- .../src/live/runnerd-codex-transport.test.ts | 100 +++ .../src/live/runnerd-codex-transport.ts | 186 ++++- .../grok-public-install-sandbox.test.mjs | 229 +++++- .../release-verify-workflow.test.mjs | 403 +++++++++- scripts/dev-runner-native-binary.mjs | 32 +- scripts/dev-runner.ts | 42 +- scripts/grok-public-install-sandbox.mjs | 140 +++- scripts/pi-public-install-probe.mjs | 43 +- scripts/prepare-linux-arm64-qualification.sh | 142 ++++ scripts/release.sh | 10 + scripts/verify-grok-npm-install.mjs | 206 ++++- .../dev-runner-native-binary.test.ts | 68 +- server/src/__tests__/remove-test-home.test.ts | 18 +- server/src/__tests__/remove-test-home.ts | 34 +- .../services/local-ai-browser-login.test.ts | 88 ++- server/src/services/local-ai-browser-login.ts | 8 +- .../native-codex-runner.test.ts | 118 ++- .../native-runtime/native-codex-runner.ts | 34 + .../native-session-executor.test.ts | 77 +- .../native-runtime/native-session-executor.ts | 715 +++++++++--------- server/src/vendor/paperclip-runner/index.ts | 3 + tests/runner-e2e/installed-cli.test.ts | 99 ++- tests/runner-e2e/installed-cli.ts | 127 +++- 40 files changed, 3474 insertions(+), 565 deletions(-) create mode 100755 scripts/prepare-linux-arm64-qualification.sh diff --git a/.github/workflows/release-smoke.yml b/.github/workflows/release-smoke.yml index e0aae6d1a74..6301a4d1c1a 100644 --- a/.github/workflows/release-smoke.yml +++ b/.github/workflows/release-smoke.yml @@ -23,6 +23,11 @@ on: required: false default: release-smoke type: string + qualification_source_sha: + description: Optional immutable source SHA for provider-free native package qualification + required: false + default: "" + type: string workflow_call: inputs: paperclip_version: @@ -36,6 +41,13 @@ on: required: false default: release-smoke type: string + qualification_source_sha: + required: false + default: "" + type: string + +permissions: + contents: read jobs: # The Docker smoke below can never exercise the background-service leg of @@ -45,6 +57,7 @@ jobs: # on the runner VM's systemd and require the installed service to end up # serving. smoke_service: + if: inputs.qualification_source_sha == '' runs-on: ubuntu-latest timeout-minutes: 30 @@ -98,6 +111,7 @@ jobs: retention-days: 14 smoke: + if: inputs.qualification_source_sha == '' runs-on: ubuntu-latest timeout-minutes: 45 @@ -198,3 +212,163 @@ jobs: - name: Stop Docker smoke container if: always() run: docker rm -f "$SMOKE_CONTAINER_NAME" >/dev/null 2>&1 || true + + qualification_daemons: + if: inputs.qualification_source_sha != '' + uses: ./.github/workflows/release-verify.yml + with: + ref: ${{ inputs.qualification_source_sha }} + runner_binaries_only: true + + qualification_pack: + if: inputs.qualification_source_sha != '' + needs: qualification_daemons + name: Assemble and verify public Linux packages + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ inputs.qualification_source_sha }} + persist-credentials: false + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + with: + version: 9.15.4 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 24 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: ${{ needs.qualification_daemons.outputs.runner_artifact_prefix }}-* + path: ${{ runner.temp }}/release-runner-inputs + merge-multiple: true + - name: Bind the immutable Linux producer source + env: + SOURCE_SHA: ${{ inputs.qualification_source_sha }} + run: | + set -euo pipefail + [[ "$SOURCE_SHA" =~ ^[a-f0-9]{40}$ ]] || exit 1 + [[ "$(git rev-parse HEAD)" == "$SOURCE_SHA" ]] || exit 1 + - name: Build normal workspace outputs and public CLI + run: | + pnpm install --no-frozen-lockfile + pnpm build + bash scripts/build-npm.sh --skip-checks --skip-typecheck + mv cli/package.dev.json cli/package.json + - name: Assemble exact-source daemons before server packing + env: + SOURCE_SHA: ${{ inputs.qualification_source_sha }} + run: | + node packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs \ + assemble-daemons "$RUNNER_TEMP/release-runner-inputs" "$SOURCE_SHA" + cp -R packages/paperclip-runner/dist/bin/. server/dist/vendor/paperclip-runner/bin/ + - name: Verify clean public installation and retain the same package bytes + env: + PAPERCLIP_RUNNER_PUBLIC_PACKS_DIR: ${{ runner.temp }}/public-runner-packs + run: node scripts/verify-grok-npm-install.mjs + - name: Locate completed package assembly + id: pack_identity + if: ${{ !cancelled() }} + run: | + if [[ -s "$RUNNER_TEMP/public-runner-packs/manifest.json" ]]; then + echo "available=true" >> "$GITHUB_OUTPUT" + fi + - name: Retain completed package assembly even after a later Linux failure + if: ${{ !cancelled() && steps.pack_identity.outputs.available == 'true' }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: ${{ inputs.artifact_name }}-public-packages + path: ${{ runner.temp }}/public-runner-packs/ + if-no-files-found: error + retention-days: 14 + + qualification_native: + if: ${{ !cancelled() && inputs.qualification_source_sha != '' && needs.qualification_daemons.result == 'success' }} + needs: [qualification_daemons, qualification_pack] + name: Clean installed package (${{ matrix.target }}) + runs-on: ${{ matrix.host }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.qualification_daemons.outputs.native_consumer_matrix) }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ inputs.qualification_source_sha }} + persist-credentials: false + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + with: + version: 9.15.4 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 24 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: ${{ inputs.artifact_name }}-public-packages + path: ${{ runner.temp }}/public-runner-packs + - name: Install only the existing verifier tooling + run: pnpm install --ignore-scripts --no-frozen-lockfile + - name: Checkout immutable ARM qualification tooling + if: ${{ matrix.target == 'linux-arm64' }} + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + # job.workflow_* identifies this reusable workflow, not its caller. + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: .qualification-tooling + sparse-checkout: scripts/prepare-linux-arm64-qualification.sh + sparse-checkout-cone-mode: false + persist-credentials: false + - name: Prepare Linux ARM sandbox prerequisites + if: ${{ matrix.target == 'linux-arm64' }} + timeout-minutes: 5 + env: + SOURCE_SHA: ${{ inputs.qualification_source_sha }} + TOOLING_SHA: ${{ job.workflow_sha }} + TOOLING_REPOSITORY: ${{ job.workflow_repository }} + TOOLING_WORKFLOW_REF: ${{ job.workflow_ref }} + TOOLING_WORKFLOW_PATH: ${{ job.workflow_file_path }} + run: | + set -euo pipefail + [[ "$SOURCE_SHA" =~ ^[a-f0-9]{40}$ ]] || exit 1 + [[ "$TOOLING_SHA" =~ ^[a-f0-9]{40}$ ]] || exit 1 + [[ "$(git rev-parse HEAD)" == "$SOURCE_SHA" ]] || exit 1 + [[ "$(git -C .qualification-tooling rev-parse HEAD)" == "$TOOLING_SHA" ]] || exit 1 + node --input-type=module <<'NODE' + import { createHash } from 'node:crypto'; + import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; + import { join } from 'node:path'; + const directory = join(process.env.RUNNER_TEMP, 'paperclip-arm-bwrap-prerequisite'); + mkdirSync(directory, { recursive: true }); + const helper = '.qualification-tooling/scripts/prepare-linux-arm64-qualification.sh'; + writeFileSync(join(directory, 'qualification-identity.json'), JSON.stringify({ + productSourceSha: process.env.SOURCE_SHA, + toolingSourceSha: process.env.TOOLING_SHA, + toolingRepository: process.env.TOOLING_REPOSITORY, + toolingWorkflowRef: process.env.TOOLING_WORKFLOW_REF, + toolingWorkflowPath: process.env.TOOLING_WORKFLOW_PATH, + helperSha256: createHash('sha256').update(readFileSync(helper)).digest('hex'), + }, null, 2) + '\n'); + NODE + bash .qualification-tooling/scripts/prepare-linux-arm64-qualification.sh + - name: Verify host identity and actual clean npm consumer + env: + SOURCE_SHA: ${{ inputs.qualification_source_sha }} + TARGET: ${{ matrix.target }} + run: | + set -euo pipefail + [[ "$(git rev-parse HEAD)" == "$SOURCE_SHA" ]] || exit 1 + [[ "$(node -p 'process.platform + "-" + process.arch')" == "$TARGET" ]] || exit 1 + mode=--mac-consumer + if [[ "$TARGET" == linux-arm64 ]]; then mode=--linux-arm64-consumer; fi + node scripts/verify-grok-npm-install.mjs "$mode" \ + "$RUNNER_TEMP/public-runner-packs" "$SOURCE_SHA" "$RUNNER_TEMP/native-public-install.json" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + if: always() + with: + name: ${{ inputs.artifact_name }}-${{ matrix.target }} + path: | + ${{ runner.temp }}/native-public-install.json + ${{ runner.temp }}/paperclip-arm-bwrap-prerequisite/ + if-no-files-found: warn + retention-days: 14 diff --git a/.github/workflows/release-verify.yml b/.github/workflows/release-verify.yml index 986968201d8..96402b6abec 100644 --- a/.github/workflows/release-verify.yml +++ b/.github/workflows/release-verify.yml @@ -7,18 +7,30 @@ on: description: Commit SHA, branch, or tag to verify required: true type: string + runner_binaries_only: + description: Build exact-source release daemons without publishing or provider calls + required: false + default: false + type: boolean + outputs: + runner_artifact_prefix: + value: ${{ jobs.runner_binaries.outputs.artifact_prefix }} + native_consumer_matrix: + value: ${{ jobs.runner_binary_targets.outputs.native_consumers }} # Caller-provided refs may name unmerged PR code. AWS is eligible only when # the caller runs on canonical master and verifies that event's exact SHA. # The organization group also restricts these workflow files to master. jobs: runner_chaos_evals: + if: ${{ !inputs.runner_binaries_only }} name: Pre-release Runner chaos evals uses: ./.github/workflows/runner-chaos-evals.yml with: ref: ${{ inputs.ref }} typecheck: + if: ${{ !inputs.runner_binaries_only }} name: Typecheck runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }} timeout-minutes: 20 @@ -97,6 +109,7 @@ jobs: run: pnpm -r typecheck general_tests: + if: ${{ !inputs.runner_binaries_only }} name: General tests (${{ matrix.group_label }}) runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }} timeout-minutes: 20 @@ -208,6 +221,7 @@ jobs: fi serialized_tests: + if: ${{ !inputs.runner_binaries_only }} name: Serialized tests (${{ matrix.shard_label }}) runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }} timeout-minutes: 20 @@ -257,6 +271,7 @@ jobs: run: pnpm test:run:serialized -- --shard-index ${{ matrix.shard_index }} --shard-count ${{ matrix.shard_count }} runner_workflow_evals: + if: ${{ !inputs.runner_binaries_only }} name: Runner workflow eval scorer contract runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }} timeout-minutes: 10 @@ -287,6 +302,7 @@ jobs: run: pnpm test:runner-workflow-evals verify_paperclip_runner: + if: ${{ !inputs.runner_binaries_only }} name: Verify Paperclip Runner (${{ matrix.lane }}) runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }} timeout-minutes: 20 @@ -470,6 +486,7 @@ jobs: key: runner-release-crate-v1-${{ steps.runner_release_crate_key.outputs.scope }}-${{ hashFiles('packages/paperclip-runner/rust-toolchain.toml', 'packages/paperclip-runner/runner/Cargo.toml', 'packages/paperclip-runner/runner/Cargo.lock', 'packages/paperclip-runner/runner/crates/**', 'packages/paperclip-runner/protocol/**', 'packages/paperclip-runner/test-fixtures/**', 'packages/paperclip-runner/test/fixtures/**', 'tests/runner-e2e/fixtures/**') }} build: + if: ${{ !inputs.runner_binaries_only }} name: Build runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }} timeout-minutes: 20 @@ -498,3 +515,161 @@ jobs: - name: Build run: pnpm build + + runner_binary_targets: + if: inputs.runner_binaries_only + name: Read exact-source daemon targets + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + outputs: + matrix: ${{ steps.targets.outputs.matrix }} + native_consumers: ${{ steps.targets.outputs.native_consumers }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ inputs.ref }} + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 24 + - name: Preserve the immutable source's platform contract + id: targets + env: + SOURCE_SHA: ${{ inputs.ref }} + run: | + set -euo pipefail + [[ "$SOURCE_SHA" =~ ^[a-f0-9]{40}$ ]] || exit 1 + [[ "$(git rev-parse HEAD)" == "$SOURCE_SHA" ]] || exit 1 + node --input-type=module <<'NODE' + import assert from 'node:assert/strict'; + import { appendFileSync } from 'node:fs'; + import { RELEASE_RUNNER_TARGETS } from './packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs'; + const historical = ['darwin-arm64', 'darwin-x64', 'linux-x64']; + const current = [...historical, 'linux-arm64']; + const targets = [...RELEASE_RUNNER_TARGETS].sort(); + assert.ok(JSON.stringify(targets) === JSON.stringify(historical.sort()) || JSON.stringify(targets) === JSON.stringify(current.sort()), 'Unknown exact-source daemon platform contract'); + const hosts = { 'linux-x64': targets.length === current.length ? 'ubuntu-24.04' : 'ubuntu-latest', 'linux-arm64': 'ubuntu-24.04-arm', 'darwin-arm64': 'macos-15', 'darwin-x64': 'macos-15-intel' }; + const include = RELEASE_RUNNER_TARGETS.map(target => ({ target, host: hosts[target] })); + appendFileSync(process.env.GITHUB_OUTPUT, `matrix=${JSON.stringify({ include })}\nnative_consumers=${JSON.stringify({ include: include.filter(row => row.target !== 'linux-x64') })}\n`); + NODE + + runner_binaries: + if: inputs.runner_binaries_only + needs: runner_binary_targets + name: Release daemon (${{ matrix.target }}) + runs-on: ${{ matrix.host }} + timeout-minutes: 30 + permissions: + contents: read + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.runner_binary_targets.outputs.matrix) }} + outputs: + artifact_prefix: ${{ steps.identity.outputs.artifact_prefix }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ inputs.ref }} + persist-credentials: false + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + with: + version: 9.15.4 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 24 + - name: Bind immutable producer source and target + id: identity + env: + SOURCE_SHA: ${{ inputs.ref }} + TARGET: ${{ matrix.target }} + run: | + set -euo pipefail + [[ "$SOURCE_SHA" =~ ^[a-f0-9]{40}$ ]] || exit 1 + [[ "$(git rev-parse HEAD)" == "$SOURCE_SHA" ]] || exit 1 + [[ "$(node -p 'process.platform + "-" + process.arch')" == "$TARGET" ]] || exit 1 + echo "artifact_prefix=runner-release-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT" + - name: Pin native Cargo workspace and toolchain + id: native_rust_workspace + working-directory: packages/paperclip-runner + run: | + set -euo pipefail + pinned="$HOME/paperclip-runner-rust" + if [[ -e "$pinned" && ! -L "$pinned" ]]; then + echo '::error::Native Cargo cache path must not replace a real directory' + exit 1 + fi + rm -f "$pinned" + ln -s "$GITHUB_WORKSPACE/packages/paperclip-runner/runner" "$pinned" + echo "path=$pinned" >> "$GITHUB_OUTPUT" + rustup show + toolchain="$(rustup show active-toolchain | awk '{print $1}')" + echo "RUSTUP_TOOLCHAIN=$toolchain" >> "$GITHUB_ENV" + extra_toolchains="$(rustup toolchain list | awk '{print $1}' | grep -vx "$toolchain" || true)" + if [[ -n "$extra_toolchains" ]]; then + echo "$extra_toolchains" | xargs -n1 rustup toolchain uninstall \ + || echo '::notice title=Native Rust cache::could not remove an extra toolchain; the cache key may not match' + fi + rustup toolchain list + - name: Cache native Rust dependencies + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + workspaces: ${{ steps.native_rust_workspace.outputs.path }} -> target + # Compiler/environment and Cargo lock hashes are added by the action. + # Also bind generated contracts outside the Rust workspace, while + # always recompiling workspace code and recording actual new bytes. + shared-key: release-native-v2-${{ matrix.host }}-${{ matrix.target }}-${{ hashFiles('packages/paperclip-runner/protocol/**', 'packages/paperclip-runner/src/**', 'packages/paperclip-runner/*.json') }} + cache-workspace-crates: false + cache-bin: false + cache-on-failure: false + # A master workflow can verify an unmerged preview ref. Those jobs + # restore only; they never write master's trusted dependency cache. + save-if: ${{ github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.event_name == 'push' && github.ref == 'refs/heads/master' && inputs.ref == github.sha }} + - name: Install dependencies + run: pnpm install --no-frozen-lockfile + - name: Build TypeScript contract and host daemon concurrently + run: | + node --input-type=module <<'NODE' + import { mkdirSync, readFileSync } from 'node:fs'; + import { join } from 'node:path'; + import { startPrefetch, stopPrefetches } from './scripts/prefetch-child.mjs'; + const root = process.cwd(), logs = join(process.env.RUNNER_TEMP, 'native-build-preparation'); + mkdirSync(logs, { recursive: true }); + const builds = []; + let failure; + try { + for (const [name, args, cwd] of [ + ['typescript', ['--filter', '@paperclipai/paperclip-runner', 'build:typescript'], root], + ['native', ['build:binary'], join(root, 'packages/paperclip-runner')], + ]) { + builds.push(startPrefetch({ name, cmd: 'pnpm', args, cwd, env: process.env, logPath: join(logs, name + '.log') })); + } + await Promise.all(builds.map(build => build.done)); + } catch (error) { failure = error; } + finally { + await stopPrefetches(builds); + const results = await Promise.allSettled(builds.map(build => build.done)); + builds.forEach((build, index) => { + console.log('::group::' + build.name + ' build'); + process.stdout.write(readFileSync(join(logs, build.name + '.log'), 'utf8')); + console.log('::endgroup::'); + console.log(JSON.stringify({ build: build.name, status: results[index].status, + exitCode: build.child.exitCode, signal: build.child.signalCode, durationMs: build.durationMs })); + }); + } + if (failure) throw failure; + NODE + - name: Record actual daemon metadata and immutable bytes + env: + SOURCE_SHA: ${{ inputs.ref }} + TARGET: ${{ matrix.target }} + run: | + node packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs record "$SOURCE_SHA" "$TARGET" \ + packages/paperclip-runner/dist/bin/paperclip-runnerd "$RUNNER_TEMP/runner-release/$TARGET" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: ${{ steps.identity.outputs.artifact_prefix }}-${{ matrix.target }} + path: ${{ runner.temp }}/runner-release/ + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a2c51dbc747..a676cf55a28 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -340,9 +340,54 @@ jobs: SOURCE_SHA: ${{ github.sha }} run: node scripts/cloud-source-verification.mjs "$SOURCE_SHA" + release_runner_source: + # Old promotions retain their already-shipped packaging contract. New + # sources opt into source-declared native target assembly in their release.sh. + needs: [verify_canary, select_nightly, select_beta, preflight_stable] + if: >- + !cancelled() && ( + (github.event_name == 'push' && needs.verify_canary.result == 'success') || + (needs.select_nightly.result == 'success' && needs.select_nightly.outputs.proceed == 'true') || + needs.select_beta.result == 'success' || needs.preflight_stable.result == 'success') + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + outputs: + sha: ${{ steps.source.outputs.sha }} + required: ${{ steps.source.outputs.required }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ needs.preflight_stable.outputs.sha || needs.select_beta.outputs.sha || needs.select_nightly.outputs.sha || github.sha }} + persist-credentials: false + - name: Read the exact source packaging contract + id: source + env: + SOURCE_SHA: ${{ needs.preflight_stable.outputs.sha || needs.select_beta.outputs.sha || needs.select_nightly.outputs.sha || github.sha }} + run: | + set -euo pipefail + [[ "$SOURCE_SHA" =~ ^[a-f0-9]{40}$ ]] || exit 1 + [[ "$(git rev-parse HEAD)" == "$SOURCE_SHA" ]] || exit 1 + echo "sha=$SOURCE_SHA" >> "$GITHUB_OUTPUT" + source_script="$(git show "$SOURCE_SHA:scripts/release.sh")" || exit 1 + if printf '%s\n' "$source_script" | grep -F 'assemble-daemons "$PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR"' > /dev/null; then + echo "required=true" >> "$GITHUB_OUTPUT" + else + echo "required=false" >> "$GITHUB_OUTPUT" + fi + + release_runner_binaries: + needs: release_runner_source + if: needs.release_runner_source.outputs.required == 'true' + uses: ./.github/workflows/release-verify.yml + with: + ref: ${{ needs.release_runner_source.outputs.sha }} + runner_binaries_only: true + publish_canary: - if: github.event_name == 'push' - needs: verify_canary + if: ${{ !cancelled() && github.event_name == 'push' && needs.verify_canary.result == 'success' && needs.release_runner_source.result == 'success' && (needs.release_runner_source.outputs.required == 'false' || needs.release_runner_binaries.result == 'success') }} + needs: [verify_canary, release_runner_source, release_runner_binaries] runs-on: ubuntu-latest timeout-minutes: 150 environment: npm-canary @@ -380,6 +425,17 @@ jobs: git checkout -B master "$SOURCE_SHA" test "$(git rev-parse HEAD)" = "$SOURCE_SHA" + - name: Download exact-source release daemon receipts + if: needs.release_runner_source.outputs.required == 'true' + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: ${{ needs.release_runner_binaries.outputs.runner_artifact_prefix }}-* + path: ${{ runner.temp }}/release-runner-inputs + merge-multiple: true + - name: Bind daemon inputs for release assembly + if: needs.release_runner_source.outputs.required == 'true' + run: echo "PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR=$RUNNER_TEMP/release-runner-inputs" >> "$GITHUB_ENV" + - name: Setup pnpm uses: pnpm/action-setup@v6 with: @@ -633,12 +689,14 @@ jobs: artifact_name: nightly-release-smoke publish_nightly: - needs: [select_nightly, smoke_nightly] + needs: [select_nightly, smoke_nightly, release_runner_source, release_runner_binaries] # Publish when smoke passed, or when smoke was deliberately skipped by a # dry-run dispatch (the publish itself is a dry-run in that case). if: >- !cancelled() && needs.select_nightly.outputs.proceed == 'true' && + needs.release_runner_source.result == 'success' && + (needs.release_runner_source.outputs.required == 'false' || needs.release_runner_binaries.result == 'success') && (needs.smoke_nightly.result == 'success' || (needs.smoke_nightly.result == 'skipped' && github.event_name == 'workflow_dispatch' && inputs.dry_run)) runs-on: ubuntu-latest @@ -664,6 +722,17 @@ jobs: ref: ${{ needs.select_nightly.outputs.sha }} fetch-depth: 0 + - name: Download exact-source release daemon receipts + if: needs.release_runner_source.outputs.required == 'true' + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: ${{ needs.release_runner_binaries.outputs.runner_artifact_prefix }}-* + path: ${{ runner.temp }}/release-runner-inputs + merge-multiple: true + - name: Bind daemon inputs for release assembly + if: needs.release_runner_source.outputs.required == 'true' + run: echo "PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR=$RUNNER_TEMP/release-runner-inputs" >> "$GITHUB_ENV" + - name: Setup pnpm uses: pnpm/action-setup@v6 with: @@ -901,10 +970,12 @@ jobs: ref: ${{ needs.select_beta.outputs.sha }} publish_beta: - needs: [select_beta, verify_beta_candidate] + needs: [select_beta, verify_beta_candidate, release_runner_source, release_runner_binaries] if: >- !cancelled() && needs.select_beta.result == 'success' && + needs.release_runner_source.result == 'success' && + (needs.release_runner_source.outputs.required == 'false' || needs.release_runner_binaries.result == 'success') && (needs.verify_beta_candidate.result == 'success' || (needs.verify_beta_candidate.result == 'skipped' && needs.select_beta.outputs.mode == 'promote')) runs-on: ubuntu-latest @@ -930,6 +1001,17 @@ jobs: ref: ${{ needs.select_beta.outputs.sha }} fetch-depth: 0 + - name: Download exact-source release daemon receipts + if: needs.release_runner_source.outputs.required == 'true' + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: ${{ needs.release_runner_binaries.outputs.runner_artifact_prefix }}-* + path: ${{ runner.temp }}/release-runner-inputs + merge-multiple: true + - name: Bind daemon inputs for release assembly + if: needs.release_runner_source.outputs.required == 'true' + run: echo "PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR=$RUNNER_TEMP/release-runner-inputs" >> "$GITHUB_ENV" + - name: Setup pnpm uses: pnpm/action-setup@v6 with: @@ -1286,8 +1368,8 @@ jobs: ref: ${{ needs.preflight_stable.outputs.sha }} preview_stable: - if: github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && inputs.dry_run - needs: [preflight_stable, verify_stable] + if: ${{ !cancelled() && github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && inputs.dry_run && needs.preflight_stable.result == 'success' && needs.verify_stable.result == 'success' && needs.release_runner_source.result == 'success' && (needs.release_runner_source.outputs.required == 'false' || needs.release_runner_binaries.result == 'success') }} + needs: [preflight_stable, verify_stable, release_runner_source, release_runner_binaries] runs-on: ubuntu-latest timeout-minutes: 45 permissions: @@ -1300,6 +1382,17 @@ jobs: fetch-depth: 0 ref: ${{ needs.preflight_stable.outputs.sha }} + - name: Download exact-source release daemon receipts + if: needs.release_runner_source.outputs.required == 'true' + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: ${{ needs.release_runner_binaries.outputs.runner_artifact_prefix }}-* + path: ${{ runner.temp }}/release-runner-inputs + merge-multiple: true + - name: Bind daemon inputs for release assembly + if: needs.release_runner_source.outputs.required == 'true' + run: echo "PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR=$RUNNER_TEMP/release-runner-inputs" >> "$GITHUB_ENV" + - name: Setup pnpm uses: pnpm/action-setup@v6 with: @@ -1328,8 +1421,8 @@ jobs: ./scripts/release.sh "${args[@]}" publish_stable: - if: github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && !inputs.dry_run - needs: [preflight_stable, verify_stable] + if: ${{ !cancelled() && github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && !inputs.dry_run && needs.preflight_stable.result == 'success' && needs.verify_stable.result == 'success' && needs.release_runner_source.result == 'success' && (needs.release_runner_source.outputs.required == 'false' || needs.release_runner_binaries.result == 'success') }} + needs: [preflight_stable, verify_stable, release_runner_source, release_runner_binaries] runs-on: ubuntu-latest timeout-minutes: 150 environment: npm-stable @@ -1347,6 +1440,17 @@ jobs: fetch-depth: 0 ref: ${{ needs.preflight_stable.outputs.sha }} + - name: Download exact-source release daemon receipts + if: needs.release_runner_source.outputs.required == 'true' + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: ${{ needs.release_runner_binaries.outputs.runner_artifact_prefix }}-* + path: ${{ runner.temp }}/release-runner-inputs + merge-multiple: true + - name: Bind daemon inputs for release assembly + if: needs.release_runner_source.outputs.required == 'true' + run: echo "PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR=$RUNNER_TEMP/release-runner-inputs" >> "$GITHUB_ENV" + - name: Setup pnpm uses: pnpm/action-setup@v6 with: diff --git a/doc/plans/2026-10-06-default-agent-runner.md b/doc/plans/2026-10-06-default-agent-runner.md index d28e7960eaf..0e00c147a0b 100644 --- a/doc/plans/2026-10-06-default-agent-runner.md +++ b/doc/plans/2026-10-06-default-agent-runner.md @@ -1,6 +1,55 @@ # Runner defaults: staged delivery -Updated 2026-10-08. +Updated 2026-10-12. + +## Latest checkpoint: runtime prerequisite and authorized merges + +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. + +Next action: open the extracted prerequisite, compose the preserved default +branch on it, require current-source CI/review, and remotely prepare the exact +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. ## Outcome and ownership diff --git a/packages/paperclip-runner/README.md b/packages/paperclip-runner/README.md index a7f0647a39f..09fce4eb820 100644 --- a/packages/paperclip-runner/README.md +++ b/packages/paperclip-runner/README.md @@ -66,8 +66,8 @@ are allowed. Relative PATH entries use the selected process working directory. Compatibility is established by the actual protocol or login attempt. Missing executables and real protocol failures still return actionable errors. Package identity and executable containment remain checked. Browser login keeps -its existing provider-specific credential home. Linux ARM64 retains its existing -legacy login path because native execution is not qualified there. +its provider-specific credential home and resolves the installed Codex CLI on +64-bit Linux and macOS independently of the daemon's libc requirements. Exact dependency pins remain release and ACPX artifact-qualification checks. They make published builds reproducible and verify sandbox artifacts; they do not @@ -168,7 +168,7 @@ are unavailable. Accepting a native plan ends the planning run successfully whil the task waits for the next instruction. See the [Cursor release report](../../doc/plans/2026-10-03-cursor-production-readiness.md). -A release includes all three platform daemons and the Linux provider-pack identity +A release includes all four platform daemons and the Linux provider-pack identity from its matching Daytona image. Run `stage:release-binaries` with a manifest that binds each daemon path and SHA-256, plus `remoteProviderPack: {path, sha256}` for the actual image's `provider-pack.json`. Assemble these assets after the normal @@ -183,6 +183,55 @@ Ordinary remote Cursor startup uses the packaged Linux daemon and verifies every image asset against that manifest. A mismatched image fails before the provider starts; install the matching package and image together. +### Native binary platform baseline + +Release packages contain Paperclip daemons for `linux-x64`, `linux-arm64`, +`darwin-arm64`, and `darwin-x64`. Codex native binaries are installed separately +through the official optional npm platform dependencies described above. + +The shipped Linux daemons target **GNU libc 2.39 or newer**, on x86-64 or AArch64. +Their ELF version requirements include `GLIBC_2.39`; both depend on `libc.so.6`, +`libm.so.6`, `libgcc_s.so.1`, and the matching GNU dynamic loader. The ARM64 +loader is `/lib/ld-linux-aarch64.so.1`; x64 uses +`/lib64/ld-linux-x86-64.so.2`. This is a binary compatibility baseline, not a +promise that every Linux distribution or kernel has been qualified. + +Hosted native producers use Ubuntu 24.04 for both Linux architectures. Clean npm +install checks use the pinned Debian 13 Node image; the cloud Dockerfile uses +Debian 13 (`node:24-trixie-slim`). macOS binaries build on the existing macOS 15 +Apple Silicon and Intel hosts. Actual task and follow-up acceptance is performed +on Apple Silicon; Intel qualification covers build, installation, and protocol +startup. + +On Linux, Codex's sandboxed filesystem modes also require the host to permit its +unprivileged namespaces. A no-privilege Docker container can prohibit nested +namespace creation even when installation and binary compatibility pass. +Qualification therefore retains the restricted Docker install/lifecycle checks +and exercises the same installed graph's read-only native startup on the actual +ARM host. This does not qualify arbitrary container or managed setup targets; +their selected runtime must pass setup in that environment. + +Ubuntu 24.04 also needs system `bubblewrap` and the stock +`bwrap-userns-restrict` AppArmor profile described in +[Codex's Linux prerequisites](https://learn.chatgpt.com/docs/sandboxing#prerequisites). +The qualified ARM64 host uses that distribution helper/profile while retaining +AppArmor enforcement and global namespace restrictions. Hosted qualification +materializes authenticated stock package files without running maintainer +scripts; it verifies native startup and package continuity separately from the +restricted Docker installation check. This does not prove a normal OS package +installation journey or a paid provider task on ARM64. + +Windows, 32-bit ARM, and Alpine/musl native qualification remain deferred. + +Native release jobs cache Cargo dependencies per platform and OS baseline, +including compiler, lockfile, and external generated-contract inputs. Workspace +crates and released binaries are rebuilt and verified every time. Only canonical +exact-source master pushes may save trusted caches; preview qualification can +restore them. JavaScript preparation and native compilation run concurrently, +and both must succeed before artifact metadata and source-bound hashes are +recorded. Run Docker and native qualification on hosted CI rather than a local +developer machine. + Pi cold provider admission has an absolute 60-second budget. Warm run attachment checkpoints the old ACPX sidecar and starts a new one, so it uses that same budget while retaining the existing Runner authority. Live adoption and ordinary diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs index b1ed069b092..8568cb65f28 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs @@ -1034,6 +1034,9 @@ fn run() -> Result<(), Box<dyn std::error::Error>> { } "initialized" => {} "thread/start" => { + if let Some(delay) = argument(&args, "--thread-start-delay-ms") { + thread::sleep(Duration::from_millis(delay.parse()?)); + } if require_external_sandbox && (message.pointer("/params/sandbox") != Some(&json!("danger-full-access")) || message.pointer("/params/permissions").is_some()) @@ -1076,7 +1079,8 @@ fn run() -> Result<(), Box<dyn std::error::Error>> { } send(json!({ "id": id, - "result": {"thread": {"id": state.thread_id, "sessionId": "codex-account-session"}} + "result": {"thread": {"id": state.thread_id, "sessionId": "codex-account-session"}, + "model": argument(&args, "--returned-model").map(Value::String).unwrap_or_else(|| message.pointer("/params/model").filter(|model| model.is_string()).cloned().unwrap_or(json!("gpt-test")))} }))?; if agent_created_goal { send(json!({ diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs b/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs index 61432b9dbe0..cf211a6c972 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs @@ -367,6 +367,8 @@ pub struct CodexProviderConfig { pub approval_policy: String, #[serde(default)] pub externally_sandboxed: bool, + #[serde(default, skip_serializing_if = "is_false")] + pub read_only: bool, // Older persisted configurations deliberately retain the provider default. #[serde(default, skip_serializing_if = "Option::is_none")] pub include_skill_instructions: Option<bool>, @@ -669,6 +671,7 @@ pub struct CodexProvider { next_request_id: u64, thread_id: String, provider_session_id: Option<String>, + observed_model: Option<String>, active_provider_turn_id: Option<String>, pending_messages: VecDeque<BufferedProviderMessage>, deferred_ambiguous_messages: VecDeque<BufferedProviderMessage>, @@ -831,6 +834,10 @@ fn codex_permission_profile(provider: &str, external_sandbox: bool) -> &'static } } +fn is_false(value: &bool) -> bool { + !*value +} + impl CodexProvider { pub fn start( config: &CodexProviderConfig, @@ -904,11 +911,15 @@ impl CodexProvider { )); } let authorized_tools = authorized_tools.into_iter().collect::<Vec<_>>(); - let permission_profile = codex_permission_profile( - &config.provider, - config.externally_sandboxed - || std::env::var("PAPERCLIP_RUNNER_EXTERNAL_SANDBOX").as_deref() == Ok("1"), - ); + let permission_profile = if config.read_only { + "paperclip-runner-workspace-read-only" + } else { + codex_permission_profile( + &config.provider, + config.externally_sandboxed + || std::env::var("PAPERCLIP_RUNNER_EXTERNAL_SANDBOX").as_deref() == Ok("1"), + ) + }; let (dynamic_tools, authorized_tool_ids) = codex_dynamic_tools(authorized_tools.iter().cloned())?; let common_environment_keys = [ @@ -1003,6 +1014,7 @@ impl CodexProvider { next_request_id: 1, thread_id: String::new(), provider_session_id: None, + observed_model: None, active_provider_turn_id: None, pending_messages: VecDeque::new(), deferred_ambiguous_messages: VecDeque::new(), @@ -1133,6 +1145,12 @@ impl CodexProvider { .and_then(Value::as_str) .filter(|value| !value.is_empty()) .map(str::to_owned); + provider.observed_model = opened + .get("model") + .or_else(|| opened.pointer("/thread/model")) + .and_then(Value::as_str) + .filter(|value| !value.is_empty() && value.len() <= 240) + .map(str::to_owned); if resume_thread_id.is_some() { stage = ProviderStartupStage::ThreadRead; @@ -1175,6 +1193,10 @@ impl CodexProvider { self.provider_session_id.as_deref() } + pub fn observed_model(&self) -> Option<&str> { + self.observed_model.as_deref() + } + pub(crate) fn take_provider_trace_frame_id(&mut self) -> Option<u64> { self.last_trace_frame_id.take() } @@ -1685,6 +1707,16 @@ impl CodexProvider { message: &str, cwd: &str, options: CodexTurnOptions<'_>, + ) -> Result<Value, LocalRunnerError> { + self.start_turn_with_options_and_effort(message, cwd, options, None) + } + + pub(crate) fn start_turn_with_options_and_effort( + &mut self, + message: &str, + cwd: &str, + options: CodexTurnOptions<'_>, + effort: Option<&Value>, ) -> Result<Value, LocalRunnerError> { let CodexTurnOptions { skills, @@ -1705,6 +1737,17 @@ impl CodexProvider { for skill in skills { skill.validate()?; } + let effort = effort.filter(|value| !value.is_null()); + if effort.is_some_and(|value| { + !matches!( + value.as_str(), + Some("minimal" | "low" | "medium" | "high" | "xhigh" | "max" | "ultra") + ) + }) { + return Err(LocalRunnerError::invalid( + "unsupported native Codex reasoning effort", + )); + } if self.quarantined { return Err(LocalRunnerError::invalid( "Codex provider is quarantined after unsafe recovered work", @@ -1745,6 +1788,9 @@ impl CodexProvider { let turn_params_object = turn_params .as_object_mut() .expect("Codex turn parameters are an object"); + if let Some(effort) = effort { + turn_params_object.insert("effort".to_owned(), effort.clone()); + } if let Some(mode) = reasoning_mode { turn_params_object.insert("reasoningMode".to_owned(), json!(mode)); } @@ -4094,6 +4140,7 @@ done instructions: "Test only.".to_owned(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }; @@ -4481,6 +4528,7 @@ done instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }; @@ -4601,6 +4649,7 @@ done instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }; diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs index 329c3dc7bf4..e6c2c977dbe 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs @@ -2608,7 +2608,14 @@ impl CodexCommandExecutor { .as_ref() .and_then(|state| state.thread_id.as_ref()) .is_some(); - let (thread_id, provider_session_id, process_id, active_provider_turn_id, goal_probe) = { + let ( + thread_id, + provider_session_id, + process_id, + active_provider_turn_id, + goal_probe, + observed_model, + ) = { let provider = self.ensure_provider()?; ( provider.thread_id().to_owned(), @@ -2616,6 +2623,7 @@ impl CodexCommandExecutor { provider.process_id(), provider.active_provider_turn_id().map(str::to_owned), provider.get_goal(), + provider.observed_model().map(str::to_owned), ) }; let (goal_capability, goal) = match goal_probe { @@ -2703,6 +2711,7 @@ impl CodexCommandExecutor { "providerSessionId": thread_id, "providerAccountSessionId": provider_session_id, "processId": process_id, + "providerDescriptor": { "driver": driver, "providerVersion": provider_version, "effectiveModel": observed_model }, }), ), ( @@ -3003,13 +3012,14 @@ impl CodexCommandExecutor { rejected_accepted_turn, ) = { let provider = self.ensure_provider()?; - let result = provider.start_turn_with_options( + let result = provider.start_turn_with_options_and_effort( text, &cwd, CodexTurnOptions { skills: &skills, reasoning_mode, }, + payload.get("effort"), ); ( result, @@ -4998,6 +5008,7 @@ mod tests { instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }, @@ -5379,6 +5390,7 @@ mod tests { instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }, @@ -5470,6 +5482,7 @@ mod tests { instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }, @@ -5523,6 +5536,7 @@ mod tests { instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }, @@ -5566,6 +5580,7 @@ mod tests { instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }, @@ -5658,6 +5673,7 @@ mod tests { instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }, @@ -5725,6 +5741,7 @@ mod tests { instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }, @@ -5775,6 +5792,7 @@ mod tests { instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }, @@ -5899,6 +5917,7 @@ mod tests { instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }, @@ -6011,6 +6030,7 @@ mod tests { instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }, @@ -6068,6 +6088,7 @@ mod tests { instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }, @@ -6210,6 +6231,7 @@ mod tests { instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }, @@ -6251,6 +6273,7 @@ mod tests { instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }, @@ -6289,6 +6312,7 @@ mod tests { instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }, @@ -6366,6 +6390,7 @@ mod tests { instructions: String::new(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, }, diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs index b4bc2a316b5..e586105d517 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/codex_provider.rs @@ -65,6 +65,7 @@ fn provider_config(directory: &Path, switches: &[&str]) -> CodexProviderConfig { instructions: "Stay inside the test workspace.".to_owned(), approval_policy: "never".to_owned(), externally_sandboxed: false, + read_only: false, include_skill_instructions: None, conversation_mode: None, } diff --git a/packages/paperclip-runner/scripts/release-provider-pack.test.mjs b/packages/paperclip-runner/scripts/release-provider-pack.test.mjs index 33d6de5874f..5dded2a32fd 100644 --- a/packages/paperclip-runner/scripts/release-provider-pack.test.mjs +++ b/packages/paperclip-runner/scripts/release-provider-pack.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { createHash } from "node:crypto"; import { spawnSync } from "node:child_process"; -import { cpSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { cpSync, existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -9,6 +9,7 @@ import test from "node:test"; import profiles from "../acpx-profiles.json" with { type: "json" }; import distributions from "../cursor-distributions.json" with { type: "json" }; import { verifyReleaseProviderPack } from "./release-provider-pack.mjs"; +import { existingReleaseProviderPack } from "./stage-release-runner-binaries.mjs"; const revision = "a".repeat(40); const digest = "sha256:" + "b".repeat(64); @@ -47,7 +48,7 @@ test("rejects omitted Cursor, conflicting legacy inventory, stale sources and fo }); test("release assembly rejects rehashed stale or missing packs before staging any files", () => { - const root = mkdtempSync(join(tmpdir(), "release-provider-pack-")); + const root = realpathSync(mkdtempSync(join(tmpdir(), "release-provider-pack-"))); const source = dirname(dirname(fileURLToPath(import.meta.url))); try { // Run the actual assembler in an isolated layout so it cannot overwrite built artifacts. @@ -55,10 +56,10 @@ test("release assembly rejects rehashed stale or missing packs before staging an mkdirSync(dirname(join(root, file)), { recursive: true }); cpSync(join(source, file), join(root, file)); } const platforms = {}; - for (const target of ["darwin-arm64", "darwin-x64", "linux-x64"]) { + for (const target of ["darwin-arm64", "darwin-x64", "linux-x64", "linux-arm64"]) { const bytes = Buffer.alloc(64); - if (target === "linux-x64") { - bytes.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); bytes.writeUInt16LE(62, 18); + if (target.startsWith("linux-")) { + bytes.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); bytes.writeUInt16LE(target === "linux-x64" ? 62 : 183, 18); } else { bytes.writeUInt32LE(0xfeedfacf, 0); bytes.writeUInt32LE(target === "darwin-arm64" ? 0x0100000c : 0x01000007, 4); } @@ -77,3 +78,19 @@ test("release assembly rejects rehashed stale or missing packs before staging an } } finally { rmSync(root, { recursive: true, force: true }); } }); + + +test("daemon-only assembly retains an existing same-source Linux provider-pack identity", () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "release-provider-retained-"))); + try { + assert.equal(existingReleaseProviderPack(root, revision), undefined); + const file = join(root, "dist/remote-provider-packs/linux-x64/provider-pack.json"); + mkdirSync(dirname(file), { recursive: true }); + const pack = seal(fixture()); writeFileSync(file, JSON.stringify(pack)); + assert.deepEqual(existingReleaseProviderPack(root, revision), pack); + assert.throws(() => existingReleaseProviderPack(root, "c".repeat(40)), /release source/); + const wrongTarget = fixture(); wrongTarget.target = { platform: "darwin", architecture: "arm64" }; + writeFileSync(file, JSON.stringify(seal(wrongTarget))); + assert.throws(() => existingReleaseProviderPack(root, revision), /Linux x64 target/); + } finally { rmSync(root, { recursive: true, force: true }); } +}); diff --git a/packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs b/packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs index f0e505363ac..c36e79e2aa7 100644 --- a/packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs +++ b/packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs @@ -1,55 +1,135 @@ -// Assemble the three independently built release daemons before npm packing. +// Assemble the four independently built release daemons before npm packing. // Usage: node packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs MANIFEST.json // Manifest: {sourceRevision, platforms: {"darwin-arm64": {path, sha256}, ...}, remoteProviderPack: {path, sha256}}. import { createHash } from "node:crypto"; import { execFileSync } from "node:child_process"; -import { chmodSync, copyFileSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { runnerBinaryTarget } from "../src/live/runner-binary.ts"; import { verifyReleaseProviderPack } from "./release-provider-pack.mjs"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const required = ["darwin-arm64", "darwin-x64", "linux-x64"]; -if (process.argv.length !== 3) throw Error("Usage: stage-release-runner-binaries.mjs MANIFEST.json"); -const manifest = JSON.parse(readFileSync(process.argv[2], "utf8")); -if (!/^[a-f0-9]{40}$/.test(manifest.sourceRevision) || !manifest.platforms || Object.keys(manifest.platforms).sort().join() !== [...required].sort().join()) throw Error("Release daemon manifest must bind one source and all three supported targets"); -const verified = required.map(target => { - const artifact = manifest.platforms[target]; - if (!artifact || !isAbsolute(artifact.path) || !/^sha256:[a-f0-9]{64}$/.test(artifact.sha256)) throw Error(`Invalid ${target} release artifact`); - const bytes = readFileSync(artifact.path), sha256 = "sha256:" + createHash("sha256").update(bytes).digest("hex"); - if (sha256 !== artifact.sha256 || runnerBinaryTarget(bytes) !== target) throw Error(`Release daemon ${target} digest or architecture mismatch`); - return { target, source: artifact.path, sha256 }; -}); -if (!manifest.remoteProviderPack) throw Error("Release assembly requires the matching Linux remote provider pack"); -const entry = manifest.remoteProviderPack; -if (!isAbsolute(entry.path) || !/^sha256:[a-f0-9]{64}$/.test(entry.sha256)) throw Error("Invalid remote provider-pack release artifact"); -const packBytes = readFileSync(entry.path); -if (packBytes.length > 512 * 1024 || "sha256:" + createHash("sha256").update(packBytes).digest("hex") !== entry.sha256) throw Error("Remote provider-pack release manifest digest mismatch"); -const packIdentity = verifyReleaseProviderPack(JSON.parse(packBytes), manifest.sourceRevision); -const platforms = {}; -for (const artifact of verified) { - const relative = `${artifact.target}/paperclip-runnerd`, destination = join(root, "dist", "bin", relative); - mkdirSync(dirname(destination), { recursive: true }); copyFileSync(artifact.source, destination); chmodSync(destination, 0o755); - // Copied Mach-O inodes need a fresh ad-hoc signature on macOS. Record the - // resulting packaged bytes, as well as the verified build artifact identity. - if (process.platform === "darwin" && artifact.target.startsWith("darwin-")) { - execFileSync("codesign", ["--force", "--sign", "-", destination], { stdio: "pipe" }); - } - const sha256 = "sha256:" + createHash("sha256").update(readFileSync(destination)).digest("hex"); - platforms[artifact.target] = { path: relative, sha256, sourceSha256: artifact.sha256 }; +export const RELEASE_RUNNER_TARGETS = Object.freeze(["darwin-arm64", "darwin-x64", "linux-x64", "linux-arm64"]); +const required = RELEASE_RUNNER_TARGETS; +const digest = bytes => "sha256:" + createHash("sha256").update(bytes).digest("hex"); +function regularFile(path, maximumSize) { + const stat = lstatSync(path); + if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size < 1 || stat.size > maximumSize + || realpathSync(path) !== resolve(path)) throw Error("Release artifact must be a bounded canonical regular file"); + return readFileSync(path); } -{ - const destination = join(root, "dist", "remote-provider-packs", "linux-x64", "provider-pack.json"); - mkdirSync(dirname(destination), { recursive: true }); - // Replace the inode so repeat assembly can preserve a read-only manifest. - const staged = `${destination}.staging-${process.pid}`; - try { - writeFileSync(staged, packBytes, { mode: 0o444, flag: "wx" }); - renameSync(staged, destination); - } finally { - rmSync(staged, { force: true }); - } + +// Production TypeScript contracts are built by the existing producer step. +// Foreign artifacts are data: the assembler never executes another target. +async function metadataContract() { + const { PAPERCLIP_RUNNER_BUILD_METADATA } = await import("../dist/evals/build-metadata.js"); + const { parsePaperclipRunnerdBuildMetadata } = await import("../dist/evals/runnerd-artifact.js"); + return metadata => verifyReleaseRunnerMetadata(parsePaperclipRunnerdBuildMetadata(metadata), PAPERCLIP_RUNNER_BUILD_METADATA); } -writeFileSync(join(root, "dist", "bin", "release-manifest.json"), JSON.stringify({schema:"paperclip.runner.release-binaries.v1",sourceRevision:manifest.sourceRevision,platforms, - remoteProviderPack: { target: "linux-x64", digest: packIdentity.digest, sourceRevision: packIdentity.payload.runnerSourceRevision }},null,2)+"\n"); -console.log("Staged verified release daemons for " + required.join(", ")); +export function verifyReleaseRunnerMetadata(metadata, expected) { + if (metadata.binaryContractVersion !== expected.contracts.runnerdArtifact + || metadata.nativeExecutionVersion !== expected.contracts.nativeExecution + || metadata.harnessDriverVersion !== expected.contracts.harnessDriver + || metadata.packageName !== expected.package.name || metadata.packageVersion !== expected.package.version + || metadata.prp?.name !== expected.prp.name || metadata.prp.minimumVersion > expected.prp.maximumVersion + || metadata.prp.maximumVersion < expected.prp.minimumVersion) throw Error("Release daemon build metadata is incompatible with the production contract"); + return metadata; +} + +export function releaseDaemonManifest(artifactDirectory, sourceRevision, verifyMetadata) { + if (!/^[a-f0-9]{40}$/.test(sourceRevision)) throw Error("Release daemons require an immutable source revision"); + if (readdirSync(artifactDirectory).sort().join() !== [...required].sort().join()) throw Error("Release daemons require exactly the four supported target directories"); + const platforms = {}; + for (const target of required) { + const directory = join(resolve(artifactDirectory), target); + if (readdirSync(directory).sort().join() !== "paperclip-runnerd,receipt.json") throw Error("Release target must contain only its daemon and receipt"); + const receipt = JSON.parse(regularFile(join(directory, "receipt.json"), 64 * 1024)); + const path = join(directory, "paperclip-runnerd"), bytes = regularFile(path, 256 * 1024 * 1024); + if (receipt.schema !== "paperclip.runner.release-artifact.v1" || receipt.sourceRevision !== sourceRevision + || receipt.target !== target || receipt.sha256 !== digest(bytes) || runnerBinaryTarget(bytes) !== target) { + throw Error(`Release daemon ${target} source, digest or architecture mismatch`); + } + verifyMetadata(receipt.buildMetadata); + platforms[target] = { path, sha256: receipt.sha256, buildMetadata: receipt.buildMetadata }; + } + return { sourceRevision, platforms }; +} + +export function existingReleaseProviderPack(packageRoot, sourceRevision) { + const file = join(packageRoot, "dist/remote-provider-packs/linux-x64/provider-pack.json"); + if (!existsSync(file)) return undefined; + return verifyReleaseProviderPack(JSON.parse(regularFile(file, 512 * 1024)), sourceRevision); +} + +async function main() { + if (process.argv[2] === "record") { + const [, , , sourceRevision, target, source, output] = process.argv; + if (process.argv.length !== 7 || !/^[a-f0-9]{40}$/.test(sourceRevision) || !required.includes(target) + || target !== `${process.platform}-${process.arch}`) throw Error("Record requires the exact source and actual producer host target"); + const bytes = regularFile(resolve(source), 256 * 1024 * 1024); + if (runnerBinaryTarget(bytes) !== target) throw Error("Built daemon architecture does not match its producer"); + const buildMetadata = JSON.parse(execFileSync(resolve(source), ["--build-metadata"], { + timeout: 15_000, maxBuffer: 64 * 1024, encoding: "utf8", + })); + (await metadataContract())(buildMetadata); + mkdirSync(output, { recursive: true }); + copyFileSync(source, join(output, "paperclip-runnerd")); + writeFileSync(join(output, "receipt.json"), JSON.stringify({ schema: "paperclip.runner.release-artifact.v1", + sourceRevision, target, sha256: digest(bytes), buildMetadata }, null, 2) + "\n"); + return; + } + const daemonOnly = process.argv[2] === "assemble-daemons"; + if (daemonOnly ? process.argv.length !== 5 : process.argv.length !== 3) throw Error("Usage: stage-release-runner-binaries.mjs MANIFEST.json | assemble-daemons ARTIFACT_DIRECTORY SOURCE_SHA"); + const manifest = daemonOnly + ? releaseDaemonManifest(process.argv[3], process.argv[4], await metadataContract()) + : JSON.parse(regularFile(resolve(process.argv[2]), 512 * 1024)); + if (!/^[a-f0-9]{40}$/.test(manifest.sourceRevision) || !manifest.platforms || Object.keys(manifest.platforms).sort().join() !== [...required].sort().join()) throw Error("Release daemon manifest must bind one source and all four supported targets"); + const verified = required.map(target => { + const artifact = manifest.platforms[target]; + if (!artifact || !isAbsolute(artifact.path) || !/^sha256:[a-f0-9]{64}$/.test(artifact.sha256)) throw Error(`Invalid ${target} release artifact`); + const bytes = regularFile(artifact.path, 256 * 1024 * 1024), sha256 = "sha256:" + createHash("sha256").update(bytes).digest("hex"); + if (sha256 !== artifact.sha256 || runnerBinaryTarget(bytes) !== target) throw Error(`Release daemon ${target} digest or architecture mismatch`); + return { target, source: artifact.path, sha256 }; + }); + if (!manifest.remoteProviderPack && !daemonOnly) throw Error("Release assembly requires the matching Linux remote provider pack"); + let packBytes, packIdentity; + if (manifest.remoteProviderPack) { + const entry = manifest.remoteProviderPack; + if (!isAbsolute(entry.path) || !/^sha256:[a-f0-9]{64}$/.test(entry.sha256)) throw Error("Invalid remote provider-pack release artifact"); + packBytes = regularFile(entry.path, 512 * 1024); + if (packBytes.length > 512 * 1024 || "sha256:" + createHash("sha256").update(packBytes).digest("hex") !== entry.sha256) throw Error("Remote provider-pack release manifest digest mismatch"); + packIdentity = verifyReleaseProviderPack(JSON.parse(packBytes), manifest.sourceRevision); + } else if (daemonOnly) { + // Preserve a qualified same-source Linux pack already materialized by the + // normal producer. It never becomes a Mac provider-pack qualification. + packIdentity = existingReleaseProviderPack(root, manifest.sourceRevision); + } + const platforms = {}; + for (const artifact of verified) { + const relative = `${artifact.target}/paperclip-runnerd`, destination = join(root, "dist", "bin", relative); + mkdirSync(dirname(destination), { recursive: true }); copyFileSync(artifact.source, destination); chmodSync(destination, 0o755); + // Copied Mach-O inodes need a fresh ad-hoc signature on macOS. Record the + // resulting packaged bytes, as well as the verified build artifact identity. + if (process.platform === "darwin" && artifact.target.startsWith("darwin-")) { + execFileSync("codesign", ["--force", "--sign", "-", destination], { stdio: "pipe", timeout: 30_000 }); + } + const sha256 = "sha256:" + createHash("sha256").update(readFileSync(destination)).digest("hex"); + platforms[artifact.target] = { path: relative, sha256, sourceSha256: artifact.sha256 }; + } + if (packBytes) { + const destination = join(root, "dist", "remote-provider-packs", "linux-x64", "provider-pack.json"); + mkdirSync(dirname(destination), { recursive: true }); + // Replace the inode so repeat assembly can preserve a read-only manifest. + const staged = `${destination}.staging-${process.pid}`; + try { + writeFileSync(staged, packBytes, { mode: 0o444, flag: "wx" }); + renameSync(staged, destination); + } finally { + rmSync(staged, { force: true }); + } + } + writeFileSync(join(root, "dist", "bin", "release-manifest.json"), JSON.stringify({schema:"paperclip.runner.release-binaries.v1",sourceRevision:manifest.sourceRevision,platforms, + ...(packIdentity ? { remoteProviderPack: { target: "linux-x64", digest: packIdentity.digest, sourceRevision: packIdentity.payload.runnerSourceRevision } } : {})},null,2)+"\n"); + console.log("Staged verified release daemons for " + required.join(", ")); +} +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main(); diff --git a/packages/paperclip-runner/scripts/stage-runner-binary.mjs b/packages/paperclip-runner/scripts/stage-runner-binary.mjs index 48643ea7083..581ee2867e8 100644 --- a/packages/paperclip-runner/scripts/stage-runner-binary.mjs +++ b/packages/paperclip-runner/scripts/stage-runner-binary.mjs @@ -28,11 +28,16 @@ export async function stageRunnerBinary(source, destination) { } } +export async function stageBuiltRunnerBinary(packageRoot) { + const executable = process.platform === "win32" ? "paperclip-runnerd.exe" : "paperclip-runnerd"; + const source = path.join(packageRoot, "runner", "target", "release", executable); + const binaryRoot = path.join(packageRoot, "dist", "bin"); + // Source builds are separate from the source-bound release targets and their + // manifest. The dev supervisor selects this fresh output for its server. + await stageRunnerBinary(source, path.join(binaryRoot, executable)); +} + if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); - const executable = process.platform === "win32" ? "paperclip-runnerd.exe" : "paperclip-runnerd"; - await stageRunnerBinary( - path.join(packageRoot, "runner", "target", "release", executable), - path.join(packageRoot, "dist", "bin", executable), - ); + await stageBuiltRunnerBinary(packageRoot); } diff --git a/packages/paperclip-runner/scripts/stage-runner-binary.test.mjs b/packages/paperclip-runner/scripts/stage-runner-binary.test.mjs index 81696b8ba65..3efc5bcf7e9 100644 --- a/packages/paperclip-runner/scripts/stage-runner-binary.test.mjs +++ b/packages/paperclip-runner/scripts/stage-runner-binary.test.mjs @@ -1,11 +1,75 @@ import assert from "node:assert/strict"; -import { spawn } from "node:child_process"; +import { execFileSync, spawn } from "node:child_process"; import { once } from "node:events"; -import { mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; +import { copyFile, mkdir, mkdtemp, readFile, readdir, realpath, rm, stat, symlink, utimes, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; import { tmpdir } from "node:os"; import path from "node:path"; import { test } from "node:test"; -import { stageRunnerBinary } from "./stage-runner-binary.mjs"; +import { stageBuiltRunnerBinary, stageRunnerBinary } from "./stage-runner-binary.mjs"; +import { RELEASE_RUNNER_TARGETS, releaseDaemonManifest, verifyReleaseRunnerMetadata } from "./stage-release-runner-binaries.mjs"; +import { paperclipRunnerBinaryNeedsBuild, resolveNativeRunnerDevBinary } from "../../../scripts/dev-runner-native-binary.mjs"; +import { resolvePackagedRunnerBinary } from "../src/live/runner-binary.ts"; + +const contract = { package: { name: "@paperclipai/paperclip-runner", version: "0.0.0" }, + contracts: { runnerdArtifact: 2, nativeExecution: 1, harnessDriver: 1 }, prp: { name: "paperclip.runner", minimumVersion: 1, maximumVersion: 3 } }; +const metadata = { binaryContractVersion: 2, nativeExecutionVersion: 1, harnessDriverVersion: 1, + packageName: "@paperclipai/paperclip-runner", packageVersion: "0.0.0", prp: { name: "paperclip.runner", minimumVersion: 1, maximumVersion: 2 } }; + +test("release assembly binds all four actual targets, source, hashes and compatible metadata", async () => { + const root = await realpath(await mkdtemp(path.join(tmpdir(), "runner-release-inputs-"))), source = "a".repeat(40); + const verify = value => verifyReleaseRunnerMetadata(value, contract); + const receiptPath = target => path.join(root, target, "receipt.json"); + try { + for (const target of RELEASE_RUNNER_TARGETS) { + await mkdir(path.join(root, target)); + const bytes = Buffer.alloc(64); + if (target.startsWith("linux-")) { + bytes.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); bytes.writeUInt16LE(target === "linux-x64" ? 62 : 183, 18); + } else { bytes.writeUInt32LE(0xfeedfacf); bytes.writeUInt32LE(target === "darwin-x64" ? 0x01000007 : 0x0100000c, 4); } + await writeFile(path.join(root, target, "paperclip-runnerd"), bytes); + await writeFile(receiptPath(target), JSON.stringify({ schema: "paperclip.runner.release-artifact.v1", sourceRevision: source, + target, sha256: "sha256:" + createHash("sha256").update(bytes).digest("hex"), buildMetadata: metadata })); + } + const actual = releaseDaemonManifest(root, source, verify); + assert.deepEqual(Object.keys(actual.platforms), RELEASE_RUNNER_TARGETS); + assert.equal(actual.remoteProviderPack, undefined, "Daemon-only data does not qualify a provider pack"); + assert.throws(() => releaseDaemonManifest(root, "b".repeat(40), verify), /source, digest or architecture/); + const original = await readFile(receiptPath("darwin-arm64"), "utf8"), receipt = JSON.parse(original); + for (const delta of [{ target: "darwin-x64" }, { sha256: "sha256:" + "0".repeat(64) }, { sourceRevision: "b".repeat(40) }, + { buildMetadata: { ...metadata, binaryContractVersion: 99 } }]) { + await writeFile(receiptPath("darwin-arm64"), JSON.stringify({ ...receipt, ...delta })); + assert.throws(() => releaseDaemonManifest(root, source, verify)); + } + await writeFile(receiptPath("darwin-arm64"), original); + const binary = path.join(root, "darwin-arm64/paperclip-runnerd"), bytes = await readFile(binary); + await writeFile(binary, await readFile(path.join(root, "darwin-x64/paperclip-runnerd"))); + assert.throws(() => releaseDaemonManifest(root, source, verify), /digest or architecture/); + await rm(binary); await symlink(path.join(root, "darwin-x64/paperclip-runnerd"), binary); + assert.throws(() => releaseDaemonManifest(root, source, verify), /regular file/); + await rm(binary); await writeFile(binary, bytes); + const armBinary = path.join(root, "linux-arm64/paperclip-runnerd"), armBytes = await readFile(armBinary); + const armReceipt = JSON.parse(await readFile(receiptPath("linux-arm64"), "utf8")); + const wrongArm = await readFile(path.join(root, "linux-x64/paperclip-runnerd")); + await writeFile(armBinary, wrongArm); + await writeFile(receiptPath("linux-arm64"), JSON.stringify({ ...armReceipt, sha256: "sha256:" + createHash("sha256").update(wrongArm).digest("hex") })); + assert.throws(() => releaseDaemonManifest(root, source, verify), /architecture mismatch/); + await writeFile(armBinary, armBytes); await writeFile(receiptPath("linux-arm64"), JSON.stringify(armReceipt)); + await mkdir(path.join(root, "win32-x64")); + assert.throws(() => releaseDaemonManifest(root, source, verify), /exactly the four/); + await rm(path.join(root, "win32-x64"), { recursive: true }); + await rm(receiptPath("linux-arm64")); + assert.throws(() => releaseDaemonManifest(root, source, verify), /only its daemon and receipt/); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("release metadata rejects incompatible native, harness and protocol contracts without semver heuristics", () => { + assert.equal(verifyReleaseRunnerMetadata(metadata, contract), metadata); + for (const delta of [{ nativeExecutionVersion: 9 }, { harnessDriverVersion: 9 }, { packageName: "untrusted" }, + { prp: { ...metadata.prp, name: "other" } }, { prp: { ...metadata.prp, minimumVersion: 4, maximumVersion: 5 } }]) { + assert.throws(() => verifyReleaseRunnerMetadata({ ...metadata, ...delta }, contract), /incompatible/); + } +}); async function withOwnedChildren(root, action) { const owned = []; @@ -53,6 +117,82 @@ test("atomically publishes while the previous executable is running", { skip: pr }); }); +test("a source rebuild selects fresh dev bytes while preserving release binaries and their manifest", { skip: process.platform === "win32", timeout: 15_000 }, async () => { + const root = await mkdtemp(path.join(tmpdir(), "runnerd-stage-rebuild-")); + const packageRoot = path.join(root, "packages", "paperclip-runner"); + const binaryRoot = path.join(packageRoot, "dist", "bin"); + const hostTarget = path.join(binaryRoot, `${process.platform}-${process.arch}`, "paperclip-runnerd"); + const source = path.join(packageRoot, "runner", "crates", "runner-core", "src", "main.rs"); + const built = path.join(packageRoot, "runner", "target", "release", "paperclip-runnerd"); + await withOwnedChildren(root, async start => { + const copyHostFixture = async (input, output) => { + await copyFile(input, output); + if (process.platform === "darwin") { + // System fixtures can be universal; Cargo release daemons are thin. + // Extract only this host slice before exercising the native header gate. + const slices = execFileSync("/usr/bin/lipo", ["-archs", input], { encoding: "utf8" }).trim().split(/\s+/); + const architecture = slices.find(value => process.arch === "arm64" ? value.startsWith("arm64") : value === "x86_64"); + assert.ok(architecture); + if (slices.length > 1) execFileSync("/usr/bin/lipo", [input, "-thin", architecture, "-output", output]); + } + }; + await mkdir(path.dirname(source), { recursive: true }); + await writeFile(source, "fn main() {}\n"); + await mkdir(path.dirname(built), { recursive: true }); + await copyHostFixture("/bin/echo", built); + const previousBuild = path.join(root, "previous-build"); + await copyHostFixture("/bin/sleep", previousBuild); + await stageRunnerBinary(previousBuild, hostTarget); + await stageRunnerBinary(built, path.join(binaryRoot, "paperclip-runnerd")); + const platforms = {}, releaseBytes = new Map(); + for (const target of RELEASE_RUNNER_TARGETS) { + const artifact = path.join(binaryRoot, target, "paperclip-runnerd"); + if (artifact !== hostTarget) { + const header = Buffer.alloc(64); + if (target.startsWith("linux-")) { + header.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); header.writeUInt16LE(target === "linux-x64" ? 62 : 183, 18); + } else { header.writeUInt32LE(0xfeedfacf); header.writeUInt32LE(target === "darwin-x64" ? 0x01000007 : 0x0100000c, 4); } + await mkdir(path.dirname(artifact), { recursive: true }); + await writeFile(artifact, header); + } + const bytes = await readFile(artifact); + releaseBytes.set(artifact, bytes); + platforms[target] = { path: `${target}/paperclip-runnerd`, sha256: "sha256:" + createHash("sha256").update(bytes).digest("hex") }; + } + const oldTime = new Date(Date.now() - 10_000); + await utimes(hostTarget, oldTime, oldTime); + await utimes(path.join(binaryRoot, "paperclip-runnerd"), oldTime, oldTime); + const oldArtifact = await readFile(hostTarget); + const oldIdentity = await stat(hostTarget); + const manifestPath = path.join(binaryRoot, "release-manifest.json"); + const manifest = JSON.stringify({ schema: "paperclip.runner.release-binaries.v1", sourceRevision: "a".repeat(40), platforms }); + await writeFile(manifestPath, manifest); + const old = start(hostTarget, ["30"]); + await old.spawned; + const requirement = { repoRoot: root, nativeRunnerRequired: true }; + assert.equal(paperclipRunnerBinaryNeedsBuild(requirement), true); + + await stageBuiltRunnerBinary(packageRoot); + + const selected = resolveNativeRunnerDevBinary(requirement); + assert.deepEqual(selected, { binary: path.join(binaryRoot, "paperclip-runnerd"), sourceBuild: true }); + const published = await stat(selected.binary); + assert.equal(published.mode & 0o777, 0o755); + assert.notDeepEqual(await readFile(selected.binary), oldArtifact); + assert.equal((await stat(hostTarget)).ino, oldIdentity.ino); + assert.deepEqual(await readFile(hostTarget), oldArtifact); + assert.equal(await readFile(manifestPath, "utf8"), manifest); + for (const [artifact, bytes] of releaseBytes) assert.deepEqual(await readFile(artifact), bytes); + assert.equal(resolvePackagedRunnerBinary(path.join(packageRoot, "dist")), hostTarget); + assert.equal(paperclipRunnerBinaryNeedsBuild(requirement), false); + assert.equal(old.child.exitCode, null); + assert.equal(old.child.signalCode, null); + const next = start(selected.binary, ["rebuilt"]); + await next.spawned; await next.closed; + assert.equal(next.child.exitCode, 0); + }); +}); + test("a second spawn error still retires the live old child and removes its fixture", { skip: process.platform === "win32", timeout: 15_000 }, async () => { const root = await mkdtemp(path.join(tmpdir(), "runnerd-stage-spawn-failure-")); let old; diff --git a/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.test.ts b/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.test.ts index aad5b5c737c..02d377d0451 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.test.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.test.ts @@ -5,16 +5,66 @@ import { rmSync, writeFileSync, realpathSync, + statSync, symlinkSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { execFileSync } from "node:child_process"; -import { describe, it, expect } from "vitest"; +import { beforeEach, afterEach, describe, it, expect, vi } from "vitest"; import { parse } from "smol-toml"; import { trustCodexStartupRoot } from "./codex-startup-trust.js"; -describe("isolated Codex startup trust", () => { +const parserStyle = vi.hoisted(() => ({ nullPrototypeTables: false })); +vi.mock("smol-toml", async (importOriginal) => { + const actual = await importOriginal<typeof import("smol-toml")>(); + const setTablePrototypes = (value: unknown): void => { + if (value === null || typeof value !== "object" || value instanceof Date) return; + if (Array.isArray(value)) { + value.forEach(setTablePrototypes); + return; + } + Object.values(value).forEach(setTablePrototypes); + Object.setPrototypeOf(value, parserStyle.nullPrototypeTables ? null : Object.prototype); + }; + return { + ...actual, + parse: (...args: Parameters<typeof actual.parse>) => { + const result = actual.parse(...args); + // Exercise both table representations independently of the installed + // parser version while retaining the real TOML values. + setTablePrototypes(result); + return result; + }, + }; +}); + +describe.each([ + { name: "ordinary tables", nullPrototypeTables: false }, + { name: "null-prototype tables", nullPrototypeTables: true }, +])("isolated Codex startup trust with $name", ({ nullPrototypeTables }) => { + beforeEach(() => { parserStyle.nullPrototypeTables = nullPrototypeTables; }); + afterEach(() => { parserStyle.nullPrototypeTables = false; }); + + it.each(["missing", "empty"])("initializes %s config in an isolated home", (kind) => { + const temp = realpathSync(mkdtempSync(join(tmpdir(), "codex-trust-empty-"))); + try { + const cwd = join(temp, "project"); + const home = join(temp, "home"); + mkdirSync(cwd); + if (kind === "empty") { + mkdirSync(home); + writeFileSync(join(home, "config.toml"), ""); + } + trustCodexStartupRoot(home, cwd); + const path = join(home, "config.toml"); + const updated = readFileSync(path, "utf8"); + expect(parse(updated)).toEqual({ projects: { [cwd]: { trust_level: "trusted" } } }); + expect(statSync(path).mode & 0o777).toBe(0o600); + trustCodexStartupRoot(home, cwd); + expect(readFileSync(path, "utf8")).toBe(updated); + } finally { rmSync(temp, { recursive: true, force: true }); } + }); it.each(["missing", "table", "empty-table", "inline", "empty-inline", "inline-projects"])( "preserves managed comments and unrelated content with %s project configuration", (kind) => { const temp = realpathSync(mkdtempSync(join(tmpdir(), "codex-trust-comments-"))); @@ -60,15 +110,17 @@ describe("isolated Codex startup trust", () => { mkdirSync(home); writeFileSync( join(home, "config.toml"), - 'model = "test"\n[mcp_servers.fixture]\nurl = "http://localhost/example"\n', + 'model = "test"\nstarted = 2026-10-12T12:00:00Z\nlimits = [inf, nan, 9007199254740991]\n[mcp_servers.fixture]\nurl = "http://localhost/example"\n', ); + const original = readFileSync(join(home, "config.toml"), "utf8"); + const unrelated = parse(original); trustCodexStartupRoot(home, cwd); trustCodexStartupRoot(home, cwd); expect(parse(readFileSync(join(home, "config.toml"), "utf8"))).toEqual({ - model: "test", - mcp_servers: { fixture: { url: "http://localhost/example" } }, + ...unrelated, projects: { [cwd]: { trust_level: "trusted" } }, }); + expect(readFileSync(join(home, "config.toml"), "utf8")).toContain(original); expect(readFileSync(join(home, "config.toml"), "utf8")).not.toContain( `[projects."${temp}"]`, ); diff --git a/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.ts b/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.ts index d85f002552f..488b025148d 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-startup-trust.ts @@ -92,24 +92,25 @@ export function trustCodexStartupRoot(codexHome: string, cwd: string): void { const path = join(codexHome, "config.toml"); const source = existsSync(path) ? readFileSync(path, "utf8") : ""; const config = parse(source); - const projects = config.projects ?? {}; + // Retain the parser's table prototypes so strict candidate comparison also + // works with parsers that return null-prototype TOML tables. + const projects = config.projects ?? parse(""); if ( typeof projects !== "object" || Array.isArray(projects) || projects instanceof Date ) throw new Error("codex_startup_trust_invalid_projects"); - const project = projects[root] ?? {}; + const project = projects[root] ?? parse(""); if ( typeof project !== "object" || Array.isArray(project) || project instanceof Date ) throw new Error("codex_startup_trust_invalid_project"); - config.projects = { - ...projects, - [root]: { ...project, trust_level: "trusted" }, - }; + project.trust_level = "trusted"; + projects[root] = project; + config.projects = projects; const updated = editTrust(source, root, config); if (updated === source) return; const temporary = resolve(codexHome, `config.toml.${randomUUID()}.tmp`); diff --git a/packages/paperclip-runner/src/index.ts b/packages/paperclip-runner/src/index.ts index fe7e4c70978..32103f88d94 100644 --- a/packages/paperclip-runner/src/index.ts +++ b/packages/paperclip-runner/src/index.ts @@ -46,6 +46,7 @@ export type { DurableRecoveryIdentity } from "./control-plane/prp-transport-type export * from "./drivers/codex/app-server-transport.js"; export * from "./drivers/codex/codex-app-server-driver.js"; export { resolveCodexCommand, resolvePinnedCodexCommand } from "./drivers/codex/codex-command.js"; +export { runnerBinaryTarget } from "./live/runner-binary.js"; export * from "./drivers/opencode/opencode-server-driver.js"; export * from "./drivers/opencode/mcp-bridge.js"; export * from "./drivers/acpx/qualified-profiles.js"; @@ -59,6 +60,7 @@ export * from "./drivers/acpx/sidecar-protocol.js"; export * from "./drivers/runner-tool-bridge.js"; export { createRunnerdCodexTransport, + probeNativeRunnerEnvironment, runnerCodexDynamicToolsFit, defaultCapabilityRunnerdBinary, readRunnerdArtifactBinding, diff --git a/packages/paperclip-runner/src/live/runner-binary.test.ts b/packages/paperclip-runner/src/live/runner-binary.test.ts index 914f4439989..6f27822fc6b 100644 --- a/packages/paperclip-runner/src/live/runner-binary.test.ts +++ b/packages/paperclip-runner/src/live/runner-binary.test.ts @@ -17,9 +17,9 @@ function binary(root: string, target: string | null, bytes: Buffer) { const file = join(folder, "paperclip-runnerd"); writeFileSync(file, bytes); return file; } describe("packaged native daemon target", () => { - it.each(["darwin-arm64", "darwin-x64", "linux-x64"])("selects %s from a universal package rather than the build-host legacy binary", target => { + it.each(["darwin-arm64", "darwin-x64", "linux-x64", "linux-arm64"])("selects %s from a universal package rather than the build-host legacy binary", target => { const root = fixture(); binary(root, null, header("darwin-arm64")); - for (const t of ["darwin-arm64", "darwin-x64", "linux-x64"]) binary(root, t, header(t)); + for (const t of ["darwin-arm64", "darwin-x64", "linux-x64", "linux-arm64"]) binary(root, t, header(t)); const [platform, architecture] = target.split("-"); expect(runnerBinaryTarget(header(target))).toBe(target); expect(resolvePackagedRunnerBinary(join(root, "dist"), platform as NodeJS.Platform, architecture as typeof process.arch)).toBe(join(root, "dist", "bin", target, "paperclip-runnerd")); @@ -32,6 +32,16 @@ describe("packaged native daemon target", () => { const root = fixture(); binary(root, null, header("linux-x64")); binary(root, "linux-x64", header("darwin-arm64")); expect(() => resolvePackagedRunnerBinary(join(root, "dist"), "linux", "x64")).toThrow("runner_runtime_incompatible"); }); + it.each([["linux-x64", "arm64"], ["linux-arm64", "x64"]] as const)("selects remote bytes beside a %s controller for linux-%s", (controller, remoteArch) => { + const root = fixture(); + binary(root, null, header(controller)); + const remote = binary(root, `linux-${remoteArch}`, header(`linux-${remoteArch}`)); + expect(resolvePackagedRunnerBinary(join(root, "dist"), "linux", remoteArch)).toBe(remote); + }); + it.each([["linux-x64", "arm64"], ["linux-arm64", "x64"]] as const)("rejects a missing remote target instead of falling back to the %s controller for linux-%s", (controller, remoteArch) => { + const root = fixture(); binary(root, null, header(controller)); + expect(() => resolvePackagedRunnerBinary(join(root, "dist"), "linux", remoteArch)).toThrow("runner_runtime_incompatible"); + }); it("retains a matching local legacy build and permits the source-only debug fallback", () => { const root = fixture(); expect(resolvePackagedRunnerBinary(join(root, "dist"), "linux", "x64")).toBeNull(); const legacy = binary(root, null, header("linux-x64")); expect(resolvePackagedRunnerBinary(join(root, "dist"), "linux", "x64")).toBe(legacy); diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts index a64c51c26e2..d33ff858a0c 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -72,6 +72,7 @@ import { releaseMaterializedNativeRuntimeSkills } from "../drivers/runtime-conte import { RUNNERD_CANONICAL_ITEM } from "../drivers/codex/codex-driver-values.js"; import { + probeNativeRunnerEnvironment, parseAcpxTurnControlCapabilities, authorizedToolSetForProvider, createCapabilityRunnerdCodexTransport, @@ -2723,6 +2724,105 @@ function fakeCodexArgs(stateDirectory: string, ...args: string[]): string[] { ]; } +it.each(["complete", "api-key", "default-model", "mismatched-model", "model-refresh-error", "model-cleanup-error", "missing-model", "missing-runtime", "missing-completion", "late-bootstrap", "remote-complete", "remote-mismatched-model", "late-remote-preparation", "late-remote-release-error"] as const)("native Codex setup requires its daemon/app-server hello (%s)", async mode => { + const root = await mkdtemp(join(tmpdir(), "runnerd-native-setup-")); + const runtimeDirectory = join(root, "native-state"); + await mkdir(runtimeDirectory, { mode: 0o700 }); + const calls = join(root, "calls.jsonl"); + const sourceHome = join(root, "bound-account"); + await mkdir(sourceHome, { mode: 0o700 }); + await writeFile(join(sourceHome, "auth.json"), JSON.stringify({ OPENAI_API_KEY: mode === "api-key" ? "unrelated-login" : "selected-account" }), { mode: 0o600 }); + if (mode === "api-key") await writeFile(join(sourceHome, "config.toml"), 'model = "ambient-model"\n[mcp_servers.ambient]\ncommand = "untrusted-ambient-command"\n', { mode: 0o600 }); + const remote = mode.startsWith("remote-") || mode.startsWith("late-remote-"); + const remoteRoot = join(root, "assigned-remote-filesystem"); + const remoteFilesystem = join(remoteRoot, "filesystem"); + const remoteWorkspace = join(remoteFilesystem, "workspace"); + let registrationReleased = false; + let refreshed = false; + let cleanupConfirmed = false; + try { + const probe = probeNativeRunnerEnvironment({ + runtimeDirectory, provider: "codex", model: mode === "default-model" ? null : "gpt-6.1-sol", reasoningEffort: "low", + environment: { PATH: process.env.PATH, OPENAI_API_KEY: mode === "api-key" ? "selected-account" : "", HOME: "/unrelated/home" }, + timeoutMs: mode.startsWith("late-remote-") ? 800 : mode === "late-bootstrap" ? 100 : mode === "missing-completion" ? 1_000 : 10_000, + ...(remote ? { workingDirectory: remoteWorkspace } : {}), + transportOptions: { + ...(remote ? { + runnerFilesystemRoot: remoteFilesystem, runnerStateDirectory: join(remoteRoot, "runner"), + readRunnerState: async () => JSON.parse(await readFile(join(remoteRoot, "runner", "runner-state.json"), "utf8")), + controlPlaneRegistration: async authority => { + await mkdir(remoteWorkspace, { recursive: true, mode: 0o700 }); + const remoteHome = join(remoteFilesystem, "codex-home"); + await mkdir(remoteHome, { mode: 0o700 }); + for (const name of ["auth.json", "config.toml"] as const) await cp(join(runtimeDirectory, "codex-home", name), join(remoteHome, name)); + if (mode.startsWith("late-remote-")) await new Promise(resolve => setTimeout(resolve, 1000)); + await authority.start(); + return { connectUrl: authority.connectUrl, release: async () => { registrationReleased = true; await authority.stop(); if (mode === "late-remote-release-error") throw new Error("PRP registration release failed"); } }; + }, + } : {}), + runnerBinary: mode === "missing-runtime" ? join(root, "missing-runner") : resolve("runner/target/debug/paperclip-runnerd"), + codexCommand: fakeCodex, codexArgs: fakeCodexArgs(root, "--request-log", calls, + ...(mode === "missing-completion" ? ["--hold-turn"] : []), + ...(mode === "mismatched-model" || mode === "remote-mismatched-model" || mode === "model-refresh-error" || mode === "model-cleanup-error" ? ["--returned-model", "another-model"] : []), + ...(mode === "missing-model" ? ["--returned-model", ""] : []), + ...(mode === "late-bootstrap" ? ["--thread-start-delay-ms", "1000"] : [])), + sourceCodexHome: sourceHome, + }, + onCodexCredentialRefresh: async filename => { + expect(filename).toBe(join(remote ? remoteFilesystem : runtimeDirectory, "codex-home", "auth.json")); + expect(JSON.parse(await readFile(filename, "utf8"))).toEqual({ OPENAI_API_KEY: "selected-account" }); + if (mode === "api-key") { + const config = await readFile(join(runtimeDirectory, "codex-home", "config.toml"), "utf8"); + expect(config).not.toContain("ambient-model"); expect(config).not.toContain("untrusted-ambient-command"); + expect((await stat(filename)).mode & 0o777).toBe(0o600); + } + refreshed = true; + if (mode === "model-refresh-error") throw new Error("credential refresh failed after the native model error"); + }, + onCleanupConfirmed: async () => { + cleanupConfirmed = true; + expect(refreshed || mode === "missing-runtime" || mode === "late-bootstrap" || mode.startsWith("late-remote-")).toBe(true); + if (mode === "model-cleanup-error") throw new Error("owned temporary state cleanup failed"); + await rm(runtimeDirectory, { recursive: true, force: true }); + }, + }); + if (mode === "complete" || mode === "api-key" || mode === "default-model" || mode === "remote-complete") { + const observedModel = mode === "default-model" ? "gpt-test" : "gpt-6.1-sol"; + await expect(probe).resolves.toEqual({ provider: "codex", providerDriver: "codex_app_server", effectiveModel: observedModel, helloProbePassed: true }); + expect(refreshed).toBe(true); + if (remote) expect(registrationReleased).toBe(true); + expect(cleanupConfirmed).toBe(true); + await expect(stat(runtimeDirectory)).rejects.toThrow(); + const requests = (await readFile(calls, "utf8")).trim().split("\n").map(line => JSON.parse(line)); + expect(requests.find(value => value.method === "thread/start").params).toMatchObject({ ...(remote ? { cwd: remoteWorkspace } : {}), permissions: "paperclip-runner-workspace-read-only", dynamicTools: [] }); + expect(requests.find(value => value.method === "turn/start").params).toMatchObject({ effort: "low", permissions: "paperclip-runner-workspace-read-only" }); + } else { + await expect(probe).rejects.toThrow(mode === "missing-runtime" ? /ENOENT|unavailable|cleanup/i : mode === "mismatched-model" || mode === "remote-mismatched-model" || mode === "model-refresh-error" || mode === "model-cleanup-error" || mode === "missing-model" ? /observed model|mismatched provider model/i : /timed out|cleanup|suspension/i); + if (mode === "late-remote-release-error") { + expect(registrationReleased).toBe(true); expect(cleanupConfirmed).toBe(false); + expect((await stat(runtimeDirectory)).isDirectory()).toBe(true); + } + if (mode === "model-refresh-error") { + expect(refreshed).toBe(true); + expect(cleanupConfirmed).toBe(false); + expect((await stat(runtimeDirectory)).isDirectory()).toBe(true); + } + if (mode === "model-cleanup-error") { + expect(cleanupConfirmed).toBe(true); + expect((await stat(runtimeDirectory)).isDirectory()).toBe(true); + } + if (mode === "mismatched-model" || mode === "remote-mismatched-model" || mode === "missing-model") { + expect(cleanupConfirmed).toBe(true); + await expect(stat(runtimeDirectory)).rejects.toThrow(); + } + if (mode === "mismatched-model" || mode === "remote-mismatched-model" || mode === "model-refresh-error" || mode === "model-cleanup-error" || mode === "missing-model" || mode === "late-bootstrap" || mode.startsWith("late-remote-")) { + const requests = (await readFile(calls, "utf8").catch(() => "")).trim().split("\n").filter(Boolean).map(line => JSON.parse(line)); + expect(requests.some(value => value.method === "turn/start")).toBe(false); + } + } + } finally { await rm(root, { recursive: true, force: true }); } +}, 20_000); + function assignedRuntimeContext( skillRoot: string, instructionRoot: string, diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index 4d7be0fbe62..ff7dc3e8d56 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -39,6 +39,7 @@ import type { } from "../drivers/codex/app-server-transport.js"; import { createSanitizedCodexEnvironment } from "../drivers/codex/app-server-transport.js"; import { + CodexAppServerDriver, codexSemanticToolSpecs, createIsolatedCodexAppServerArgs, } from "../drivers/codex/codex-app-server-driver.js"; @@ -50,6 +51,7 @@ import type { NativeRunIdentity, NativeTurnControlCapabilities } from "../contra import type { PrpEvent } from "../protocol/replay-contract.js"; import { NativeSessionCloseUnrecoverableError } from "../contracts/native-session-backend.js"; import type { + HarnessSession, HarnessRuntimeRequestResolution, PersistedHarnessProviderIdentity, } from "../contracts/harness-driver.js"; @@ -3548,6 +3550,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { #deferredTurnStartEvents: DurableRecoveryCommittedEvent[] = []; #recoveryTurnBindingPending = false; #threadId = ""; + #observedProviderModel: string | undefined; #sessionId: string | null = null; #providerIdentity: Record<string, unknown> | null = null; #turnControls: NativeTurnControlCapabilities = { steering: false, queuedFollowUp: false }; @@ -5005,6 +5008,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { externallySandboxed: provider === "codex" && this.options.externallySandboxed === true, + ...(provider === "codex" && params.permissions === "paperclip-runner-workspace-read-only" ? { readOnly: true } : {}), instructions: provider === "codex" ? withCodexCollaborationRuntimeInstructions( @@ -5112,6 +5116,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { const provider = this.options.provider ?? "codex"; const acpxAgent = this.options.acpxAgent ?? "codex"; return { + ...(provider === "codex" ? { model: this.#observedProviderModel } : {}), thread: { id: this.#threadId, sessionId: this.#sessionId, @@ -5949,6 +5954,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { "turn.start", { text: message, + ...((this.options.provider ?? "codex") === "codex" && params.effort !== undefined ? { effort: params.effort } : {}), ...(reasoningMode === undefined ? {} : { reasoningMode }), ...(skills.length ? { skills } : {}), turnId: pendingTurnId, @@ -6579,6 +6585,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { const started = record(record(event.envelope.payload).payload); const runtimeIdentity = record(started.runtimeIdentity); const descriptor = record(started.providerDescriptor); + if (typeof descriptor.effectiveModel === "string" && descriptor.effectiveModel.trim()) this.#observedProviderModel = descriptor.effectiveModel; const { processId: pid, threadId, @@ -7029,13 +7036,16 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { } } -export function defaultCapabilityRunnerdBinary(): string { +export function defaultCapabilityRunnerdBinary(platform = process.platform, architecture = process.arch): string { // Standalone builds use dist/live; the server vendors that compiled tree // directly under vendor/paperclip-runner. Resolve beside our own live module. const outputRoot = resolve(fileURLToPath(new URL("../", import.meta.url))); - const staged = resolvePackagedRunnerBinary(outputRoot) - ?? resolvePackagedRunnerBinary(resolve(packageRoot, "dist")); + const staged = resolvePackagedRunnerBinary(outputRoot, platform, architecture) + ?? resolvePackagedRunnerBinary(resolve(packageRoot, "dist"), platform, architecture); if (staged) return staged; + if (platform !== process.platform || architecture !== process.arch) { + throw new Error(`runner_remote_artifact_unavailable: install the Paperclip release containing the ${platform}-${architecture} daemon or configure PAPERCLIP_RUNNER_REMOTE_BINARY_PATH`); + } return resolve( packageRoot, `runner/target/debug/paperclip-runnerd${executableSuffix}`, @@ -7057,6 +7067,176 @@ export function createCapabilityRunnerdCodexTransport( export const createRunnerdCodexTransport = createCapabilityRunnerdCodexTransport; +/** A setup turn uses the same daemon/provider boundary as ordinary native runs. */ +export async function probeNativeRunnerEnvironment(options: { + runtimeDirectory: string; + provider: "codex"; + model: string | null; + reasoningEffort?: string; + environment: NodeJS.ProcessEnv; + timeoutMs?: number; + /** The selected remote filesystem supplies this cwd; never inspect it on the controller. */ + workingDirectory?: string; + transportOptions?: CapabilityRunnerdCodexTransportOptions; + onCodexCredentialRefresh?: (filename: string) => Promise<void>; + onCleanupConfirmed?: () => Promise<void>; +}): Promise<{ + provider: "codex"; + providerDriver: string; + effectiveModel: string; + helloProbePassed: true; +}> { + const environment = { ...options.environment }; + const remoteFilesystemRoot = options.transportOptions?.runnerFilesystemRoot; + if (options.workingDirectory !== undefined && !remoteFilesystemRoot) throw new Error("Native setup remote working directory requires a remote runner filesystem."); + const workingDirectory = options.workingDirectory ?? resolve(options.runtimeDirectory, "workspace"); + if (!remoteFilesystemRoot) mkdirSync(workingDirectory, { mode: 0o700 }); + const controller = new AbortController(); + let registrationTeardownError: unknown; + let registrationOperation: Promise<unknown> | undefined; + const sourceCodexHome = options.transportOptions?.sourceCodexHome ?? resolveSourceCodexHome(environment); + // Never let an unbound home or an external-sandbox flag admit host state or + // weaken this read-only probe. The normal materializer copies bound auth and + // the managed provider projection into a new daemon-owned Codex home. + delete environment.PAPERCLIP_RUNNER_EXTERNAL_SANDBOX; + environment.HOME = resolve(remoteFilesystemRoot ?? options.runtimeDirectory, "probe-home"); + environment.CODEX_HOME = resolve(remoteFilesystemRoot ?? options.runtimeDirectory, "codex-home"); + environment.PAPERCLIP_WORKSPACE_CWD = workingDirectory; + const bundle = createRunnerdCodexTransport({ + ...options.transportOptions, + ...(options.transportOptions?.controlPlaneRegistration ? { controlPlaneRegistration: (authority, identity) => { + const operation = (async () => { + controller.signal.throwIfAborted(); + const registration = await options.transportOptions!.controlPlaneRegistration!(authority, identity); + if (controller.signal.aborted) { + try { await registration.release(); } + catch (error) { registrationTeardownError = error; throw error; } + controller.signal.throwIfAborted(); + } + return registration; + })(); + registrationOperation = operation; + return operation; + } } : {}), + ...(options.transportOptions?.runnerProcessLauncher ? { runnerProcessLauncher: spec => { + controller.signal.throwIfAborted(); + return options.transportOptions!.runnerProcessLauncher!(spec); + } } : {}), + provider: options.provider, + sourceCodexHome: sourceCodexHome ?? "", + stateDirectory: options.runtimeDirectory, + environment, + externallySandboxed: false, + closeGraceMs: 10_000, + lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null }, + }); + const driver = new CodexAppServerDriver({ + taskEnvelope: { + schema: "paperclip.skillless_task.v1", objective: "Verify the selected native account and model.", + completionContract: { revision: "environment-probe-v1", criteria: [] }, + constraints: [], expectedResultSchema: "paperclip.run_result.v1", + }, + ...(options.model ? { model: options.model } : {}), + ...(options.reasoningEffort ? { reasoningEffort: options.reasoningEffort } : {}), + conversationMode: "direct", + requestedCollaborationMode: "plan", + approvalPolicy: "never", + includeCollaborationModeInstructions: false, + dynamicTools: [], + environment, + transportFactory: () => bundle.transport, + requireProviderSessionIdentity: true, + ...(remoteFilesystemRoot ? { workingDirectoryAuthority: "remote_runner" as const } : {}), + }); + let rejectAborted!: (error: unknown) => void; + const aborted = new Promise<never>((_resolve, reject) => { rejectAborted = reject; }); + const timer = setTimeout(() => { + const error = new Error("Native provider hello probe timed out."); + controller.abort(error); + rejectAborted(error); + }, Math.max(1, Math.min(options.timeoutMs ?? 45_000, 120_000))); + let session: HarnessSession | undefined; + let completed = false; + let effectiveModel: string | undefined; + let failure: unknown; + const operation = (async () => { + session = await driver.openSession({ runId: "environment-probe", normalizedSessionId: "environment-probe", + workingDirectory, signal: controller.signal }); + controller.signal.throwIfAborted(); + const ids = session.ids(); + const expectedDriver = "codex_app_server"; + if (!ids.driverSessionId || !ids.providerSessionId || bundle.evidence().providerDriver !== expectedDriver) { + throw new Error(`The native environment probe returned no matching provider identity (${bundle.evidence().providerDriver ?? "missing driver"}).`); + } + const events = session.events()[Symbol.asyncIterator](); + const first = await events.next(); + const context = record(first.value?.payload.context); + if (first.value?.eventType !== "session.started" || !context.model || record(record(context.sandbox).permissionProfile).id !== "paperclip-runner-workspace-read-only") { + throw new Error("The native environment probe returned no observed model or read-only runtime identity."); + } + { + effectiveModel = typeof context.model === "string" && context.model.trim() && context.model !== "unknown" ? context.model : undefined; + if (!effectiveModel || (options.model !== null && effectiveModel !== options.model)) throw new Error("The selected native runtime returned a missing or mismatched provider model."); + } + controller.signal.throwIfAborted(); + const { turnId } = await session.startTurn({ message: { role: "user", text: "Respond only with hello. Do not use tools or inspect files." } }); + let responseObserved = false; + while (true) { + const next = await events.next(); + if (next.done) break; + const event = next.value; + if (event.turnId !== turnId) continue; + if (event.eventType === "item.completed" && event.payload.kind === "agentMessage" + && typeof event.payload.text === "string" && event.payload.text.trim()) { + responseObserved = true; + } + if (event.eventType === "turn.completed") { + if (!responseObserved || !effectiveModel) throw new Error("The native provider hello probe returned no response or observed model."); + completed = true; + return; + } + if (event.eventType === "turn.failed" || event.eventType === "turn.interrupted" || event.eventType === "turn.cancelled") { + const detail = record(event.payload.error).message; + throw new Error(typeof detail === "string" ? detail : "The native provider hello probe failed."); + } + } + throw new Error("The native provider hello probe ended without a successful turn."); + })(); + try { + await Promise.race([operation, aborted]); + } catch (error) { + failure = error; + } finally { + clearTimeout(timer); + // Preserve private recovery state if the production close cannot confirm + // teardown; callers remove it only after an authoritative receipt. + const teardownErrors: unknown[] = []; + try { + if (session) await session.close({ reason: "environment probe complete" }); + else await bundle.transport.close("environment probe complete"); + if (bundle.evidence().runnerPid !== null && !bundle.evidence().runnerExited) throw new Error("Native setup probe cleanup is incomplete."); + } catch (error) { teardownErrors.push(error); } + await operation.catch(() => undefined); + await registrationOperation?.catch(() => undefined); + if (registrationTeardownError !== undefined) teardownErrors.push(registrationTeardownError); + if (bundle.evidence().runnerPid !== null && bundle.evidence().runnerExited && options.provider === "codex" && options.onCodexCredentialRefresh) { + try { await options.onCodexCredentialRefresh(resolve(remoteFilesystemRoot ?? options.runtimeDirectory, "codex-home", "auth.json")); } + catch (error) { teardownErrors.push(error); } + } + if (!teardownErrors.length && options.onCleanupConfirmed) { + try { await options.onCleanupConfirmed(); } + catch (error) { teardownErrors.push(error); } + } + if (teardownErrors.length) { + const errors = [...(failure === undefined ? [] : [failure]), ...teardownErrors]; + throw new AggregateError(errors, errors.map(error => error instanceof Error ? error.message : "Native probe cleanup failed.").join("; "), { cause: failure ?? teardownErrors[0] }); + } + } + if (failure !== undefined) throw failure; + if (!completed) throw new Error("The native provider hello probe did not complete."); + return Object.freeze({ provider: options.provider, providerDriver: bundle.evidence().providerDriver!, effectiveModel: effectiveModel!, helloProbePassed: true }); +} + /** The transport provider identifies the biller, independently of model family. */ function openedThreadModelProvider( provider: CapabilityRunnerdCodexTransportOptions["provider"], diff --git a/scripts/__tests__/grok-public-install-sandbox.test.mjs b/scripts/__tests__/grok-public-install-sandbox.test.mjs index 71f6971d9ba..5b731c17a3f 100644 --- a/scripts/__tests__/grok-public-install-sandbox.test.mjs +++ b/scripts/__tests__/grok-public-install-sandbox.test.mjs @@ -1,16 +1,64 @@ import assert from 'node:assert/strict'; -import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; import { isWorkspacePackage, parseWorkspaceEntries } from '../build-standalone-public-packages.mjs'; -import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, assertInstalledCodexPlatformProvenance, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedCodexProbeSource } from '../grok-public-install-sandbox.mjs'; +import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, assertInstalledCodexPlatformProvenance, assertInstalledNativeConsumerContinuity, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedCodexProbeSource, installedDaemonProbeSource, macNodeHeadersDirectory, macPublicInstallLifecycleProfile, macInstalledStartupProbeSource } from '../grok-public-install-sandbox.mjs'; +import { verifyPiPackagedDaemon } from '../pi-public-install-probe.mjs'; +import { runnerBinaryTarget } from '../../packages/paperclip-runner/src/live/runner-binary.ts'; const paths = { assets: '/private/staging/assets', consumer: '/private/staging/consumer', cache: '/private/staging/cache', uid: 1001, gid: 1001 }; const values = (args, flag) => args.flatMap((value, index) => value === flag ? [args[index + 1]] : []); +test('native host startup preserves the consumer lock and both executable byte identities', () => { + const before = { lockSha256: 'a'.repeat(64), runnerSha256: 'b'.repeat(64), codexCommandSha256: 'c'.repeat(64), codexNativeExecutableSha256: 'e'.repeat(64) }; + assert.doesNotThrow(() => assertInstalledNativeConsumerContinuity(before, { ...before })); + for (const field of Object.keys(before)) { + assert.throws(() => assertInstalledNativeConsumerContinuity(before, { ...before, [field]: 'd'.repeat(64) }), new RegExp(field)); + assert.throws(() => assertInstalledNativeConsumerContinuity({ ...before, [field]: '' }, { ...before, [field]: '' }), new RegExp(field)); + } + const source = readFileSync(new URL('../verify-grok-npm-install.mjs', import.meta.url), 'utf8'); + const arm = source.split('async function verifyLinuxArmConsumer')[1].split("if (process.argv[2] === '--linux-arm64-consumer')")[0]; + assert.match(arm, /const hostEnv = \{ PATH: .*HOME: home, TMPDIR: hostTmp, LC_ALL: 'C', TZ: 'UTC' \}/); + assert.match(arm, /execFileSync\(process.execPath, \[file\], \{ cwd: consumer, env: hostEnv/); + assert.ok(arm.indexOf('isolated(GROK_PUBLIC_INSTALL_LIFECYCLE)') < arm.indexOf("hostProbe('host-startup-probe.mjs'")); + assert.ok(arm.indexOf('const before =') < arm.indexOf("hostProbe('host-startup-probe.mjs'")); + assert.ok(arm.indexOf("hostProbe('host-startup-probe.mjs'") < arm.indexOf('const after =')); + assert.match(arm, /assertInstalledNativeConsumerContinuity\(before, after\)/); + assert.doesNotMatch(arm, /externallySandboxed|externalSandbox|sudo|sysctl|--privileged|seccomp=unconfined|unshare/); +}); + +test('Mac offline lifecycle uses only the matching executing Node SDK', () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'mac-node-headers-test-'))); + try { + const prefix = join(root, 'sdk'), executable = join(prefix, 'bin/node'), include = join(prefix, 'include/node'); + mkdirSync(join(prefix, 'bin'), { recursive: true }); mkdirSync(include, { recursive: true }); + writeFileSync(executable, 'fixture Node executable'); + writeFileSync(join(include, 'node.h'), '#include "node_version.h"\n'); + const header = '#define NODE_MAJOR_VERSION 24\n#define NODE_MINOR_VERSION 20\n#define NODE_PATCH_VERSION 0\n'; + writeFileSync(join(include, 'node_version.h'), header); + assert.equal(macNodeHeadersDirectory(executable, '24.20.0'), prefix); + assert.throws(() => macNodeHeadersDirectory(executable, '24.19.0'), /must match the executing Node/); + assert.throws(() => macNodeHeadersDirectory(executable, '24.20.0-custom'), /exact release version/); + writeFileSync(join(include, 'node_version.h'), header + '#define NODE_PATCH_VERSION 0\n'); + assert.throws(() => macNodeHeadersDirectory(executable, '24.20.0'), /declare one patch version/); + writeFileSync(join(include, 'node_version.h'), header); + rmSync(join(include, 'node.h')); + assert.throws(() => macNodeHeadersDirectory(executable, '24.20.0'), /ENOENT/); + writeFileSync(join(root, 'outside-node.h'), '#include "node_version.h"\n'); + symlinkSync(join(root, 'outside-node.h'), join(include, 'node.h')); + assert.throws(() => macNodeHeadersDirectory(executable, '24.20.0'), /inside the executing Node prefix/); + const source = readFileSync(new URL('../verify-grok-npm-install.mjs', import.meta.url), 'utf8'); + assert.ok(source.includes('macNodeHeadersDirectory(process.execPath, process.versions.node)')); + assert.ok(source.includes('consumer, { ...env, npm_config_nodedir: nodeHeaders }'), 'SDK is bound only to the deferred lifecycle environment'); + assert.equal(source.split('npm_config_nodedir:').length, 2); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + test('lifecycle execution has no network, host credentials, checkout, or elevated privileges', () => { const args = grokConsumerDockerArgs({ ...paths, command: GROK_PUBLIC_INSTALL_LIFECYCLE }); assert.deepEqual(values(args, '--network'), ['none']); @@ -85,7 +133,9 @@ test('verification never elevates PR-controlled provisioning or cleanup on the h test('the image and Grok downloads start before staging and join without weakening the npm checks', () => { const source = readFileSync(new URL('../verify-grok-npm-install.mjs', import.meta.url), 'utf8'); - const position = fragment => { const index = source.indexOf(fragment); assert.notEqual(index, -1, `verifier must contain ${fragment}`); return index; }; + // The optional Mac consumer owns separate scratch and no Linux prefetches. + const linuxSource = source.slice(source.indexOf("assert.equal(process.platform, 'linux'")); + const position = fragment => { const index = linuxSource.indexOf(fragment); assert.notEqual(index, -1, `verifier must contain ${fragment}`); return index; }; // Exactly the pinned consumer image is pulled, for the platform the probes run on. const imagePull = position("prefetch('image', 'docker', ['pull', '--platform', 'linux/amd64', GROK_PUBLIC_INSTALL_IMAGE])"); // Grok is provisioned into the prefetch directory, never straight into `native/grok`. @@ -109,7 +159,7 @@ test('the image and Grok downloads start before staging and join without weakeni // cleanup waits for them before removing the directory they write into. assert.ok(source.includes("import { startPrefetch, stopPrefetches } from './prefetch-child.mjs'")); assert.ok(position('await stopPrefetches(prefetches)') < position('rmSync(root, { recursive: true, force: true })')); - assert.equal(source.match(/rmSync\(root/g).length, 1); + assert.equal(linuxSource.match(/rmSync\(root/g).length, 1); }); test('the bundled-dependency installs run together while the other packages are copied, and pack in order', () => { @@ -137,7 +187,8 @@ test('the bundled-dependency installs run together while the other packages are const checks = position("for (const tarball of tarballs) assertNoBundledCodexPayloads(run('tar', ['-tzf', tarball])"); const copied = position('for (const tarball of tarballs) {'); assert.ok(bundledSources < start && start < registered && registered < otherSources && otherSources < join && join < packs && packs < reordered && reordered < checks && checks < copied); - assert.equal(source.match(/run\('npm', \['pack'/g).length, 1, 'only the lifecycle sentinel is packed in-process'); + const packagingLane = source.slice(position("} else {\nassert.equal(process.platform, 'linux'")); + assert.equal(packagingLane.match(/run\('npm', \['pack'/g).length, 1, 'only the lifecycle sentinel is packed in-process in the packaging lane'); assert.ok(source.includes("destination: join(root, `pack-${index}`)"), 'each pack writes into its own directory'); // The timing line reports the split staging phase, each install, and the // pack step with what the packs would have cost one after another. @@ -201,6 +252,14 @@ test('the installed Codex probe exercises the public export and rejects incomple const platformPath = `node_modules/@openai/codex-${target}`; const platform = join(consumer, platformPath); mkdirSync(platform, { recursive: true }); const nativeVendor = join(platform, 'vendor'); mkdirSync(nativeVendor); + const triple = { 'linux-x64': 'x86_64-unknown-linux-musl', 'linux-arm64': 'aarch64-unknown-linux-musl', + 'darwin-x64': 'x86_64-apple-darwin', 'darwin-arm64': 'aarch64-apple-darwin', 'win32-x64': 'x86_64-pc-windows-msvc', 'win32-arm64': 'aarch64-pc-windows-msvc' }[target]; + const nativeBytes = Buffer.alloc(64); + if (process.platform === 'darwin') { nativeBytes.writeUInt32LE(0xfeedfacf); nativeBytes.writeUInt32LE(process.arch === 'arm64' ? 0x0100000c : 0x01000007, 4); } + else if (process.platform === 'win32') { nativeBytes.set([0x4d, 0x5a]); nativeBytes.writeUInt32LE(40, 60); nativeBytes.writeUInt32LE(0x00004550, 40); nativeBytes.writeUInt16LE(process.arch === 'arm64' ? 0xaa64 : 0x8664, 44); } + else { nativeBytes.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); nativeBytes.writeUInt16LE(process.arch === 'arm64' ? 183 : 62, 18); } + const nativeFile = join(nativeVendor, triple, 'bin', process.platform === 'win32' ? 'codex.exe' : 'codex'); + mkdirSync(join(nativeVendor, triple, 'bin'), { recursive: true }); writeFileSync(nativeFile, nativeBytes, { mode: 0o755 }); const manifest = { name: '@openai/codex', version: `0.160.0-${target}`, os: [process.platform], cpu: [process.arch] }; writeFileSync(join(platform, 'package.json'), JSON.stringify(manifest)); const entry = { name: manifest.name, version: manifest.version, @@ -209,6 +268,8 @@ test('the installed Codex probe exercises the public export and rejects incomple writeFileSync(join(consumer, 'package-lock.json'), JSON.stringify(lock)); const server = join(consumer, 'node_modules/@paperclipai/server'); const vendor = join(server, 'dist/vendor/paperclip-runner'), drivers = join(vendor, 'drivers/acpx'); mkdirSync(drivers, { recursive: true }); + mkdirSync(join(vendor, 'live')); + writeFileSync(join(vendor, 'live/runner-binary.js'), `export const runnerBinaryTarget = ${runnerBinaryTarget.toString()};`); writeFileSync(join(server, 'package.json'), JSON.stringify({ name: '@paperclipai/server', type: 'module' })); const integrity = join(drivers, 'installation-integrity.js'); const security = join(vendor, 'drivers/codex/codex-security-config.js'); mkdirSync(join(vendor, 'drivers/codex')); @@ -233,6 +294,24 @@ test('the installed Codex probe exercises the public export and rejects incomple assert.equal(JSON.parse(result.stdout).pinnedCodexCommandVerified, true); assert.equal(JSON.parse(result.stdout).codexPlatformPackageSource, 'official npm'); assert.equal(JSON.parse(result.stdout).codexConsumerPlatform, target); + assert.equal(JSON.parse(result.stdout).codexCommandSha256, createHash('sha256').update(readFileSync(command)).digest('hex')); + assert.equal(JSON.parse(result.stdout).codexNativeExecutableSha256, createHash('sha256').update(nativeBytes).digest('hex')); + const original = JSON.parse(result.stdout); + nativeBytes[63] = 1; writeFileSync(nativeFile, nativeBytes); + result = run(); assert.equal(result.status, 0, result.stderr); + const changed = JSON.parse(result.stdout); + assert.equal(changed.codexCommandSha256, original.codexCommandSha256, 'native mutation does not change the npm launcher'); + assert.notEqual(changed.codexNativeExecutableSha256, original.codexNativeExecutableSha256); + assert.throws(() => assertInstalledNativeConsumerContinuity({ lockSha256: 'a'.repeat(64), runnerSha256: 'b'.repeat(64), ...original }, + { lockSha256: 'a'.repeat(64), runnerSha256: 'b'.repeat(64), ...changed }), /codexNativeExecutableSha256/); + nativeBytes[63] = 0; writeFileSync(nativeFile, nativeBytes); + rmSync(nativeFile); + result = run(); assert.notEqual(result.status, 0); assert.match(result.stderr, /one native executable/); + const foreignNative = join(root, 'foreign-codex'); writeFileSync(foreignNative, nativeBytes, { mode: 0o755 }); + symlinkSync(foreignNative, nativeFile); + result = run(); assert.notEqual(result.status, 0); assert.match(result.stderr, /bounded regular file in its selected platform package/); + rmSync(nativeFile); writeFileSync(nativeFile, nativeBytes, { mode: 0o755 }); + result = run(); assert.equal(result.status, 0, result.stderr); assert.equal(JSON.parse(result.stdout).codexQualifiedInstallationVerified, true); assert.equal(JSON.parse(result.stdout).codexCommandLeaseVerified, true); assert.equal(JSON.parse(result.stdout).codexNativeSandboxResourcesVerified, true); @@ -253,6 +332,20 @@ test('the installed Codex probe exercises the public export and rejects incomple writeFileSync(legacyCommand, '#!/bin/sh\nexit 0\n', { mode: 0o755 }); writeFileSync(index, `export const resolvePinnedCodexCommand = () => ${JSON.stringify(legacyCommand)};`); result = run(); assert.notEqual(result.status, 0); assert.match(result.stderr, /Selected Codex platform version must match the qualified pin/); + // Native Codex uses protocol compatibility, not the ACPX version allowlist. + // Keep exact official-npm host provenance and the selected resource boundary. + mkdirSync(join(legacy, 'vendor')); + const legacyNative = join(legacy, 'vendor', triple, 'codex', process.platform === 'win32' ? 'codex.exe' : 'codex'); + mkdirSync(join(legacy, 'vendor', triple, 'codex'), { recursive: true }); writeFileSync(legacyNative, nativeBytes, { mode: 0o755 }); + writeFileSync(legacyCommand, '#!/bin/sh\nprintf "codex-cli 0.156.1\\n"\n', { mode: 0o755 }); + writeFileSync(security, securitySource.replaceAll(JSON.stringify(nativeVendor), JSON.stringify(join(legacy, 'vendor')))); + writeFileSync(probe, installedCodexProbeSource(index, consumer, undefined, { nativeCodex: true })); + result = run(); assert.equal(result.status, 0, result.stderr); + assert.equal(JSON.parse(result.stdout).installedCodexVersion, 'codex-cli 0.156.1'); + assert.equal(JSON.parse(result.stdout).codexQualifiedInstallationVerified, undefined); + writeFileSync(probe, installedCodexProbeSource(index, consumer, '0.160.0')); + writeFileSync(security, securitySource); + writeFileSync(index, `import { writeFileSync } from 'node:fs'; export const resolvePinnedCodexCommand = () => { writeFileSync(${JSON.stringify(join(platform, 'package.json'))}, ${JSON.stringify(JSON.stringify({ ...manifest, altered: true }))}); return ${JSON.stringify(command)}; };`); @@ -308,6 +401,7 @@ test('actual tar member checks allow Codex JavaScript but reject nested platform assert.doesNotThrow(() => assertNoBundledCodexPayloads(javascript)); for (const payload of [ 'package/node_modules/@openai/codex-linux-x64/package.json', + 'package/node_modules/@openai/codex-linux-arm64/package.json', 'package/node_modules/bridge/node_modules/@openai/codex-darwin-arm64/package.json', 'package/node_modules/@openai/codex-win32-x64/package.json', 'package/node_modules/@openai/codex/vendor/x86_64-unknown-linux-musl/codex/codex', @@ -320,24 +414,129 @@ test('actual tar member checks allow Codex JavaScript but reject nested platform }); test('consumer provenance rejects bundled, foreign, inconsistent and non-official Codex payloads', () => { - const path = 'node_modules/@openai/codex-linux-x64'; - const manifest = { name: '@openai/codex', version: '0.160.0-linux-x64', os: ['linux'], cpu: ['x64'] }; + for (const target of ['linux-x64', 'linux-arm64']) { + const path = `node_modules/@openai/codex-${target}`; + const manifest = { name: '@openai/codex', version: `0.160.0-${target}`, os: ['linux'], cpu: [target.slice('linux-'.length)] }; const entry = { version: manifest.version, resolved: `https://registry.npmjs.org/@openai/codex/-/codex-${manifest.version}.tgz`, integrity: `sha512-${Buffer.alloc(64, 1).toString('base64')}` }; - const check = (value = entry, installed = [{ path, manifest }]) => assertInstalledCodexPlatformProvenance({ packages: { [path]: value } }, installed, 'linux-x64'); + const check = (value = entry, installed = [{ path, manifest }]) => assertInstalledCodexPlatformProvenance({ packages: { [path]: value } }, installed, target); assert.equal(check().codexConsumerHostOnly, true); - const legacyManifest = { ...manifest, version: '0.156.1-linux-x64' }; + const legacyManifest = { ...manifest, version: `0.156.1-${target}` }; assert.equal(check({ ...entry, version: legacyManifest.version, - resolved: 'https://registry.npmjs.org/@openai/codex/-/codex-0.156.1-linux-x64.tgz' }, [{ path, manifest: legacyManifest }]).codexConsumerHostOnly, true); - assert.throws(() => check(entry, [{ path, manifest: { ...manifest, version: '^0.160.0-linux-x64' } }]), /exact host version/); + resolved: `https://registry.npmjs.org/@openai/codex/-/codex-0.156.1-${target}.tgz` }, [{ path, manifest: legacyManifest }]).codexConsumerHostOnly, true); + assert.throws(() => check(entry, [{ path, manifest: { ...manifest, version: `^0.160.0-${target}` } }]), /exact host version/); for (const change of [ - { inBundle: true }, { link: true }, { version: '0.156.1-linux-x64' }, - { resolved: 'https://example.com/@openai/codex/-/codex-0.160.0-linux-x64.tgz' }, - { resolved: 'https://registry.npmjs.org/@openai/codex/-/codex-0.156.1-linux-x64.tgz' }, - { resolved: 'https://user@registry.npmjs.org/@openai/codex/-/codex-0.160.0-linux-x64.tgz' }, + { inBundle: true }, { link: true }, { version: `0.156.1-${target}` }, + { resolved: `https://example.com/@openai/codex/-/codex-0.160.0-${target}.tgz` }, + { resolved: `https://registry.npmjs.org/@openai/codex/-/codex-0.156.1-${target}.tgz` }, + { resolved: `https://user@registry.npmjs.org/@openai/codex/-/codex-0.160.0-${target}.tgz` }, { integrity: undefined }, { integrity: 'sha512-AA==' }, ]) assert.throws(() => check({ ...entry, ...change })); assert.throws(() => check(entry, []), /must install its host/); assert.throws(() => check(entry, [{ path: 'node_modules/@openai/codex-darwin-arm64', manifest }]), /only the host/); assert.throws(() => check(entry, [{ path, manifest: { ...manifest, name: 'untrusted' } }]), /identity/); + } +}); + + +test('hosted Mac lifecycle denies OS networking and writes outside owned scratch', () => { + const profile = macPublicInstallLifecycleProfile('/Users/runner/work/_temp/owned-check'); + assert.match(profile, /\(deny network\*\)/); + assert.match(profile, /\(deny file-write\*/); + assert.match(profile, /subpath "\/Users\/runner\/work\/_temp\/owned-check"/); + assert.throws(() => macPublicInstallLifecycleProfile('relative'), /absolute/); + assert.throws(() => macPublicInstallLifecycleProfile('/tmp/owned\0bad'), /absolute/); + const source = readFileSync(new URL('../verify-grok-npm-install.mjs', import.meta.url), 'utf8'); + assert.match(source, /process.env.GITHUB_ACTIONS, 'true'/); + assert.match(source, /networkProbe, 'allowed'/); + assert.match(source, /networkProbe, 'denied'/); + assert.match(source, /error.code,'EPERM'/); + assert.match(source, /'rebuild', '--offline', '--ignore-scripts=false', '--dangerously-allow-all-scripts'/); + assert.match(source, /readFileSync\(sentinel, 'utf8'\), 'ok'/); + assert.match(source, /readFileSync\(join\(consumer, 'package-lock.json'\), 'utf8'\), lock/); + assert.match(source, /finally \{ rmSync\(root, \{ recursive: true, force: true \}\); \}/); +}); + +test('installed daemon proof uses the production host resolver and complete source-bound manifest', () => { + const source = installedDaemonProbeSource('/consumer/server/dist/vendor/paperclip-runner', '/consumer', 'a'.repeat(40)); + assert.match(source, /defaultCapabilityRunnerdBinary\(\)/); + assert.match(source, /Object.keys\(manifest.platforms\).sort\(\), targets.sort\(\)/); + assert.match(source, /manifest.sourceRevision, "a{40}"/); + assert.match(source, /runnerBinaryTarget\(bytes\), target/); + assert.match(source, /createHash\('sha256'\)/); + assert.match(source, /metadata.binaryContractVersion, expected.contracts.runnerdArtifact/); + assert.match(source, /'linux-arm64'/); + assert.doesNotMatch(source, /PAPERCLIP_RUNNERD_BINARY|runner\/target\/debug|execFileSync\('paperclip-runnerd'/); +}); + +test('native ARM clean consumers keep the existing container and installed-runtime boundaries', () => { + const args = grokConsumerDockerArgs({ ...paths, platform: 'linux/arm64', command: GROK_PUBLIC_INSTALL_LIFECYCLE }); + assert.deepEqual(values(args, '--platform'), ['linux/arm64']); + assert.deepEqual(values(args, '--network'), ['none']); + assert.deepEqual(values(args, '--user'), ['1001:1001']); + assert.ok(args.includes('--read-only')); + assert.deepEqual(values(args, '--cap-drop'), ['ALL']); + assert.deepEqual(values(args, '--security-opt'), ['no-new-privileges']); + assert.ok(values(args, '--mount').some(value => value.endsWith('dst=/packages,readonly'))); + assert.throws(() => grokConsumerDockerArgs({ ...paths, platform: 'linux/arm/v7', command: [] }), /supported native Linux platform/); + const source = readFileSync(new URL('../verify-grok-npm-install.mjs', import.meta.url), 'utf8'); + const arm = source.split('async function verifyLinuxArmConsumer(')[1].split("if (process.argv[2] === '--linux-arm64-consumer')")[0]; + assert.match(arm, /process.platform \+ '-' \+ process.arch, 'linux-arm64'/); + assert.match(arm, /const platform = 'linux\/arm64'/); + assert.match(arm, /isolated\(GROK_PUBLIC_INSTALL_LIFECYCLE\)/); + assert.match(arm, /installedCodexProbeSource\(installed \+ '\/index.js', '\/consumer', undefined, \{ nativeCodex: true \}\)/); + assert.match(arm, /tests\/runner-e2e\/installed-cli.ts/); + assert.match(arm, /process.versions.node.split\('\.'\)\[0\], '24'/); + assert.match(arm, /macInstalledStartupProbeSource\(consumer, hostServer, daemon.sha256, join\(assets, 'installed-cli.ts'\)\)/); + assert.match(arm, /startupEnvironment: 'native host, same installed graph, isolated empty homes'/); + assert.doesNotMatch(arm, /stage-runner|runner\/target|PAPERCLIP_RUNNER_BINARY|prerequisite:/); +}); + + +test('Mac startup wiring normalizes only validated manifest digests and uses server-scoped tooling', () => { + const source = macInstalledStartupProbeSource('/owned/consumer', '/owned/consumer/server', 'sha256:' + 'a'.repeat(64), '/source/tests/runner-e2e/installed-cli.ts'); + assert.match(source, /expectedRunnerSha256:"a{64}"/); + assert.doesNotMatch(source, /expectedRunnerSha256:"sha256:/); + for (const digest of ['a'.repeat(64), 'sha256:bad', 'sha512:' + 'a'.repeat(64), 'sha256:' + 'A'.repeat(64)]) { + assert.throws(() => macInstalledStartupProbeSource('/owned/consumer', '/owned/consumer/server', digest, '/source/helper.ts'), /verified SHA-256/); + } + const verifier = readFileSync(new URL('../verify-grok-npm-install.mjs', import.meta.url), 'utf8'); + assert.match(verifier, /\['--import', 'tsx', startupFile\], join\(repo, 'server'\)/); + const closedEnvironment = verifier.match(/const env = \{ PATH: `\$\{tools\}[\s\S]*?\};/)[0]; + assert.doesNotMatch(closedEnvironment, /NODE_PATH|NODE_OPTIONS|\.\.\.process.env/); +}); + +test('Pi admission verifies assembled host daemon identity and preserves the flat source-package control', async () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'pi-installed-daemon-'))), server = join(root, 'server'); + const output = join(server, 'dist/vendor/paperclip-runner'), target = 'linux-x64', source = 'a'.repeat(40); + const binary = join(output, 'bin', target, 'paperclip-runnerd'), manifestPath = join(output, 'bin/release-manifest.json'); + const bytes = Buffer.alloc(64); bytes.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); bytes.writeUInt16LE(62, 18); + const sha256 = value => `sha256:${createHash('sha256').update(value).digest('hex')}`; + const manifest = { schema: 'paperclip.runner.release-binaries.v1', sourceRevision: source, + platforms: { [target]: { path: `${target}/paperclip-runnerd`, sha256: sha256(bytes) } } }; + const check = () => verifyPiPackagedDaemon(server, binary, target, runnerBinaryTarget); + try { + mkdirSync(join(output, 'bin', target), { recursive: true }); + writeFileSync(binary, bytes); writeFileSync(join(server, 'dist/build-info.json'), JSON.stringify({ commit: source })); + writeFileSync(manifestPath, JSON.stringify(manifest)); + await check(); + for (const changed of [null, { ...manifest, schema: 'untrusted' }, { ...manifest, sourceRevision: 'b'.repeat(40) }, + { ...manifest, platforms: { [target]: { ...manifest.platforms[target], path: '../outside' } } }, + { ...manifest, platforms: { [target]: { ...manifest.platforms[target], sha256: 'sha256:' + '0'.repeat(64) } } }]) { + writeFileSync(manifestPath, JSON.stringify(changed)); await assert.rejects(check()); + } + const wrongHeader = Buffer.from(bytes); wrongHeader.writeUInt16LE(183, 18); + writeFileSync(binary, wrongHeader); + writeFileSync(manifestPath, JSON.stringify({ ...manifest, platforms: { [target]: { ...manifest.platforms[target], sha256: sha256(wrongHeader) } } })); + await assert.rejects(check(), /linux-arm64/); + writeFileSync(binary, bytes); writeFileSync(manifestPath, JSON.stringify(manifest)); + const outside = join(root, 'outside'); writeFileSync(outside, bytes); + rmSync(binary); symlinkSync(outside, binary); await assert.rejects(check()); + rmSync(binary); writeFileSync(binary, bytes); rmSync(manifestPath); + const flat = join(output, 'bin/paperclip-runnerd'); writeFileSync(flat, bytes); + await verifyPiPackagedDaemon(server, flat, target, runnerBinaryTarget); + await assert.rejects(check(), /strictly equal/); + writeFileSync(manifestPath, 'null'); + await assert.rejects(verifyPiPackagedDaemon(server, flat, target, runnerBinaryTarget), /manifest must be an object/); + } finally { rmSync(root, { recursive: true, force: true }); } }); diff --git a/scripts/__tests__/release-verify-workflow.test.mjs b/scripts/__tests__/release-verify-workflow.test.mjs index 9d00a15b79e..2aa415cd205 100644 --- a/scripts/__tests__/release-verify-workflow.test.mjs +++ b/scripts/__tests__/release-verify-workflow.test.mjs @@ -1,8 +1,10 @@ import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { spawn, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; +import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import test from "node:test"; import { createServer } from "node:http"; @@ -81,14 +83,14 @@ test("chaos verification isolates callers that verify the same source commit", ( test("canary reuses exact-source proof while stable keeps full verification", () => { const releaseWorkflow = readWorkflow("release.yml"); - const canary = releaseWorkflow.split(" verify_canary:\n")[1].split("\n publish_canary:")[0]; + const canary = releaseWorkflow.match(/ verify_canary:\n[\s\S]*?(?=\n [a-z_]+:)/)[0]; assert.match(canary, /github\.repository == 'paperclipai\/paperclip' && github\.event_name == 'push' && github\.ref == 'refs\/heads\/master'/); assert.match(canary, /actions: read/); assert.match(canary, /ref: \$\{\{ github\.sha \}\}/); assert.match(canary, /SOURCE_SHA: \$\{\{ github\.sha \}\}/); assert.match(canary, /run: node scripts\/cloud-source-verification\.mjs "\$SOURCE_SHA"/); assert.doesNotMatch(canary, /release-verify\.yml|continue-on-error|always\(\)/); - assert.match(releaseWorkflow, /publish_canary:\n\s+if: github\.event_name == 'push'\n\s+needs: verify_canary/); + assert.match(releaseWorkflow, /publish_canary:\n\s+if: .*github\.event_name == 'push'.*\n\s+needs: \[verify_canary, release_runner_source, release_runner_binaries\]/); // The stable lane is gated on the stable channel since the nightly lane // was added; a `needs:` line (for example a preflight job) may sit between // the gate and the delegation. @@ -742,6 +744,399 @@ test("direct protocol concurrency override only lowers the configured ceiling", } }); +test("release daemon production uses exact-source native hosts and no publisher or provider permissions", () => { + const workflow = readWorkflow("release-verify.yml"); + const producer = workflow.split(" runner_binaries:\n")[1]; + const targets = workflow.split(" runner_binary_targets:\n")[1].split(" runner_binaries:\n")[0]; + for (const [target, host] of [["linux-arm64", "ubuntu-24.04-arm"], ["darwin-arm64", "macos-15"], ["darwin-x64", "macos-15-intel"]]) { + assert.ok(targets.includes(`'${target}': '${host}'`)); + } + assert.match(producer, /matrix: \$\{\{ fromJSON\(needs.runner_binary_targets.outputs.matrix\) \}\}/); + assert.match(targets, /Unknown exact-source daemon platform contract/); + assert.match(targets, /RELEASE_RUNNER_TARGETS/); + assert.doesNotMatch(targets, /secrets\.|id-token: write|contents: write|npm publish|environment:/); + assert.match(producer, /persist-credentials: false/); + assert.match(producer, /contents: read/); + assert.doesNotMatch(producer, /secrets\.|id-token: write|contents: write|npm publish|environment:/); + assert.match(producer, /\[\[ "\$SOURCE_SHA" =~ \^\[a-f0-9\]\{40\}\$ \]\]/); + assert.match(producer, /git rev-parse HEAD/); + assert.match(producer, /process.platform \+ "-" \+ process.arch/); + assert.match(producer, /\['native', \['build:binary'\]/); + const runner = JSON.parse(readFileSync(path.join(repoRoot, 'packages/paperclip-runner/package.json'), 'utf8')); + assert.match(runner.scripts['build:binary'], /cargo build --release --manifest-path runner\/Cargo.toml --locked/); + assert.match(runner.scripts['build:binary'], /&& node scripts\/stage-runner-binary.mjs$/); + assert.match(producer, /stage-release-runner-binaries.mjs record/); + for (const job of ["runner_chaos_evals", "typecheck", "general_tests", "serialized_tests", "runner_workflow_evals", "verify_paperclip_runner", "build"]) { + assert.ok(workflow.includes(`${job}:\n if: \${{ !inputs.runner_binaries_only }}`)); + } + const release = readWorkflow("release.yml"); + for (const job of ["publish_canary", "publish_nightly", "publish_beta", "preview_stable", "publish_stable"]) { + const block = release.split(` ${job}:\n`)[1].split(/\n [a-z_]+:\n/)[0]; + assert.match(block, /needs: .*release_runner_binaries/); + assert.match(block, /pattern: \$\{\{ needs.release_runner_binaries.outputs.runner_artifact_prefix \}\}-\*/); + assert.match(block, /PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR/); + } + const script = readFileSync(path.join(repoRoot, 'scripts/release.sh'), 'utf8'); + assert.ok(script.indexOf('assemble-daemons "$PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR"') < script.indexOf('scripts/build-standalone-public-packages.mjs')); + assert.match(script, /cp -R .*paperclip-runner\/dist\/bin\/\." .*server\/dist\/vendor\/paperclip-runner\/bin\//); + assert.match(script, /elif \[ "\$dry_run" = false \]; then[\s\S]*?release_fail "Public releases require/); +}); + +function nativeProducer() { + return readWorkflow('release-verify.yml').split(' runner_binaries:\n')[1]; +} + +test('native dependency caches are target-bound and unmerged master callers cannot save them', () => { + const producer = nativeProducer(); + const cache = producer.split(' - name: Cache native Rust dependencies\n')[1].split('\n - ')[0]; + assert.match(cache, /Swatinem\/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6/); + assert.match(cache, /workspaces: \$\{\{ steps.native_rust_workspace.outputs.path \}\} -> target/); + assert.match(cache, /shared-key: release-native-v2-\$\{\{ matrix.host \}\}-\$\{\{ matrix.target \}\}-\$\{\{ hashFiles\('packages\/paperclip-runner\/protocol\/\*\*', 'packages\/paperclip-runner\/src\/\*\*', 'packages\/paperclip-runner\/\*.json'\) \}\}/); + for (const option of ['cache-workspace-crates', 'cache-bin', 'cache-on-failure']) { + assert.match(cache, new RegExp(option + ': false')); + } + const expression = cache.match(/save-if: \$\{\{ (.+) \}\}/)?.[1]; + assert.ok(expression, 'cache writes need a separate source trust gate'); + const canSave = new Function('github', 'inputs', `return (${expression});`); + const github = { repository: 'paperclipai/paperclip', repository_id: '1170821064', event_name: 'push', ref: 'refs/heads/master', sha: 'a'.repeat(40) }; + assert.equal(canSave(github, { ref: github.sha }), true); + for (const [overrides, input] of [ + [{}, 'b'.repeat(40)], [{}, ''], + [{ event_name: 'workflow_dispatch' }, github.sha], + [{ event_name: 'workflow_dispatch' }, 'b'.repeat(40)], + [{ event_name: 'pull_request', ref: 'refs/pull/123/merge' }, github.sha], + [{ ref: 'refs/heads/topic' }, github.sha], + [{ repository: 'fork/paperclip' }, github.sha], + [{ repository_id: '1' }, github.sha], + ]) assert.equal(canSave({ ...github, ...overrides }, { ref: input }), false); + assert.match(producer, /pinned="\$HOME\/paperclip-runner-rust"/); + assert.match(producer, /ln -s "\$GITHUB_WORKSPACE\/packages\/paperclip-runner\/runner" "\$pinned"/); + assert.match(producer, /echo "RUSTUP_TOOLCHAIN=\$toolchain" >> "\$GITHUB_ENV"/); + assert.doesNotMatch(producer, /cache-hit|continue-on-error/); + assert.ok(producer.indexOf('Bind immutable producer source and target') < producer.indexOf('Cache native Rust dependencies')); + assert.ok(producer.indexOf('Build TypeScript contract and host daemon concurrently') < producer.indexOf('Record actual daemon metadata and immutable bytes')); +}); + +function nativePreparationScript() { + const step = nativeProducer().split(' - name: Build TypeScript contract and host daemon concurrently\n')[1].split('\n - ')[0]; + const script = step.match(/node --input-type=module <<'NODE'\n([\s\S]*?)\n NODE/)?.[1]; + assert.ok(script); + return script.replace(/^ {10}/gm, ''); +} + +const fixtureProcessGone = pid => { + try { process.kill(pid, 0); } catch (error) { return error.code === 'ESRCH'; } + try { return /^\d+ \(.*\) Z /.test(readFileSync(`/proc/${pid}/stat`, 'utf8')); } catch { return false; } +}; +const waitForFixture = async (predicate, label) => { + for (let attempt = 0; attempt < 200; attempt++) { + if (predicate()) return; + await new Promise(resolve => setTimeout(resolve, 25)); + } + assert.fail(label); +}; + +// Execute the workflow's actual coordinator with two tiny local Node commands. +// Both must cross the start barrier before either can finish; no pnpm, Cargo, +// provider, registry, or Docker build runs in these fixtures. +async function withNativePreparationFixture(mode, check) { + const root = realpathSync(mkdtempSync(path.join(tmpdir(), 'native-preparation-'))); + let controller; + try { + for (const directory of ['scripts', 'tools', 'packages/paperclip-runner', 'logs']) { + mkdirSync(path.join(root, directory), { recursive: true }); + } + for (const helper of ['prefetch-child.mjs', 'prefetch-supervisor.mjs']) { + copyFileSync(path.join(repoRoot, 'scripts', helper), path.join(root, 'scripts', helper)); + } + writeFileSync(path.join(root, 'prepare.mjs'), nativePreparationScript()); + writeFileSync(path.join(root, 'tools/pnpm'), `#!${process.execPath} +const { spawn } = require('node:child_process'); +const { existsSync, writeFileSync } = require('node:fs'); +const { join } = require('node:path'); +const root = process.env.FIXTURE_ROOT; +const name = process.argv.includes('build:binary') ? 'native' : 'typescript'; +writeFileSync(join(root, name + '.started'), JSON.stringify({ pid: process.pid, cwd: process.cwd(), args: process.argv.slice(2) })); +const wait = setInterval(() => { + if (!existsSync(join(root, 'native.started')) || !existsSync(join(root, 'typescript.started'))) return; + clearInterval(wait); + if (process.env.FIXTURE_MODE === 'success') { + setTimeout(() => { writeFileSync(join(root, name + '.finished'), 'joined'); console.log(name + ' complete'); }, 150); + return; + } + const child = spawn(process.execPath, ['-e', \ + "const fs=require('node:fs'); fs.writeFileSync(process.argv[1], String(process.pid)); setInterval(() => fs.writeFileSync(process.argv[2], String(Date.now())), 25)",\ + join(root, name + '.descendant'), join(root, name + '.writing')], { stdio: 'inherit' }); + child.on('exit', () => process.exit(0)); + const barrier = setInterval(() => { + if (!existsSync(join(root, 'native.descendant')) || !existsSync(join(root, 'typescript.descendant'))) return; + clearInterval(barrier); + if (process.env.FIXTURE_MODE === name) { console.error(name + ' failed'); process.exit(23); } + }, 10); +}, 10); +`, { mode: 0o755 }); + controller = spawn(process.execPath, ['prepare.mjs'], { cwd: root, env: { + ...process.env, PATH: `${path.join(root, 'tools')}:${process.env.PATH}`, RUNNER_TEMP: path.join(root, 'logs'), FIXTURE_ROOT: root, FIXTURE_MODE: mode, + }, stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = '', stderr = ''; + controller.stdout.on('data', chunk => { stdout += chunk; }); + controller.stderr.on('data', chunk => { stderr += chunk; }); + const completed = new Promise((resolve, reject) => { + controller.once('error', reject); + controller.once('close', (status, signal) => resolve({ status, signal, stdout, stderr })); + }); + await check({ root, controller, completed }); + } finally { + if (controller?.exitCode === null && controller.signalCode === null) controller.kill('SIGKILL'); + for (const name of ['native', 'typescript']) { + for (const filename of [name + '.started', name + '.descendant']) { + const file = path.join(root, filename); + if (!existsSync(file)) continue; + const data = readFileSync(file, 'utf8'); + const pid = filename.endsWith('.started') ? JSON.parse(data).pid : Number(data); + if (!fixtureProcessGone(pid)) { + try { process.kill(pid, 'SIGKILL'); } catch (error) { if (error.code !== 'ESRCH') throw error; } + } + } + } + rmSync(root, { recursive: true, force: true }); + } +} + +test('native preparation overlaps independent outputs and joins both before recording', async () => { + await withNativePreparationFixture('success', async ({ root, completed }) => { + const result = await completed; + assert.equal(result.status, 0, result.stderr); + for (const name of ['native', 'typescript']) assert.equal(readFileSync(path.join(root, name + '.finished'), 'utf8'), 'joined'); + assert.equal(JSON.parse(readFileSync(path.join(root, 'native.started'), 'utf8')).cwd, path.join(root, 'packages/paperclip-runner')); + assert.deepEqual(JSON.parse(readFileSync(path.join(root, 'native.started'), 'utf8')).args, ['build:binary']); + assert.deepEqual(JSON.parse(readFileSync(path.join(root, 'typescript.started'), 'utf8')).args, ['--filter', '@paperclipai/paperclip-runner', 'build:typescript']); + const results = result.stdout.split('\n').filter(line => line.startsWith('{')).map(line => JSON.parse(line)); + assert.deepEqual(results.map(item => [item.build, item.status, item.exitCode]), [['typescript', 'fulfilled', 0], ['native', 'fulfilled', 0]]); + }); +}); + +for (const name of ['typescript', 'native']) { + test(`${name} preparation failure fails the join and retires both owned process trees`, async () => { + await withNativePreparationFixture(name, async ({ root, completed }) => { + const result = await completed; + assert.equal(result.status, 1, result.stderr); + assert.match(result.stderr, new RegExp(name + ' prefetch failed \\(exit 23\\)')); + const results = result.stdout.split('\n').filter(line => line.startsWith('{')).map(line => JSON.parse(line)); + assert.equal(results.length, 2); + assert.ok(results.every(item => item.status === 'rejected')); + for (const build of ['native', 'typescript']) { + const pid = Number(readFileSync(path.join(root, build + '.descendant'), 'utf8')); + await waitForFixture(() => fixtureProcessGone(pid), build + ' descendant outlived failed join'); + } + }); + }); +} + +test('canceling native preparation retires both supervisors and their descendants', { skip: process.platform === 'win32' }, async () => { + await withNativePreparationFixture('cancel', async ({ root, controller, completed }) => { + await waitForFixture(() => ['native', 'typescript'].every(name => existsSync(path.join(root, name + '.descendant'))), 'both native build trees must start'); + controller.kill('SIGTERM'); + const result = await completed; + assert.equal(result.signal, 'SIGTERM'); + for (const name of ['native', 'typescript']) { + const pid = Number(readFileSync(path.join(root, name + '.descendant'), 'utf8')); + await waitForFixture(() => fixtureProcessGone(pid), name + ' descendant outlived cancellation'); + } + }); +}); + +test("premerge native qualification consumes the same Linux-packed npm bytes without publishing", () => { + const smoke = readWorkflow("release-smoke.yml"); + assert.match(smoke, /smoke_service:\n\s+if: inputs.qualification_source_sha == ''/); + assert.match(smoke, /smoke:\n\s+if: inputs.qualification_source_sha == ''/); + const qualification = smoke.split(" qualification_daemons:\n")[1]; + assert.doesNotMatch(qualification, /secrets\.|npm publish|id-token: write|contents: write|continue-on-error/); + assert.match(qualification, /runner_binaries_only: true/); + assert.match(qualification, /PAPERCLIP_RUNNER_PUBLIC_PACKS_DIR/); + assert.match(qualification, /if: \$\{\{ !cancelled\(\) && steps.pack_identity.outputs.available == 'true' \}\}/); + assert.match(qualification, /name: \$\{\{ inputs.artifact_name \}\}-public-packages/); + assert.match(qualification, /--mac-consumer/); + assert.match(qualification, /--linux-arm64-consumer/); + assert.match(qualification, /fromJSON\(needs.qualification_daemons.outputs.native_consumer_matrix\)/); + assert.match(qualification, /pnpm install --ignore-scripts --no-frozen-lockfile/); +}); + +test("native ARM qualification prepares its scoped distro sandbox before the existing consumer", () => { + const smoke = readWorkflow("release-smoke.yml"); + const native = smoke.split(" qualification_native:\n")[1]; + const preparation = native.split(" - name: Prepare Linux ARM sandbox prerequisites\n")[1].split("\n - ")[0]; + assert.match(preparation, /if: \$\{\{ matrix\.target == 'linux-arm64' \}\}/); + assert.match(preparation, /timeout-minutes: 5/); + assert.match(preparation, /bash \.qualification-tooling\/scripts\/prepare-linux-arm64-qualification\.sh/); + const tooling = native.split(" - name: Checkout immutable ARM qualification tooling\n")[1].split("\n - ")[0]; + assert.match(tooling, /if: \$\{\{ matrix\.target == 'linux-arm64' \}\}/); + assert.match(tooling, /repository: \$\{\{ job\.workflow_repository \}\}/); + assert.match(tooling, /ref: \$\{\{ job\.workflow_sha \}\}/); + assert.match(tooling, /sparse-checkout: scripts\/prepare-linux-arm64-qualification\.sh/); + assert.match(tooling, /sparse-checkout-cone-mode: false/); + assert.match(tooling, /persist-credentials: false/); + assert.doesNotMatch(tooling + preparation, /github\.workflow_sha|github\.sha|ref: (?:master|main)|ref: \$\{\{ inputs\.qualification_source_sha/); + assert.ok(native.indexOf("Checkout immutable ARM qualification tooling") < native.indexOf("Prepare Linux ARM sandbox prerequisites")); + assert.ok(native.indexOf("Prepare Linux ARM sandbox prerequisites") < native.indexOf("Verify host identity and actual clean npm consumer")); + assert.match(native, /path: \|\n\s+\$\{\{ runner\.temp \}\}\/native-public-install\.json\n\s+\$\{\{ runner\.temp \}\}\/paperclip-arm-bwrap-prerequisite\//); + assert.doesNotMatch(smoke.split(" qualification_daemons:\n")[0], /prepare-linux-arm64-qualification/, + "ordinary service and Docker smoke keep their existing setup"); +}); + +test("ARM qualification binds separate product and reusable-workflow revisions even when the product lacks the helper", { skip: process.platform === "win32" }, () => { + const native = readWorkflow("release-smoke.yml").split(" qualification_native:\n")[1]; + const step = native.split(" - name: Prepare Linux ARM sandbox prerequisites\n")[1].split("\n - ")[0]; + const script = step.split(" run: |\n")[1].split("\n").map(line => line.startsWith(" ") ? line.slice(10) : line).join("\n"); + const root = mkdtempSync(path.join(tmpdir(), "arm-separate-tooling-")); + try { + const tools = path.join(root, "tools"); mkdirSync(tools); + const tooling = path.join(root, ".qualification-tooling", "scripts"); mkdirSync(tooling, { recursive: true }); + const helper = 'printf "%s\\n" "$TOOLING_SHA" > "$FIXTURE_PREPARED"\n'; + writeFileSync(path.join(tooling, "prepare-linux-arm64-qualification.sh"), helper); + writeFileSync(path.join(tools, "git"), '#!/bin/bash\nif [[ "$1" == -C ]]; then printf "%s\\n" "$FIXTURE_TOOLING_HEAD"; else printf "%s\\n" "$FIXTURE_PRODUCT_HEAD"; fi\n', { mode: 0o755 }); + const productSha = "a".repeat(40), toolingSha = "b".repeat(40); + for (const [productHead, toolingHead, expectedStatus] of [[productSha, toolingSha, 0], ["c".repeat(40), toolingSha, 1], [productSha, "c".repeat(40), 1], [productSha, "invalid", 1]]) { + const report = path.join(root, "report"); mkdirSync(report); + const prepared = path.join(root, "prepared"); + const result = spawnSync("/bin/bash", ["-c", script], { cwd: root, encoding: "utf8", env: { + PATH: `${tools}:${path.dirname(process.execPath)}:/usr/bin:/bin`, RUNNER_TEMP: report, + SOURCE_SHA: productSha, TOOLING_SHA: toolingSha, TOOLING_REPOSITORY: "paperclipai/paperclip", + TOOLING_WORKFLOW_REF: "paperclipai/paperclip/.github/workflows/release-smoke.yml@refs/heads/qualification", + TOOLING_WORKFLOW_PATH: ".github/workflows/release-smoke.yml", + FIXTURE_PRODUCT_HEAD: productHead, FIXTURE_TOOLING_HEAD: toolingHead, FIXTURE_PREPARED: prepared, + // Reusable calls retain the caller context; it must not select tooling. + GITHUB_REPOSITORY: "different/caller", GITHUB_SHA: "d".repeat(40), + } }); + assert.equal(result.status, expectedStatus, result.stderr); + assert.equal(existsSync(path.join(root, "scripts", "prepare-linux-arm64-qualification.sh")), false); + assert.equal(existsSync(prepared), expectedStatus === 0); + if (expectedStatus === 0) { + assert.equal(readFileSync(prepared, "utf8").trim(), toolingSha); + const identity = JSON.parse(readFileSync(path.join(report, "paperclip-arm-bwrap-prerequisite", "qualification-identity.json"), "utf8")); + assert.equal(identity.productSourceSha, productSha); + assert.equal(identity.toolingSourceSha, toolingSha); + assert.equal(identity.toolingRepository, "paperclipai/paperclip"); + assert.equal(identity.toolingWorkflowPath, ".github/workflows/release-smoke.yml"); + assert.equal(identity.helperSha256, createHash("sha256").update(helper).digest("hex")); + } + rmSync(report, { recursive: true, force: true }); + rmSync(prepared, { force: true }); + } + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("Linux ARM prerequisite setup rejects other hosts before invoking tools and preserves scoped security", { skip: process.platform === "win32" }, () => { + const helper = path.join(repoRoot, "scripts/prepare-linux-arm64-qualification.sh"); + const source = readFileSync(helper, "utf8"); + const root = mkdtempSync(path.join(tmpdir(), "arm-prerequisite-guard-")); + try { + const tools = path.join(root, "tools"); mkdirSync(tools); + const sentinel = path.join(root, "invoked"); + for (const command of ["dpkg", "uname", "mkdir", "mktemp", "sysctl", "sudo"]) { + writeFileSync(path.join(tools, command), '#!/bin/bash\nprintf "%s\\n" "$0" >> "$PREREQUISITE_SENTINEL"\nexit 85\n', { mode: 0o755 }); + } + for (const [actions, runnerOs] of [["", "Linux"], ["false", "Linux"], ["true", "macOS"], ["true", "Windows"]]) { + const result = spawnSync("/bin/bash", [helper], { encoding: "utf8", env: { + PATH: tools, GITHUB_ACTIONS: actions, RUNNER_OS: runnerOs, RUNNER_TEMP: root, PREREQUISITE_SENTINEL: sentinel, + } }); + assert.equal(result.status, 1); + assert.match(result.stderr, /requires a GitHub Actions Linux runner/); + assert.equal(existsSync(sentinel), false, "host guard must precede every external tool"); + assert.equal(existsSync(path.join(root, "paperclip-arm-bwrap-prerequisite")), false); + } + assert.ok(source.indexOf('"${GITHUB_ACTIONS:-}" != true') < source.indexOf(". /etc/os-release")); + assert.match(source, /-o Acquire::AllowInsecureRepositories=false/); + assert.match(source, /download "\$package_name=\$package_version"/); + assert.match(source, /printf '%s %s\\n' "\$package_sha256" "\$package_file" \| sha256sum -c -/); + assert.match(source, /dpkg-deb --fsys-tarfile "\$package_file"/); + assert.match(source, /tar -xO \.\/usr\/bin\/bwrap/); + assert.match(source, /11d39094f044f0cda0febb3ad517b830301da6b2ce929664af09ee9e4dd264f9/); + assert.ok(source.indexOf('test ! -L "$target"') < source.indexOf('sudo install -o root -g root -m 0644 "$stock_profile" "$target"')); + assert.match(source, /timeout 15s sudo "\$parser" -r "\$target"/); + for (const setting of ["userns", "apparmor", "clone"]) assert.ok(source.includes(`test "$after_${setting}" = "$before_${setting}"`)); + const aptCommands = source.replace(/\\\n\s*/g, " ").split("\n").filter(line => /\bapt-get\b/.test(line)); + assert.equal(aptCommands.length, 2, "only signed-index update and download may invoke APT"); + assert.match(aptCommands[0], /\bupdate\b/); + assert.match(aptCommands[1], /\bdownload\b/); + for (const command of aptCommands) assert.doesNotMatch(command, /\b(?:install|upgrade|remove|purge)\b/); + assert.doesNotMatch(source, /apt-get[^\n]* install|\bdpkg(?:-deb)?\s+(?:-i|--install|--unpack)|sysctl\s+(?:-w|--system)|\baa-disable\b|\bsetcap\b|systemctl|--no-sandbox/); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("exact-source daemon matrices preserve historical three-target sources and add native ARM only for new sources", () => { + const workflow = readWorkflow("release-verify.yml"); + const step = workflow.split(' - name: Preserve the immutable source\'s platform contract')[1].split('\n runner_binaries:')[0]; + const script = step.split(' run: |\n')[1].split('\n').map(line => line.startsWith(' ') ? line.slice(10) : line).join('\n'); + const root = mkdtempSync(path.join(tmpdir(), 'release-target-contract-')); + try { + const tools = path.join(root, 'tools'); mkdirSync(tools); + writeFileSync(path.join(tools, 'git'), '#!/bin/sh\nprintf "%s\\n" "$FIXTURE_HEAD"\n', { mode: 0o755 }); + const source = path.join(root, 'packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs'); + mkdirSync(path.dirname(source), { recursive: true }); + const output = path.join(root, 'outputs'); + const historical = ['darwin-arm64', 'darwin-x64', 'linux-x64']; + for (const targets of [historical, [...historical, 'linux-arm64']]) { + writeFileSync(source, `export const RELEASE_RUNNER_TARGETS = ${JSON.stringify(targets)};`); + writeFileSync(output, ''); + const result = spawnSync('bash', ['-euo', 'pipefail', '-c', script], { cwd: root, encoding: 'utf8', env: { + PATH: `${tools}:${path.dirname(process.execPath)}:/usr/bin:/bin`, SOURCE_SHA: 'a'.repeat(40), FIXTURE_HEAD: 'a'.repeat(40), GITHUB_OUTPUT: output, + } }); + assert.equal(result.status, 0, result.stderr); + const data = Object.fromEntries(readFileSync(output, 'utf8').trim().split('\n').map(line => { + const index = line.indexOf('='); return [line.slice(0, index), JSON.parse(line.slice(index + 1))]; + })); + assert.deepEqual(data.matrix.include.map(row => row.target), targets); + assert.deepEqual(data.native_consumers.include.map(row => row.target), targets.filter(target => target !== 'linux-x64')); + assert.ok(data.matrix.include.every(row => row.target !== 'linux-arm64' || row.host === 'ubuntu-24.04-arm')); + assert.equal(data.matrix.include.find(row => row.target === 'linux-x64').host, targets.length === 4 ? 'ubuntu-24.04' : 'ubuntu-latest', + 'new GNU libc baseline stays pinned while historical qualification keeps its producer mode'); + } + writeFileSync(source, 'export const RELEASE_RUNNER_TARGETS = ["linux-arm64"];'); writeFileSync(output, ''); + const result = spawnSync('bash', ['-euo', 'pipefail', '-c', script], { cwd: root, encoding: 'utf8', env: { + PATH: `${tools}:${path.dirname(process.execPath)}:/usr/bin:/bin`, SOURCE_SHA: 'a'.repeat(40), FIXTURE_HEAD: 'a'.repeat(40), GITHUB_OUTPUT: output, + } }); + assert.equal(result.status, 1); assert.match(result.stderr, /Unknown exact-source daemon platform contract/); + assert.equal(readFileSync(output, 'utf8'), ''); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + + +test("historical promotion sources keep their packaging path while new sources require daemon assembly", () => { + const workflow = readWorkflow("release.yml"); + const selector = workflow.split(' - name: Read the exact source packaging contract')[1].split('\n release_runner_binaries:')[0]; + const script = selector.split(' run: |\n')[1].split('\n').map(line => line.startsWith(' ') ? line.slice(10) : line).join('\n'); + const root = mkdtempSync(path.join(tmpdir(), 'release-source-contract-')); + try { + const tools = path.join(root, 'tools'); mkdirSync(tools); + writeFileSync(path.join(tools, 'git'), '#!/bin/sh\ncase "$1" in\nrev-parse) printf "%s\\n" "$FIXTURE_HEAD";;\nshow) cat "$FIXTURE_SOURCE";;\n*) exit 3;;\nesac\n', { mode: 0o755 }); + const source = path.join(root, 'release.sh'), output = path.join(root, 'outputs'); + for (const [contents, required] of [['pnpm build\n', false], ['assemble-daemons "$PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR" "$SOURCE_SHA"\n', true]]) { + writeFileSync(source, contents); writeFileSync(output, ''); + const result = spawnSync('bash', ['-euo', 'pipefail', '-c', script], { encoding: 'utf8', env: { + PATH: `${tools}:/usr/bin:/bin`, SOURCE_SHA: 'a'.repeat(40), FIXTURE_HEAD: 'a'.repeat(40), FIXTURE_SOURCE: source, GITHUB_OUTPUT: output, + } }); + assert.equal(result.status, 0, result.stderr); + assert.match(readFileSync(output, 'utf8'), new RegExp(`required=${required}`)); + } + const result = spawnSync('bash', ['-euo', 'pipefail', '-c', script], { encoding: 'utf8', env: { + PATH: `${tools}:/usr/bin:/bin`, SOURCE_SHA: 'a'.repeat(40), FIXTURE_HEAD: 'b'.repeat(40), FIXTURE_SOURCE: source, GITHUB_OUTPUT: output, + } }); + assert.equal(result.status, 1, 'Wrong checkout cannot opt out of mandatory assembly'); + const unreadable = spawnSync('bash', ['-euo', 'pipefail', '-c', script], { encoding: 'utf8', env: { + PATH: `${tools}:/usr/bin:/bin`, SOURCE_SHA: 'a'.repeat(40), FIXTURE_HEAD: 'a'.repeat(40), FIXTURE_SOURCE: path.join(root, 'absent'), GITHUB_OUTPUT: output, + } }); + assert.equal(unreadable.status, 1, 'A failed immutable source read must not masquerade as a legacy source'); + + } finally { rmSync(root, { recursive: true, force: true }); } + for (const job of ['publish_canary','publish_nightly','publish_beta','preview_stable','publish_stable']) { + const block = workflow.split(` ${job}:\n`)[1].split(/\n [a-z_]+:\n/)[0]; + assert.match(block, /needs.release_runner_source.result == 'success'/); + assert.match(block, /needs.release_runner_source.outputs.required == 'false' \|\| needs.release_runner_binaries.result == 'success'/); + assert.match(block, /Download exact-source release daemon receipts\n\s+if: needs.release_runner_source.outputs.required == 'true'/); + } +}); + test("canary startup refreshes an incomplete dependency publication, then onboards once", async () => { diff --git a/scripts/dev-runner-native-binary.mjs b/scripts/dev-runner-native-binary.mjs index ecf9cabede5..d49eb01aaed 100644 --- a/scripts/dev-runner-native-binary.mjs +++ b/scripts/dev-runner-native-binary.mjs @@ -1,5 +1,6 @@ -import { readdirSync, statSync } from "node:fs"; +import { readFileSync, readdirSync, statSync } from "node:fs"; import path from "node:path"; +import { runnerBinaryTarget } from "../packages/paperclip-runner/src/live/runner-binary.ts"; export function resolveNativeRunnerRequirement({ exitCode, stdout }) { if (exitCode !== 0) { @@ -38,20 +39,41 @@ function newestMtimeMs(target) { return newest; } +export function resolveNativeRunnerDevBinary({ + repoRoot, + platform = process.platform, + architecture = process.arch, +}) { + const executable = platform === "win32" ? "paperclip-runnerd.exe" : "paperclip-runnerd"; + const packageRoot = path.join(repoRoot, "packages", "paperclip-runner"); + const target = path.join(packageRoot, "dist", "bin", `${platform}-${architecture}`, executable); + const flat = path.join(packageRoot, "dist", "bin", executable); + const targetStat = statSync(target, { throwIfNoEntry: false }); + const flatStat = statSync(flat, { throwIfNoEntry: false }); + // Release targets remain authoritative unless a newer source build matches + // this host. In particular, a build-host flat artifact cannot mask a remotely + // staged Mac target or force a local Cargo build. + if (flatStat?.isFile() && (!targetStat?.isFile() || flatStat.mtimeMs > targetStat.mtimeMs) + && runnerBinaryTarget(readFileSync(flat)) === `${platform}-${architecture}`) { + return { binary: flat, sourceBuild: true }; + } + return targetStat?.isFile() ? { binary: target, sourceBuild: false } : null; +} + export function paperclipRunnerBinaryNeedsBuild({ repoRoot, nativeRunnerRequired, configuredBinary, platform = process.platform, + architecture = process.arch, }) { if (!nativeRunnerRequired) return false; if (configuredBinary?.trim()) return false; - const executable = platform === "win32" ? "paperclip-runnerd.exe" : "paperclip-runnerd"; + const selected = resolveNativeRunnerDevBinary({ repoRoot, platform, architecture }); + if (!selected) return true; + const binaryStat = statSync(selected.binary); const packageRoot = path.join(repoRoot, "packages", "paperclip-runner"); - const stagedBinary = path.join(packageRoot, "dist", "bin", executable); - const binaryStat = statSync(stagedBinary, { throwIfNoEntry: false }); - if (!binaryStat?.isFile()) return true; const runnerRoot = path.join(packageRoot, "runner"); const buildInputs = [ diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 6bd777f3333..748df95ab72 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -8,6 +8,7 @@ import { stdin, stdout } from "node:process"; import { createCapturedOutputBuffer, parseJsonResponseWithLimit } from "./dev-runner-output.ts"; import { paperclipRunnerBinaryNeedsBuild, + resolveNativeRunnerDevBinary, resolveNativeRunnerRequirement, } from "./dev-runner-native-binary.mjs"; import { applyDevRunnerOptions } from "./dev-runner-options.ts"; @@ -171,6 +172,9 @@ const env: NodeJS.ProcessEnv = { ...process.env, PAPERCLIP_UI_DEV_MIDDLEWARE: explicitUiDevMiddleware ?? (serveBuiltUiForManagedRuntime ? "false" : "true"), }; +// An automatic source-build override must not hide subsequent Rust edits from +// the watcher. Keep the operator's original choice separate from server env. +const configuredRunnerBinary = env.PAPERCLIP_RUNNER_BINARY; if (mode === "dev") { env.PAPERCLIP_DEV_SERVER_STATUS_FILE = devServerStatusFilePath; @@ -573,28 +577,32 @@ async function buildPaperclipRunner() { process.exit(typescriptResult.code); } + const nativeRunnerRequired = await getNativeRunnerRequired(); if ( - !paperclipRunnerBinaryNeedsBuild({ + paperclipRunnerBinaryNeedsBuild({ repoRoot, - nativeRunnerRequired: await getNativeRunnerRequired(), - configuredBinary: env.PAPERCLIP_RUNNER_BINARY, + nativeRunnerRequired, + configuredBinary: configuredRunnerBinary, }) ) { - return; + console.log("[paperclip] building paperclip runner native binary..."); + const binaryResult = await runPnpm( + ["--filter", "@paperclipai/paperclip-runner", "build:binary"], + { stdio: "inherit" }, + ); + if (binaryResult.signal) { + exitForSignal(binaryResult.signal); + return; + } + if (binaryResult.code !== 0) { + console.error("[paperclip] paperclip runner native binary build failed"); + process.exit(binaryResult.code); + } } - - console.log("[paperclip] building paperclip runner native binary..."); - const binaryResult = await runPnpm( - ["--filter", "@paperclipai/paperclip-runner", "build:binary"], - { stdio: "inherit" }, - ); - if (binaryResult.signal) { - exitForSignal(binaryResult.signal); - return; - } - if (binaryResult.code !== 0) { - console.error("[paperclip] paperclip runner native binary build failed"); - process.exit(binaryResult.code); + if (!configuredRunnerBinary?.trim()) { + const selected = nativeRunnerRequired ? resolveNativeRunnerDevBinary({ repoRoot }) : null; + if (selected?.sourceBuild) env.PAPERCLIP_RUNNER_BINARY = selected.binary; + else delete env.PAPERCLIP_RUNNER_BINARY; } } diff --git a/scripts/grok-public-install-sandbox.mjs b/scripts/grok-public-install-sandbox.mjs index 8ce7663810f..3e674c1017b 100644 --- a/scripts/grok-public-install-sandbox.mjs +++ b/scripts/grok-public-install-sandbox.mjs @@ -1,4 +1,6 @@ import assert from 'node:assert/strict'; +import { readFileSync, realpathSync, statSync } from 'node:fs'; +import { basename, dirname, join } from 'node:path'; // Keep public-package lifecycle code off the verification host. Resolve and // cache the public npm graph without scripts, then execute it offline. @@ -9,6 +11,31 @@ export const GROK_PUBLIC_INSTALL_LIFECYCLE = [ 'npm', 'rebuild', '--offline', '--ignore-scripts=false', '--dangerously-allow-all-scripts', ]; +// setup-node provides its matching SDK alongside the executing Node binary. +// Deferred lifecycle scripts may read that SDK, but may never download headers. +export function macNodeHeadersDirectory(nodeExecutable, nodeVersion) { + assert.match(nodeVersion, /^\d+\.\d+\.\d+$/, 'Executing Node must have an exact release version'); + const executable = realpathSync(nodeExecutable); + assert.equal(basename(executable), 'node', 'Node SDK must belong to the executing Node binary'); + assert.ok(statSync(executable).isFile(), 'Executing Node must be a regular file'); + const prefix = realpathSync(join(dirname(executable), '..')); + const include = join(prefix, 'include/node'); + for (const name of ['node.h', 'node_version.h']) { + const header = join(include, name); + assert.equal(realpathSync(header), header, 'Node SDK headers must remain inside the executing Node prefix'); + assert.ok(statSync(header).isFile(), 'Node SDK headers must be regular files'); + } + assert.match(readFileSync(join(include, 'node.h'), 'utf8'), /#\s*include\s+"node_version\.h"/, 'Node SDK must include its version header'); + const versionHeader = readFileSync(join(include, 'node_version.h'), 'utf8'); + const versions = ['MAJOR', 'MINOR', 'PATCH'].map(part => { + const matches = [...versionHeader.matchAll(new RegExp('^#\\s*define\\s+NODE_' + part + '_VERSION\\s+(\\d+)\\s*$', 'gm'))]; + assert.equal(matches.length, 1, 'Node SDK must declare one ' + part.toLowerCase() + ' version'); + return matches[0][1]; + }); + assert.equal(versions.join('.'), nodeVersion, 'Node SDK version must match the executing Node'); + return prefix; +} + // Inspect the actual npm archive, independently of the staging helper. export function assertNoBundledCodexPayloads(entries) { assert.ok(Array.isArray(entries) && entries.length > 0, 'Packed package must have tar members'); @@ -60,10 +87,11 @@ export function assertInstalledCodexPlatformProvenance(lock, installed, target) // Execute this in the existing offline public consumer, never in the checkout. // The public index and returned command must both come from the installed graph. -export function installedCodexProbeSource(indexPath, consumerRoot, expectedVersion) { +export function installedCodexProbeSource(indexPath, consumerRoot, expectedVersion, { nativeCodex = false } = {}) { return ` import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; + import { createHash } from 'node:crypto'; import { createRequire } from 'node:module'; import { accessSync, constants, existsSync, lstatSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs'; import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; @@ -118,11 +146,11 @@ export function installedCodexProbeSource(indexPath, consumerRoot, expectedVersi assert.ok(selectedPlatform, 'Codex wrapper must resolve an official host platform package from its installed graph'); const selectedMetadata = JSON.parse(readFileSync(selectedPlatformManifest, 'utf8')); assert.deepEqual(selectedMetadata, selectedPlatform.manifest, 'Selected Codex platform metadata must match its scanned provenance'); - assert.equal(selectedMetadata.version, ${JSON.stringify(expectedVersion)} + '-' + process.platform + '-' + process.arch, - 'Selected Codex platform version must match the qualified pin'); + ${nativeCodex ? '' : `assert.equal(selectedMetadata.version, ${JSON.stringify(expectedVersion)} + '-' + process.platform + '-' + process.arch, + 'Selected Codex platform version must match the qualified pin');`} const version = execFileSync(command, ['--version'], { timeout: 30_000, maxBuffer: 128 * 1024, encoding: 'utf8', - env: { PATH: '/usr/local/bin:/usr/bin:/bin', HOME: '/tmp', NODE_PATH: '' } }).trim(); - assert.equal(version, 'codex-cli ' + ${JSON.stringify(expectedVersion)}, 'Installed Codex version must match the qualified pin'); + env: { PATH: process.env.PATH, HOME: process.env.HOME, NODE_PATH: '' } }).trim(); + ${nativeCodex ? "assert.match(version, /^codex-cli /, 'Installed official Codex command must execute');" : `assert.equal(version, 'codex-cli ' + ${JSON.stringify(expectedVersion)}, 'Installed Codex version must match the qualified pin');`} const vendor = dirname(index); const integrityPath = join(vendor, 'drivers/acpx/installation-integrity.js'); const profilesPath = join(vendor, 'drivers/acpx/qualified-profiles.js'); @@ -132,26 +160,116 @@ export function installedCodexProbeSource(indexPath, consumerRoot, expectedVersi const platformRoot = realpathSync(dirname(selectedPlatformManifest)); const nativeVendor = realpathSync(join(platformRoot, 'vendor')); assert.ok(contained(nativeVendor) && nativeVendor.startsWith(platformRoot + sep), 'Codex vendor resources must remain inside their installed platform package'); + const triple = { 'linux-x64': 'x86_64-unknown-linux-musl', 'linux-arm64': 'aarch64-unknown-linux-musl', + 'darwin-x64': 'x86_64-apple-darwin', 'darwin-arm64': 'aarch64-apple-darwin', + 'win32-x64': 'x86_64-pc-windows-msvc', 'win32-arm64': 'aarch64-pc-windows-msvc' }[process.platform + '-' + process.arch]; + assert.ok(triple, 'Codex native payload requires a known host target'); + const nativeName = process.platform === 'win32' ? 'codex.exe' : 'codex'; + const nativeCandidates = ['bin', 'codex'].map(directory => join(nativeVendor, triple, directory, nativeName)).filter(existsSync); + assert.equal(nativeCandidates.length, 1, 'Selected official Codex platform must contain one native executable'); + const nativeExecutable = realpathSync(nativeCandidates[0]); + const nativeStat = lstatSync(nativeCandidates[0]); + assert.ok(nativeExecutable.startsWith(nativeVendor + sep) && nativeStat.isFile() && !nativeStat.isSymbolicLink() + && nativeStat.size >= 20 && nativeStat.size <= 512 * 1024 * 1024, 'Codex native executable must be a bounded regular file in its selected platform package'); + accessSync(nativeExecutable, constants.X_OK); + const nativeBytes = readFileSync(nativeExecutable); + const { runnerBinaryTarget } = await import(pathToFileURL(join(vendor, 'live/runner-binary.js')).href); + assert.equal(runnerBinaryTarget(nativeBytes), process.platform + '-' + process.arch, 'Codex native executable must match the actual host architecture'); const sandboxSource = { HOME: '/private-provider-home', PATH: '/usr/local/bin:/usr/bin:/bin' }; const readRoots = codexExecutableReadOnlyRoots(sandboxSource, command); assert.ok(readRoots.includes(nativeVendor), 'Selected native Codex vendor directory must be readable inside its sandbox'); assert.ok(!readRoots.includes(platformRoot) && !readRoots.includes(root) && !readRoots.includes(sandboxSource.HOME), 'Codex sandbox must not grant enclosing npm or credential-home access'); const sandboxArgs = createIsolatedCodexAppServerArgs(sandboxSource, readRoots).join('\\n'); assert.ok(sandboxArgs.includes(JSON.stringify(nativeVendor) + '="read"'), 'Codex sandbox configuration must retain the selected native executable resources'); - const { createAcpxPackageJsonResolver, verifyQualifiedAcpxInstallation } = await import(pathToFileURL(integrityPath).href); + ${nativeCodex ? '' : `const { createAcpxPackageJsonResolver, verifyQualifiedAcpxInstallation } = await import(pathToFileURL(integrityPath).href); const { resolveQualifiedAcpxProfile } = await import(pathToFileURL(profilesPath).href); const serverRoot = resolve(vendor, '../../..'); assert.ok(contained(serverRoot), 'Codex package authority must be the installed server'); const installation = await verifyQualifiedAcpxInstallation(resolveQualifiedAcpxProfile('codex', 'gpt-5.4'), createAcpxPackageJsonResolver(serverRoot, join(serverRoot, 'package.json'))); const lease = await installation.openCommand(); - await lease.close(); - console.log(JSON.stringify({ pinnedCodexCommandVerified: true, pinnedCodexVersion: ${JSON.stringify(expectedVersion)}, - ...provenance, codexSelectedPlatformPath: selectedPlatform.path, codexSelectedPlatformVersion: selectedMetadata.version, codexNativeSandboxResourcesVerified: true, codexQualifiedInstallationVerified: true, codexCommandLeaseVerified: true, providerCalls: 0 })); + await lease.close();`} + console.log(JSON.stringify({ pinnedCodexCommandVerified: true, installedCodexVersion: version, + codexCommandSha256: createHash('sha256').update(readFileSync(command)).digest('hex'), + codexNativeExecutableSha256: createHash('sha256').update(nativeBytes).digest('hex'), + codexNativeExecutablePath: relative(root, nativeExecutable).split(sep).join('/'), + ...provenance, codexSelectedPlatformPath: selectedPlatform.path, codexSelectedPlatformVersion: selectedMetadata.version, + codexNativeSandboxResourcesVerified: true, + ${nativeCodex ? '' : `pinnedCodexVersion: ${JSON.stringify(expectedVersion)}, codexQualifiedInstallationVerified: true, codexCommandLeaseVerified: true,`} + providerCalls: 0 })); `; } -export function grokConsumerDockerArgs({ assets, consumer, cache, command, uid, gid, download = false, prerequisite, temporarySizeMiB = 256, temporaryExecutable = false }) { +/** Installed bytes and consumer resolution must survive host protocol startup. */ +export function assertInstalledNativeConsumerContinuity(before, after) { + for (const field of ['lockSha256', 'runnerSha256', 'codexCommandSha256', 'codexNativeExecutableSha256']) { + if (!/^[a-f0-9]{64}$/.test(before[field]) || before[field] !== after[field]) { + throw new Error(`Installed native host startup changed ${field}`); + } + } +} + +/** The installed consumer must select its packaged host daemon, never PATH. */ +export function installedDaemonProbeSource(outputRoot, consumerRoot, sourceRevision) { + return ` + import assert from 'node:assert/strict'; + import { createHash } from 'node:crypto'; + import { execFileSync } from 'node:child_process'; + import { readFileSync, realpathSync } from 'node:fs'; + import { isAbsolute, join, relative, sep } from 'node:path'; + import { pathToFileURL } from 'node:url'; + const root = realpathSync(${JSON.stringify(consumerRoot)}), output = realpathSync(${JSON.stringify(outputRoot)}); + const inside = path => { const value = relative(root, realpathSync(path)); return value !== '' && value !== '..' && !value.startsWith('..' + sep) && !isAbsolute(value); }; + assert.ok(inside(output)); + const { defaultCapabilityRunnerdBinary } = await import(pathToFileURL(join(output, 'live/runnerd-codex-transport.js')).href); + const { runnerBinaryTarget } = await import(pathToFileURL(join(output, 'live/runner-binary.js')).href); + const { parsePaperclipRunnerdBuildMetadata } = await import(pathToFileURL(join(output, 'evals/runnerd-artifact.js')).href); + const { PAPERCLIP_RUNNER_BUILD_METADATA: expected } = await import(pathToFileURL(join(output, 'evals/build-metadata.js')).href); + const manifest = JSON.parse(readFileSync(join(output, 'bin/release-manifest.json'), 'utf8')); + const targets = ['darwin-arm64', 'darwin-x64', 'linux-x64', 'linux-arm64']; + assert.equal(manifest.schema, 'paperclip.runner.release-binaries.v1'); + assert.equal(manifest.sourceRevision, ${JSON.stringify(sourceRevision)}); + assert.deepEqual(Object.keys(manifest.platforms).sort(), targets.sort()); + for (const target of targets) { + const record = manifest.platforms[target]; + assert.equal(record.path, target + '/paperclip-runnerd'); + const file = join(output, 'bin', record.path); assert.ok(inside(file)); + const bytes = readFileSync(file); + assert.equal(runnerBinaryTarget(bytes), target); + assert.equal('sha256:' + createHash('sha256').update(bytes).digest('hex'), record.sha256); + } + const target = process.platform + '-' + process.arch, selected = defaultCapabilityRunnerdBinary(); + assert.ok(inside(selected)); + assert.equal(realpathSync(selected), realpathSync(join(output, 'bin', manifest.platforms[target].path)), 'Production must select the packaged host daemon'); + const metadata = parsePaperclipRunnerdBuildMetadata(JSON.parse(execFileSync(selected, ['--build-metadata'], { + timeout: 15000, maxBuffer: 64 * 1024, encoding: 'utf8', env: { PATH: process.env.PATH, HOME: process.env.HOME, NODE_PATH: '' }, + }))); + assert.equal(metadata.binaryContractVersion, expected.contracts.runnerdArtifact); + assert.equal(metadata.nativeExecutionVersion, expected.contracts.nativeExecution); + assert.equal(metadata.harnessDriverVersion, expected.contracts.harnessDriver); + assert.equal(metadata.prp.name, expected.prp.name); + assert.ok(metadata.prp.minimumVersion <= expected.prp.maximumVersion && metadata.prp.maximumVersion >= expected.prp.minimumVersion); + console.log(JSON.stringify({ installedRunnerDaemonVerified: true, target, sourceRevision: manifest.sourceRevision, + sha256: manifest.platforms[target].sha256, buildMetadata: metadata, providerCalls: 0 })); + `; +} + +// Hosted, credential-free Mac consumers defer only npm lifecycle execution. +// Offline npm flags are not an OS network boundary; sandbox-exec supplies it. +export function macPublicInstallLifecycleProfile(ownedRoot) { + if (!ownedRoot.startsWith('/') || ownedRoot.includes('\0')) throw new Error('Mac lifecycle requires an absolute owned root'); + return `(version 1)\n(allow default)\n(deny network*)\n(deny file-write* (require-all (require-not (subpath ${JSON.stringify(ownedRoot)})) (require-not (literal "/dev/null"))))\n`; +} + +export function macInstalledStartupProbeSource(consumerRoot, serverRoot, runnerDigest, helperPath) { + if (!/^sha256:[a-f0-9]{64}$/.test(runnerDigest)) throw new Error('Installed Mac startup requires the verified SHA-256 manifest identity'); + return `import { probeInstalledCodexStartup } from ${JSON.stringify(helperPath)}; + console.log(JSON.stringify(await probeInstalledCodexStartup({consumerRoot:${JSON.stringify(consumerRoot)}, + serverRoot:${JSON.stringify(serverRoot)},expectedRunnerSha256:${JSON.stringify(runnerDigest.slice(7))}})));`; +} + +export function grokConsumerDockerArgs({ assets, consumer, cache, command, uid, gid, download = false, prerequisite, temporarySizeMiB = 256, temporaryExecutable = false, platform = 'linux/amd64' }) { + if (!['linux/amd64', 'linux/arm64'].includes(platform)) throw new Error('Public-install verification requires a supported native Linux platform'); if (!Number.isSafeInteger(uid) || uid <= 0 || !Number.isSafeInteger(gid) || gid <= 0) { throw new Error('Public-install verification requires an unprivileged host user'); } @@ -162,7 +280,7 @@ export function grokConsumerDockerArgs({ assets, consumer, cache, command, uid, throw new Error('Executable runtime snapshots require an offline verification sandbox'); } return [ - 'run', '--rm', '--platform', 'linux/amd64', + 'run', '--rm', '--platform', platform, '--user', `${uid}:${gid}`, '--read-only', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', '--pids-limit', '256', '--memory', '3g', diff --git a/scripts/pi-public-install-probe.mjs b/scripts/pi-public-install-probe.mjs index 663754fcbc8..5827f9ad008 100644 --- a/scripts/pi-public-install-probe.mjs +++ b/scripts/pi-public-install-probe.mjs @@ -2,10 +2,44 @@ // Run in a credential-free public npm consumer after `paperclipai runtime setup pi`. // Use the server's normal packaged daemon resolver; never submit a prompt. import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; import { mkdir, mkdtemp, readFile, realpath, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { pathToFileURL } from 'node:url'; +import { join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +export async function verifyPiPackagedDaemon(server, daemon, target, runnerBinaryTarget) { + assert.match(target, /^(?:linux|darwin)-(?:x64|arm64)$/); + const output = join(server, 'dist/vendor/paperclip-runner'), manifestPath = join(output, 'bin/release-manifest.json'); + let manifest; + try { manifest = JSON.parse(await readFile(manifestPath, 'utf8')); } + catch (error) { if (error.code !== 'ENOENT') throw error; } + let expectedPath = join(output, 'bin/paperclip-runnerd'); + if (manifest !== undefined) { + assert.ok(manifest && typeof manifest === 'object' && !Array.isArray(manifest), 'Release manifest must be an object'); + assert.equal(await realpath(manifestPath), manifestPath, 'Release manifest must remain inside the installed package'); + assert.equal(manifest.schema, 'paperclip.runner.release-binaries.v1'); + assert.match(manifest.sourceRevision, /^[a-f0-9]{40}$/); + const build = JSON.parse(await readFile(join(server, 'dist/build-info.json'), 'utf8')); + assert.equal(manifest.sourceRevision, build.commit, 'Packaged daemon source must match the installed server build'); + const artifact = manifest.platforms?.[target]; + assert.equal(artifact?.path, `${target}/paperclip-runnerd`, 'Packaged daemon must use the known host target path'); + assert.match(artifact.sha256, /^sha256:[a-f0-9]{64}$/); + expectedPath = join(output, 'bin', artifact.path); + assert.equal(await realpath(daemon), expectedPath, 'Production must select the installed host daemon'); + assert.equal(await realpath(expectedPath), expectedPath, 'Packaged daemon cannot escape through a symlink'); + const bytes = await readFile(expectedPath); + assert.equal(`sha256:${createHash('sha256').update(bytes).digest('hex')}`, artifact.sha256); + assert.equal(runnerBinaryTarget(bytes), target); + } else { + // Existing host-only source packages have no multi-target release manifest. + // They must still use their exact normal flat daemon, never an override. + assert.equal(await realpath(daemon), expectedPath); + assert.equal(runnerBinaryTarget(await readFile(expectedPath)), target); + } +} + +async function main() { const [serverDirectory, ...extra] = process.argv.slice(2); assert.ok(serverDirectory && extra.length === 0, 'Usage: pi-public-install-probe.mjs INSTALLED_SERVER_ROOT'); @@ -25,7 +59,8 @@ assert.equal(QUALIFIED_ACPX_PROFILES.pi.commandDigest, 'sha256:e92078bee3c23bec4 assert.equal(Object.hasOwn(QUALIFIED_ACPX_PROFILES.pi, 'qualificationModel'), false); assert.equal(Object.hasOwn(QUALIFIED_ACPX_PROFILES.pi, 'reportedModelId'), false); const daemon = resolvePaperclipRunnerBinary(); -assert.equal(await realpath(daemon), join(server, 'dist/vendor/paperclip-runner/bin/paperclip-runnerd')); +const { runnerBinaryTarget } = await import(pathToFileURL(join(server, 'dist/vendor/paperclip-runner/live/runner-binary.js'))); +await verifyPiPackagedDaemon(server, daemon, `${process.platform}-${process.arch}`, runnerBinaryTarget); const root = await mkdtemp(join(tmpdir(), 'pi-public-install-probe-')); const evidence = []; const started = performance.now(); @@ -69,3 +104,5 @@ try { try { await bundle?.transport.close(); } finally { clearTimeout(watchdog); await rm(root, { recursive: true, force: true }); } } +} +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main(); diff --git a/scripts/prepare-linux-arm64-qualification.sh b/scripts/prepare-linux-arm64-qualification.sh new file mode 100755 index 00000000000..162e17db4e4 --- /dev/null +++ b/scripts/prepare-linux-arm64-qualification.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# Prepare the disposable GitHub ARM qualification host using stock distro files. +# Extraction skips package maintainer scripts and their global policy changes. +set -euo pipefail + +if [[ "${GITHUB_ACTIONS:-}" != true || "${RUNNER_OS:-}" != Linux ]]; then + printf 'Linux ARM qualification preparation requires a GitHub Actions Linux runner.\n' >&2 + exit 1 +fi + +. /etc/os-release +test "$ID" = ubuntu +test "$VERSION_ID" = 24.04 +test "$(dpkg --print-architecture)" = arm64 +test "$(uname -m)" = aarch64 +prerequisite_report_dir=${RUNNER_TEMP:?}/paperclip-arm-bwrap-prerequisite +mkdir -p "$prerequisite_report_dir" +prerequisite_download_dir=$(mktemp -d "${RUNNER_TEMP}/paperclip-arm-stock-packages.XXXXXX") +before_userns=$(sysctl -n kernel.apparmor_restrict_unprivileged_userns) +before_apparmor=$(cat /sys/module/apparmor/parameters/enabled) +before_clone=$(sysctl -n kernel.unprivileged_userns_clone) +test "$before_userns" = 1 +test "$before_apparmor" = Y +test "$before_clone" = 1 +printf 'restriction=%s apparmor=%s clone=%s\n' \ + "$before_userns" "$before_apparmor" "$before_clone" | \ + tee "$prerequisite_report_dir/before.txt" + +parser=$(readlink -f "$(command -v apparmor_parser)") +test "$parser" = /usr/sbin/apparmor_parser +test "$(stat -c '%u' "$parser")" = 0 +dpkg-query -W -f='${Package} ${Version} ${Architecture}\n' \ + apparmor libc6 libcap2 libselinux1 | \ + tee "$prerequisite_report_dir/existing-package-versions.txt" +dpkg --compare-versions "$(dpkg-query -W -f='${Version}' libc6)" ge 2.38 +dpkg --compare-versions "$(dpkg-query -W -f='${Version}' libcap2)" ge 1:2.10 +dpkg --compare-versions "$(dpkg-query -W -f='${Version}' libselinux1)" ge 3.1~ + +timeout 120s sudo apt-get \ + -o Acquire::AllowInsecureRepositories=false \ + -o APT::Get::AllowUnauthenticated=false update | \ + tee "$prerequisite_report_dir/apt-update.txt" +apt-cache policy bubblewrap apparmor-profiles | \ + tee "$prerequisite_report_dir/apt-policy.txt" +for package_name in bubblewrap apparmor-profiles; do + package_version=$(apt-cache policy "$package_name" | awk '/Candidate:/ {print $2}') + test -n "$package_version" + test "$package_version" != '(none)' + apt-cache show "$package_name=$package_version" > \ + "$prerequisite_report_dir/$package_name-index.txt" + package_sha256=$(awk '/^SHA256:/ {print $2}' \ + "$prerequisite_report_dir/$package_name-index.txt") + test "${#package_sha256}" = 64 + printf '%s\n' "$package_sha256" | grep -E '^[0-9a-f]{64}$' >/dev/null + ( + cd "$prerequisite_download_dir" + timeout 60s apt-get -o APT::Get::AllowUnauthenticated=false \ + download "$package_name=$package_version" + ) + package_files=("$prerequisite_download_dir"/"$package_name"_*.deb) + test "${#package_files[@]}" = 1 + package_file=${package_files[0]} + test -f "$package_file" + printf '%s %s\n' "$package_sha256" "$package_file" | sha256sum -c - + test "$(dpkg-deb -f "$package_file" Package)" = "$package_name" + test "$(dpkg-deb -f "$package_file" Version)" = "$package_version" + dpkg-deb -f "$package_file" Package Version Architecture | \ + tee "$prerequisite_report_dir/$package_name-downloaded-version.txt" + if test "$package_name" = bubblewrap; then + test "$(dpkg-deb -f "$package_file" Architecture)" = arm64 + dpkg-deb --fsys-tarfile "$package_file" | \ + tar -xO ./usr/bin/bwrap > "$prerequisite_download_dir/bwrap" + else + test "$(dpkg-deb -f "$package_file" Architecture)" = all + dpkg-deb --fsys-tarfile "$package_file" | \ + tar -xO ./usr/share/apparmor/extra-profiles/bwrap-userns-restrict > \ + "$prerequisite_download_dir/bwrap-userns-restrict" + fi +done + +stock_binary=$prerequisite_download_dir/bwrap +stock_profile=$prerequisite_download_dir/bwrap-userns-restrict +printf '11d39094f044f0cda0febb3ad517b830301da6b2ce929664af09ee9e4dd264f9 %s\n' \ + "$stock_profile" | sha256sum -c - +cat "$stock_profile" | tee "$prerequisite_report_dir/stock-profile.txt" +test "$(od -An -tx1 -N4 "$stock_binary" | tr -d ' \n')" = 7f454c46 +readelf -h "$stock_binary" | tee "$prerequisite_report_dir/bwrap-elf-header.txt" +grep -E 'Machine:.*AArch64' "$prerequisite_report_dir/bwrap-elf-header.txt" + +# Reject every target symlink, including dangling links, before privileged copy. +test ! -L /usr/bin/bwrap +if test -e /usr/bin/bwrap; then + test -f /usr/bin/bwrap + cmp "$stock_binary" /usr/bin/bwrap +else + sudo install -o root -g root -m 0755 "$stock_binary" /usr/bin/bwrap +fi +test "$(readlink -f "$(command -v bwrap)")" = /usr/bin/bwrap +test "$(stat -c '%u:%g:%a' /usr/bin/bwrap)" = 0:0:755 +command -v getcap >/dev/null +test -z "$(getcap /usr/bin/bwrap)" +sha256sum "$stock_binary" /usr/bin/bwrap | \ + tee "$prerequisite_report_dir/bwrap-sha256.txt" +timeout 5s /usr/bin/bwrap --version | tee "$prerequisite_report_dir/bwrap-version.txt" +help=$(timeout 5s /usr/bin/bwrap --help) +printf '%s\n' "$help" > "$prerequisite_report_dir/bwrap-help.txt" +printf '%s\n' "$help" | grep -F -- --as-pid-1 >/dev/null +printf '%s\n' "$help" | grep -F -- --perms >/dev/null + +target=/etc/apparmor.d/bwrap-userns-restrict +test ! -L "$target" +test ! -L /etc/apparmor.d/local/bwrap-userns-restrict +test ! -L /etc/apparmor.d/local/unpriv_bwrap +test ! -s /etc/apparmor.d/local/bwrap-userns-restrict +test ! -s /etc/apparmor.d/local/unpriv_bwrap +if test -e "$target"; then + test -f "$target" + cmp "$stock_profile" "$target" +else + sudo install -o root -g root -m 0644 "$stock_profile" "$target" +fi +timeout 15s sudo "$parser" -r "$target" +sha256sum "$stock_profile" "$target" | \ + tee "$prerequisite_report_dir/profile-sha256.txt" +loaded_profiles=$(sudo cat /sys/kernel/security/apparmor/profiles) +for required_profile in 'bwrap (enforce)' 'unpriv_bwrap (enforce)'; do + printf '%s\n' "$loaded_profiles" | grep -Fx "$required_profile" | \ + tee -a "$prerequisite_report_dir/loaded-profiles.txt" +done +after_userns=$(sysctl -n kernel.apparmor_restrict_unprivileged_userns) +after_apparmor=$(cat /sys/module/apparmor/parameters/enabled) +after_clone=$(sysctl -n kernel.unprivileged_userns_clone) +test "$after_userns" = "$before_userns" +test "$after_apparmor" = "$before_apparmor" +test "$after_clone" = "$before_clone" +printf 'restriction=%s apparmor=%s clone=%s\n' \ + "$after_userns" "$after_apparmor" "$after_clone" | \ + tee "$prerequisite_report_dir/after.txt" +timeout 5s /usr/bin/bwrap --die-with-parent --unshare-user --unshare-net \ + --ro-bind / / --cap-drop ALL /bin/true +printf 'stock files materialized without dpkg registration or maintainer scripts; namespace probe passed; no provider processes or turns\n' | \ + tee "$prerequisite_report_dir/namespace-probe.txt" diff --git a/scripts/release.sh b/scripts/release.sh index 7238c4b2939..a4a0073550f 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -284,6 +284,16 @@ release_info "" release_info "==> Step 2/7: Building workspace artifacts..." cd "$REPO_ROOT" pnpm build +# Hosted producers supply data-only, hash/source/architecture-bound daemon +# receipts. Assemble after the build (which stages its host binary), before +# public packages are packed. The server vendors this same compiled runner. +if [ -n "${PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR:-}" ]; then + node "$REPO_ROOT/packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs" \ + assemble-daemons "$PAPERCLIP_RELEASE_RUNNER_ARTIFACTS_DIR" "$(git rev-parse HEAD)" + cp -R "$REPO_ROOT/packages/paperclip-runner/dist/bin/." "$REPO_ROOT/server/dist/vendor/paperclip-runner/bin/" +elif [ "$dry_run" = false ]; then + release_fail "Public releases require the four hosted runner daemon artifacts; use the supported Release workflow." +fi node "$REPO_ROOT/scripts/build-standalone-public-packages.mjs" bash "$REPO_ROOT/scripts/prepare-server-ui-dist.sh" for pkg_dir in server packages/adapters/claude-local packages/adapters/codex-local; do diff --git a/scripts/verify-grok-npm-install.mjs b/scripts/verify-grok-npm-install.mjs index 524b76b86d5..6bd8754baf4 100644 --- a/scripts/verify-grok-npm-install.mjs +++ b/scripts/verify-grok-npm-install.mjs @@ -4,17 +4,200 @@ // explicit and separate from npm installation, and is removed in finally. import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; -import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { createServer } from 'node:net'; import { tmpdir } from 'node:os'; +import { stripTypeScriptTypes } from 'node:module'; import { basename, dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { packConcurrency, packStagedPackages, packTimings } from './pack-staged-packages.mjs'; import { materializePublishManifest } from './prepare-bundled-package.mjs'; -import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedCodexProbeSource } from './grok-public-install-sandbox.mjs'; +import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, assertInstalledNativeConsumerContinuity, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedCodexProbeSource, installedDaemonProbeSource, macNodeHeadersDirectory, macPublicInstallLifecycleProfile, macInstalledStartupProbeSource } from './grok-public-install-sandbox.mjs'; import { startPrefetch, stopPrefetches } from './prefetch-child.mjs'; import { retainRunnerQualificationPackages } from './retain-runner-qualification-packages.mjs'; import { bundledInstallTimings, startBundledInstalls } from './stage-bundled-packages.mjs'; const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +async function verifyMacConsumer(packageDirectory, sourceRevision, receiptPath) { + assert.equal(process.env.GITHUB_ACTIONS, 'true', 'Mac clean consumers run only on disposable hosted CI'); + assert.equal(process.platform, 'darwin'); + assert.ok(['arm64', 'x64'].includes(process.arch)); + assert.match(sourceRevision, /^[a-f0-9]{40}$/); + const packages = realpathSync(packageDirectory); + const manifest = JSON.parse(readFileSync(join(packages, 'manifest.json'), 'utf8')); + assert.equal(manifest.schema, 'paperclip.runner.public-packages/v1'); + assert.equal(manifest.sourceRevision, sourceRevision); + assert.ok(manifest.archives.some(entry => entry.name === '@paperclipai/server')); + assert.ok(manifest.archives.some(entry => entry.name === 'paperclipai')); + assert.equal(new Set(manifest.archives.map(entry => entry.name)).size, manifest.archives.length); + const root = realpathSync(mkdtempSync(join(tmpdir(), 'paperclip-mac-public-install-'))); + try { + const consumer = join(root, 'consumer'), home = join(root, 'home'), tools = join(root, 'tools'); + for (const directory of [consumer, home, tools, join(root, 'tmp'), join(root, 'cache')]) mkdirSync(directory); + const npmCli = realpathSync(execFileSync('/usr/bin/which', ['npm'], { encoding: 'utf8' }).trim()); + symlinkSync(process.execPath, join(tools, 'node')); + // npm subprocesses get a Node-only owned tool directory; no ambient harness + // command can hide missing installed dependencies. + const env = { PATH: `${tools}:/usr/bin:/bin:/usr/sbin:/sbin`, HOME: home, TMPDIR: join(root, 'tmp'), + npm_config_cache: join(root, 'cache'), npm_config_audit: 'false', npm_config_fund: 'false' }; + const run = (command, args, cwd = consumer, runEnv = env) => execFileSync(command, args, { cwd, env: runEnv, + timeout: 10 * 60_000, maxBuffer: 32 * 1024 * 1024, encoding: 'utf8' }); + const tarballs = manifest.archives.map(entry => { + assert.match(entry.file, /^[A-Za-z0-9._-]+\.tgz$/); assert.match(entry.sha256, /^[a-f0-9]{64}$/); + const path = join(packages, entry.file); + assert.equal(realpathSync(path), path); + assert.equal(createHash('sha256').update(readFileSync(path)).digest('hex'), entry.sha256); + assertNoBundledCodexPayloads(run('/usr/bin/tar', ['-tzf', path]).trim().split('\n')); + return path; + }); + writeFileSync(join(consumer, 'package.json'), JSON.stringify({ private: true, type: 'module' })); + const sentinelSource = join(root, 'lifecycle-sentinel'); mkdirSync(sentinelSource); + writeFileSync(join(sentinelSource, 'package.json'), JSON.stringify({ name: 'paperclip-verification-lifecycle-sentinel', version: '1.0.0', + scripts: { postinstall: 'node -e "require(\'node:fs\').writeFileSync(\'lifecycle-ran\', \'ok\')"' } })); + run(process.execPath, [npmCli, 'pack', '--ignore-scripts', '--pack-destination', root], sentinelSource); + const sentinelTar = join(root, readdirSync(root).find(name => name.endsWith('.tgz'))); + run(process.execPath, [npmCli, 'install', '--ignore-scripts', '--omit=dev', ...tarballs, sentinelTar]); + const sentinel = join(consumer, 'node_modules/paperclip-verification-lifecycle-sentinel/lifecycle-ran'); + assert.equal(existsSync(sentinel), false); + const lock = readFileSync(join(consumer, 'package-lock.json'), 'utf8'); + const profile = join(root, 'lifecycle.sb'); writeFileSync(profile, macPublicInstallLifecycleProfile(root)); + const listener = createServer(socket => socket.end()); + await new Promise((resolve, reject) => { listener.once('error', reject); listener.listen(0, '127.0.0.1', resolve); }); + try { + const port = listener.address().port; + const networkProbe = `import assert from 'node:assert/strict'; import net from 'node:net'; + const socket = net.connect({host:'127.0.0.1',port:${port}}); socket.setTimeout(3000,()=>process.exit(2)); + socket.once('connect',()=>{socket.destroy(); ${"process.argv[1] === 'denied' ? process.exit(3) : process.exit(0)"};}); + socket.once('error',error=>{assert.equal(process.argv[1],'denied'); assert.equal(error.code,'EPERM');});`; + run(process.execPath, ['--input-type=module', '-e', networkProbe, 'allowed']); + run('/usr/bin/sandbox-exec', ['-f', profile, process.execPath, '--input-type=module', '-e', networkProbe, 'denied']); + } finally { await new Promise(resolve => listener.close(resolve)); } + const nodeHeaders = macNodeHeadersDirectory(process.execPath, process.versions.node); + run('/usr/bin/sandbox-exec', ['-f', profile, process.execPath, npmCli, 'rebuild', '--offline', '--ignore-scripts=false', '--dangerously-allow-all-scripts', '--foreground-scripts'], + consumer, { ...env, npm_config_nodedir: nodeHeaders }); + assert.equal(readFileSync(sentinel, 'utf8'), 'ok'); + assert.equal(readFileSync(join(consumer, 'package-lock.json'), 'utf8'), lock); + for (const entry of manifest.archives) { + assert.equal(JSON.parse(readFileSync(join(consumer, 'node_modules', entry.name, 'package.json'))).version, manifest.releaseVersion); + } + const server = join(consumer, 'node_modules/@paperclipai/server'), installed = join(server, 'dist/vendor/paperclip-runner'); + const probe = (name, source) => { const file = join(root, name); writeFileSync(file, source); return JSON.parse(run(process.execPath, [file]).trim()); }; + const daemon = probe('daemon-probe.mjs', installedDaemonProbeSource(installed, consumer, sourceRevision)); + const codex = probe('codex-probe.mjs', installedCodexProbeSource(join(installed, 'index.js'), consumer, undefined, { nativeCodex: true })); + const startupScript = macInstalledStartupProbeSource(consumer, server, daemon.sha256, join(repo, 'tests/runner-e2e/installed-cli.ts')); + const startupFile = join(root, 'startup-probe.mts'); writeFileSync(startupFile, startupScript); + const startup = JSON.parse(run(process.execPath, ['--import', 'tsx', startupFile], join(repo, 'server')).trim()); + const receipt = { schema: 'paperclip.runner.mac-public-install.v1', sourceRevision, platform: process.platform, architecture: process.arch, + cleanNpmInstall: true, packageCount: manifest.archives.length, codexPackedPayloadsOmitted: true, + consumerLockPreserved: true, lifecycleSentinelVerified: true, lifecycleNodeHeadersVerified: true, lifecycleNodeVersion: process.versions.node, + lifecycleNetwork: 'sandbox-exec denied (loopback negative control)', + ...daemon, ...codex, startup, providerCalls: 0 }; + writeFileSync(receiptPath, JSON.stringify(receipt, null, 2) + '\n'); + console.log(JSON.stringify(receipt)); + } finally { rmSync(root, { recursive: true, force: true }); } +} +async function verifyLinuxArmConsumer(packageDirectory, sourceRevision, receiptPath) { + assert.equal(process.env.GITHUB_ACTIONS, 'true', 'Native clean consumers run only on disposable hosted CI'); + assert.equal(process.platform + '-' + process.arch, 'linux-arm64', 'ARM qualification requires a native ARM host'); + assert.equal(process.versions.node.split('.')[0], '24', 'ARM host startup requires the qualified Node24 runtime'); + assert.match(sourceRevision, /^[a-f0-9]{40}$/); + const packages = realpathSync(packageDirectory); + const manifest = JSON.parse(readFileSync(join(packages, 'manifest.json'), 'utf8')); + assert.equal(manifest.schema, 'paperclip.runner.public-packages/v1'); + assert.equal(manifest.sourceRevision, sourceRevision); + assert.ok(manifest.archives.some(entry => entry.name === '@paperclipai/server')); + assert.ok(manifest.archives.some(entry => entry.name === 'paperclipai')); + assert.equal(new Set(manifest.archives.map(entry => entry.name)).size, manifest.archives.length); + const root = realpathSync(mkdtempSync(join(tmpdir(), 'paperclip-arm-public-install-'))); + try { + const assets = join(root, 'assets'), consumer = join(root, 'consumer'), cache = join(root, 'cache'); + for (const directory of [assets, consumer, cache]) mkdirSync(directory); + const run = (command, args, cwd = root) => execFileSync(command, args, { cwd, + env: { PATH: process.env.PATH, HOME: root, TMPDIR: root }, timeout: 10 * 60_000, + maxBuffer: 32 * 1024 * 1024, encoding: 'utf8' }); + const platform = 'linux/arm64'; + run('docker', ['pull', '--platform', platform, GROK_PUBLIC_INSTALL_IMAGE]); + const isolated = (command, options = {}) => run('docker', grokConsumerDockerArgs({ assets, consumer, cache, + uid: process.getuid(), gid: process.getgid(), platform, command, ...options })); + for (const entry of manifest.archives) { + assert.match(entry.file, /^[A-Za-z0-9._-]+\.tgz$/); assert.match(entry.sha256, /^[a-f0-9]{64}$/); + const tarball = join(packages, entry.file); + assert.equal(realpathSync(tarball), tarball); + assert.equal(createHash('sha256').update(readFileSync(tarball)).digest('hex'), entry.sha256); + assertNoBundledCodexPayloads(run('tar', ['-tzf', tarball]).trim().split('\n')); + cpSync(tarball, join(assets, entry.file)); chmodSync(join(assets, entry.file), 0o644); + } + writeFileSync(join(consumer, 'package.json'), JSON.stringify({ private: true, type: 'module' })); + const sentinelSource = join(root, 'lifecycle-sentinel'); mkdirSync(sentinelSource); + writeFileSync(join(sentinelSource, 'package.json'), JSON.stringify({ name: 'paperclip-verification-lifecycle-sentinel', version: '1.0.0', + scripts: { postinstall: 'node -e "require(\'node:fs\').writeFileSync(\'lifecycle-ran\', \'ok\')"' } })); + run('npm', ['pack', '--ignore-scripts', '--pack-destination', assets], sentinelSource); + isolated(['npm', 'install', '--ignore-scripts', '--omit=dev', ...readdirSync(assets).filter(file => file.endsWith('.tgz')).map(file => `/packages/${file}`)], { download: true }); + const sentinel = join(consumer, 'node_modules/paperclip-verification-lifecycle-sentinel/lifecycle-ran'); + assert.equal(existsSync(sentinel), false); + const lock = readFileSync(join(consumer, 'package-lock.json'), 'utf8'); + isolated(GROK_PUBLIC_INSTALL_LIFECYCLE); + assert.equal(readFileSync(sentinel, 'utf8'), 'ok'); + assert.equal(readFileSync(join(consumer, 'package-lock.json'), 'utf8'), lock); + for (const entry of manifest.archives) { + assert.equal(JSON.parse(readFileSync(join(consumer, 'node_modules', entry.name, 'package.json'))).version, manifest.releaseVersion); + } + const server = '/consumer/node_modules/@paperclipai/server', installed = server + '/dist/vendor/paperclip-runner'; + const probe = (name, source, options = {}) => { writeFileSync(join(assets, name), source); return JSON.parse(isolated(['node', '/packages/' + name], options).trim()); }; + const daemon = probe('daemon-probe.mjs', installedDaemonProbeSource(installed, '/consumer', sourceRevision)); + assert.equal(daemon.target, 'linux-arm64'); + const codex = probe('codex-probe.mjs', installedCodexProbeSource(installed + '/index.js', '/consumer', undefined, { nativeCodex: true })); + // Reuse the existing zero-turn hook as read-only verifier source. All + // production imports still resolve from the installed consumer graph. + cpSync(join(repo, 'tests/runner-e2e/installed-cli.ts'), join(assets, 'installed-cli.ts')); + writeFileSync(join(assets, 'types.js'), stripTypeScriptTypes(readFileSync(join(repo, 'tests/runner-e2e/types.ts'), 'utf8'))); + // The restricted install container deliberately forbids nested namespaces. + // Keep its lifecycle/no-network proof, then test the exact same bind-mounted + // graph on the native host using Codex's normal read-only namespace policy. + const home = join(root, 'host-home'), hostTmp = join(root, 'host-tmp'); + for (const directory of [home, hostTmp]) mkdirSync(directory, { mode: 0o700 }); + const hostEnv = { PATH: `${dirname(process.execPath)}:/usr/bin:/bin:/usr/sbin:/sbin`, HOME: home, TMPDIR: hostTmp, LC_ALL: 'C', TZ: 'UTC' }; + const hostProbe = (name, source) => { + const file = join(assets, name); writeFileSync(file, source); + return JSON.parse(execFileSync(process.execPath, [file], { cwd: consumer, env: hostEnv, + timeout: 120_000, maxBuffer: 1024 * 1024, encoding: 'utf8' }).trim()); + }; + const hostServer = join(consumer, 'node_modules/@paperclipai/server'), hostInstalled = join(hostServer, 'dist/vendor/paperclip-runner'); + const hostDaemon = () => hostProbe('host-daemon-probe.mjs', installedDaemonProbeSource(hostInstalled, consumer, sourceRevision)); + const hostCodex = () => hostProbe('host-codex-probe.mjs', installedCodexProbeSource(join(hostInstalled, 'index.js'), consumer, undefined, { nativeCodex: true })); + const digest = value => createHash('sha256').update(value).digest('hex'); + const beforeCodex = hostCodex(); + const before = { lockSha256: digest(lock), runnerSha256: hostDaemon().sha256.slice(7), codexCommandSha256: beforeCodex.codexCommandSha256, codexNativeExecutableSha256: beforeCodex.codexNativeExecutableSha256 }; + assert.equal(before.runnerSha256, daemon.sha256.slice(7), 'Docker and host must select the same packaged daemon bytes'); + assert.equal(before.codexCommandSha256, codex.codexCommandSha256, 'Docker and host must select the same official Codex command bytes'); + assert.equal(before.codexNativeExecutableSha256, codex.codexNativeExecutableSha256, 'Docker and host must select the same official native Codex payload bytes'); + const hostHeader = process.report.getReport().header; + assert.match(hostHeader.glibcVersionRuntime || '', /^\d+\.\d+$/, 'Native host startup must report its actual GNU libc ABI'); + const startup = hostProbe('host-startup-probe.mjs', macInstalledStartupProbeSource(consumer, hostServer, daemon.sha256, join(assets, 'installed-cli.ts'))); + const afterCodex = hostCodex(); + const after = { lockSha256: digest(readFileSync(join(consumer, 'package-lock.json'))), runnerSha256: hostDaemon().sha256.slice(7), codexCommandSha256: afterCodex.codexCommandSha256, codexNativeExecutableSha256: afterCodex.codexNativeExecutableSha256 }; + assertInstalledNativeConsumerContinuity(before, after); + assert.equal(startup.runnerSha256, before.runnerSha256); + assert.equal(startup.codexSha256, before.codexCommandSha256); + const receipt = { schema: 'paperclip.runner.linux-arm64-public-install.v1', sourceRevision, platform: process.platform, architecture: process.arch, + cleanNpmInstall: true, packageCount: manifest.archives.length, codexPackedPayloadsOmitted: true, + consumerImage: GROK_PUBLIC_INSTALL_IMAGE, consumerImagePlatform: platform, + consumerLockPreserved: true, lifecycleSentinelVerified: true, lifecycleNetwork: 'none', + startupEnvironment: 'native host, same installed graph, isolated empty homes', + startupHost: { nodeVersion: process.versions.node, nodeModulesAbi: process.versions.modules, glibcVersionRuntime: hostHeader.glibcVersionRuntime, kernelRelease: hostHeader.osRelease }, + installedIdentityBefore: before, installedIdentityAfter: after, + ...daemon, ...codex, startup, providerCalls: 0 }; + writeFileSync(receiptPath, JSON.stringify(receipt, null, 2) + '\n'); + console.log(JSON.stringify(receipt)); + } finally { rmSync(root, { recursive: true, force: true }); } +} +if (process.argv[2] === '--linux-arm64-consumer') { + assert.equal(process.argv.length, 6, 'Usage: --linux-arm64-consumer PACKAGE_DIRECTORY SOURCE_SHA RECEIPT_PATH'); + await verifyLinuxArmConsumer(process.argv[3], process.argv[4], process.argv[5]); +} else if (process.argv[2] === '--mac-consumer') { + assert.equal(process.argv.length, 6, 'Usage: --mac-consumer PACKAGE_DIRECTORY SOURCE_SHA RECEIPT_PATH'); + await verifyMacConsumer(process.argv[3], process.argv[4], process.argv[5]); +} else { assert.equal(process.platform, 'linux', 'Run this verification on disposable EC2 Linux, not a developer host'); const root = mkdtempSync(join(tmpdir(), 'paperclip-grok-public-install-')); const prerequisite = join(root, 'native/grok'); @@ -146,6 +329,24 @@ try { timing.stage_pack_check_ms = Math.round(performance.now() - checksStarted); timing.stage_pack_ms = Math.round(performance.now() - packStarted); Object.assign(timing, packTimings(packs, concurrency)); + + // Preserve the exact Linux-produced public tarballs for independent Mac + // consumers even if a later Linux runtime probe fails. This is a packaging + // receipt, never an installation or provider-admission pass. + if (process.env.PAPERCLIP_RUNNER_PUBLIC_PACKS_DIR) { + const output = resolve(process.env.PAPERCLIP_RUNNER_PUBLIC_PACKS_DIR); + assert.equal(JSON.parse(readFileSync(join(repo, 'server/dist/build-info.json'))).commit, sourceRevision); + assert.ok(!existsSync(output), 'Never replace retained public packages'); + mkdirSync(output, { recursive: true }); + const archives = [...needed].map((name, index) => { + const file = basename(tarballs[index]); cpSync(tarballs[index], join(output, file)); + return { name, file, sha256: createHash('sha256').update(readFileSync(tarballs[index])).digest('hex') }; + }); + writeFileSync(join(output, 'manifest.json'), JSON.stringify({ + schema: 'paperclip.runner.public-packages/v1', sourceRevision, releaseVersion, + publicInstallationVerified: false, providerCalls: 0, archives, + }, null, 2) + '\n'); + } const assets = join(root, 'assets'); mkdirSync(assets, { mode: 0o755 }); const consumer = join(root, 'consumer'); mkdirSync(consumer); const cache = join(root, 'cache'); mkdirSync(cache); @@ -259,3 +460,4 @@ try { await stopPrefetches(prefetches); rmSync(root, { recursive: true, force: true }); } +} diff --git a/server/src/__tests__/dev-runner-native-binary.test.ts b/server/src/__tests__/dev-runner-native-binary.test.ts index 705ea8cf76e..0e1ac24a070 100644 --- a/server/src/__tests__/dev-runner-native-binary.test.ts +++ b/server/src/__tests__/dev-runner-native-binary.test.ts @@ -1,16 +1,22 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { paperclipRunnerBinaryNeedsBuild, + resolveNativeRunnerDevBinary, resolveNativeRunnerRequirement, } from "../../../scripts/dev-runner-native-binary.mjs"; const tempRoots = new Set<string>(); +const initialPlatform = Object.getOwnPropertyDescriptor(process, "platform")!; +const initialArchitecture = Object.getOwnPropertyDescriptor(process, "arch")!; afterEach(() => { + vi.restoreAllMocks(); + Object.defineProperty(process, "platform", initialPlatform); + Object.defineProperty(process, "arch", initialArchitecture); for (const root of tempRoots) { fs.rmSync(root, { recursive: true, force: true }); } @@ -36,10 +42,24 @@ function createRunnerCheckout(): { root: string; source: string; binary: string fs.writeFileSync(path.join(runnerRoot, "Cargo.toml"), "[workspace]\n", "utf8"); fs.writeFileSync(path.join(runnerRoot, "Cargo.lock"), "", "utf8"); fs.writeFileSync(source, "fn main() {}\n", "utf8"); - fs.writeFileSync(binary, "runnerd", "utf8"); + fs.writeFileSync(binary, nativeHeader(process.platform, process.arch)); return { root, source, binary }; } +function nativeHeader(platform: string, architecture: string): Buffer { + const bytes = Buffer.alloc(128); + if (platform === "darwin") { + bytes.writeUInt32LE(0xfeedfacf); + bytes.writeUInt32LE(architecture === "x64" ? 0x01000007 : 0x0100000c, 4); + } else if (platform === "win32") { + bytes.set([0x4d, 0x5a]); bytes.writeUInt32LE(64, 60); + bytes.writeUInt32LE(0x00004550, 64); bytes.writeUInt16LE(architecture === "x64" ? 0x8664 : 0xaa64, 68); + } else { + bytes.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); bytes.writeUInt16LE(architecture === "x64" ? 62 : 183, 18); + } + return bytes; +} + describe("paperclip runner native dev prerequisite", () => { it("uses an explicit status response and fails safe when status is unknown", () => { expect( @@ -110,6 +130,50 @@ describe("paperclip runner native dev prerequisite", () => { ).toBe(false); }); + it.each(["arm64", "x64"])("uses the staged Mac %s target before the flat build output", architecture => { + const checkout = createRunnerCheckout(); + const target = path.join(path.dirname(checkout.binary), `darwin-${architecture}`, "paperclip-runnerd"); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, nativeHeader("darwin", architecture)); + const old = new Date(Date.now() - 2000), current = new Date(Date.now() + 2000); + fs.utimesSync(checkout.binary, old, old); + fs.utimesSync(target, current, current); + const input = { repoRoot: checkout.root, nativeRunnerRequired: true, platform: "darwin", architecture }; + expect(paperclipRunnerBinaryNeedsBuild(input)).toBe(false); + // A newer Linux build-host output cannot mask the remote Mac artifact. + fs.writeFileSync(checkout.binary, nativeHeader("linux", "x64")); + const wrongHostNewer = new Date(Date.now() + 4000); + fs.utimesSync(checkout.binary, wrongHostNewer, wrongHostNewer); + expect(resolveNativeRunnerDevBinary(input)).toEqual({ binary: target, sourceBuild: false }); + expect(paperclipRunnerBinaryNeedsBuild(input)).toBe(false); + fs.rmSync(checkout.binary); + expect(paperclipRunnerBinaryNeedsBuild(input)).toBe(false); + fs.utimesSync(target, old, old); + expect(paperclipRunnerBinaryNeedsBuild(input)).toBe(true); + fs.rmSync(target); + expect(paperclipRunnerBinaryNeedsBuild(input)).toBe(true); + }); + + it.each(["arm64", "x64"])("uses only a newer matching Mac %s source build without changing release selection", architecture => { + const checkout = createRunnerCheckout(); + const target = path.join(path.dirname(checkout.binary), `darwin-${architecture}`, "paperclip-runnerd"); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, nativeHeader("darwin", architecture)); + fs.writeFileSync(checkout.binary, nativeHeader("darwin", architecture)); + const old = new Date(Date.now() - 2000), fresh = new Date(Date.now() + 2000), changed = new Date(Date.now() + 4000); + fs.utimesSync(target, old, old); fs.utimesSync(checkout.binary, fresh, fresh); + const input = { repoRoot: checkout.root, nativeRunnerRequired: true, platform: "darwin", architecture }; + expect(resolveNativeRunnerDevBinary(input)).toEqual({ binary: checkout.binary, sourceBuild: true }); + expect(paperclipRunnerBinaryNeedsBuild(input)).toBe(false); + fs.utimesSync(checkout.source, changed, changed); + expect(paperclipRunnerBinaryNeedsBuild(input)).toBe(true); + fs.writeFileSync(checkout.binary, nativeHeader("darwin", architecture === "x64" ? "arm64" : "x64")); + expect(resolveNativeRunnerDevBinary(input)).toEqual({ binary: target, sourceBuild: false }); + fs.rmSync(target); + expect(resolveNativeRunnerDevBinary(input)).toBeNull(); + expect(paperclipRunnerBinaryNeedsBuild(input)).toBe(true); + }); + it("does not build a workspace binary when an explicit binary is configured", () => { expect( paperclipRunnerBinaryNeedsBuild({ diff --git a/server/src/__tests__/remove-test-home.test.ts b/server/src/__tests__/remove-test-home.test.ts index 4eb8cc182d2..0b4a5baf82b 100644 --- a/server/src/__tests__/remove-test-home.test.ts +++ b/server/src/__tests__/remove-test-home.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { removeTestHome } from "./remove-test-home.js"; @@ -49,7 +49,9 @@ describe("removeTestHome", () => { // The plain removal the setup file used to run is what fails in CI. if (process.platform !== "win32" && process.getuid?.() !== 0) { - expect(() => fs.rmSync(home, { recursive: true, force: true })).toThrow(/EACCES|EPERM/); + expect(() => fs.rmSync(home, { recursive: true, force: true })).toThrow( + process.platform === "darwin" ? /EACCES|EPERM|ENOTEMPTY/ : /EACCES|EPERM/, + ); expect(fs.existsSync(bundle)).toBe(true); } @@ -61,6 +63,18 @@ describe("removeTestHome", () => { const home = makeHome(); fs.mkdirSync(path.join(home, "instances", "default"), { recursive: true }); fs.writeFileSync(path.join(home, "instances", "default", "paperclip.json"), "{}\n"); + // An unrelated ENOTEMPTY must not cause permission changes or be swallowed. + const error = Object.assign(new Error("unrelated nonempty directory"), { code: "ENOTEMPTY" }); + const removal = vi.spyOn(fs, "rmSync").mockImplementation(() => { throw error; }); + const permissionChange = vi.spyOn(fs, "chmodSync"); + try { + expect(() => removeTestHome(home)).toThrow(error); + expect(removal).toHaveBeenCalledTimes(1); + expect(permissionChange).not.toHaveBeenCalled(); + } finally { + removal.mockRestore(); + permissionChange.mockRestore(); + } removeTestHome(home); expect(fs.existsSync(home)).toBe(false); expect(() => removeTestHome(home)).not.toThrow(); diff --git a/server/src/__tests__/remove-test-home.ts b/server/src/__tests__/remove-test-home.ts index 524b1e34f67..1f2260f9bb2 100644 --- a/server/src/__tests__/remove-test-home.ts +++ b/server/src/__tests__/remove-test-home.ts @@ -9,7 +9,8 @@ import path from "node:path"; * in them read-only (`makeDirectoriesReadOnly` in * services/native-runtime/runtime-context.ts), and a run that is still * finishing when the worker exits can leave such a bundle under the worker's - * home. Removing a file inside a read-only directory fails with `EACCES`, + * home. Removing a file inside a read-only directory fails with `EACCES` + * or, on macOS, the recursive removal can report `ENOTEMPTY` instead. * Vitest reports it as an unhandled error, and the shard fails after every * test passed. So on a permission error every directory in the tree is made * writable first and the removal is tried once more. @@ -19,8 +20,12 @@ export function removeTestHome(home: string): void { try { fs.rmSync(home, options); } catch (error) { - if (!isPermissionError(error)) throw error; - makeDirectoriesWritable(home); + const permissionError = isPermissionError(error); + if (!permissionError && (error as NodeJS.ErrnoException | null)?.code !== "ENOTEMPTY") throw error; + const restored = makeDirectoriesWritable(home); + // ENOTEMPTY also has unrelated causes; retry only after restoring an + // actual read-only directory, and still surface a failed second removal. + if (!permissionError && !restored) throw error; fs.rmSync(home, options); } } @@ -30,28 +35,33 @@ function isPermissionError(error: unknown): boolean { return code === "EACCES" || code === "EPERM"; } -function makeDirectoriesWritable(directory: string): void { +function makeDirectoriesWritable(directory: string): boolean { let stat: fs.Stats; try { stat = fs.lstatSync(directory); } catch { - return; + return false; } - if (!stat.isDirectory()) return; + if (!stat.isDirectory()) return false; // Owner read, write and search: enough to list and unlink the children. // A symlink is not followed, so nothing outside the home is touched. - try { - fs.chmodSync(directory, stat.mode | 0o700); - } catch { - // A directory that cannot be changed still fails the removal below, loudly. + let restored = false; + if ((stat.mode & 0o700) !== 0o700) { + try { + fs.chmodSync(directory, stat.mode | 0o700); + restored = true; + } catch { + // A directory that cannot be changed still fails the removal below, loudly. + } } let entries: fs.Dirent[]; try { entries = fs.readdirSync(directory, { withFileTypes: true }); } catch { - return; + return restored; } for (const entry of entries) { - if (entry.isDirectory()) makeDirectoriesWritable(path.join(directory, entry.name)); + if (entry.isDirectory() && makeDirectoriesWritable(path.join(directory, entry.name))) restored = true; } + return restored; } diff --git a/server/src/services/local-ai-browser-login.test.ts b/server/src/services/local-ai-browser-login.test.ts index 6bfc0d36365..505b0bee18c 100644 --- a/server/src/services/local-ai-browser-login.test.ts +++ b/server/src/services/local-ai-browser-login.test.ts @@ -2,6 +2,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { mkdtemp, mkdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { createRequire } from "node:module"; import { startLocalBrowserLogin } from "./local-ai-browser-login.js"; import { resolveCodexCommand } from "../vendor/paperclip-runner/index.js"; @@ -18,6 +20,7 @@ afterEach(async () => { Object.defineProperty(process, "arch", initialArchitecture); process.env.PATH = initialPath; vi.unstubAllEnvs(); + vi.restoreAllMocks(); vi.clearAllMocks(); if (root) await rm(root, { recursive: true, force: true }); root = undefined; @@ -35,14 +38,38 @@ async function fakeCli(name: string, source: string) { return realpath(home); } -function targetPlatform(platform: string, architecture: string) { +function targetPlatform(platform: string, architecture: string, glibc = true, glibcVersionRuntime = "2.39") { Object.defineProperty(process, "platform", { ...initialPlatform, value: platform }); Object.defineProperty(process, "arch", { ...initialArchitecture, value: architecture }); + if (platform === "linux") vi.spyOn(process.report, "getReport").mockReturnValue({ header: glibc ? { glibcVersionRuntime } : {} } as never); +} + +function resolveFixtureCodexCommand(home: string): string { + // Vitest's global dependency paths can supply a foreign bridge before the + // fixture wrapper. Reuse the existing Codex security test's closed Node seam. + const source = ` + import { resolveCodexCommand } from ${JSON.stringify(new URL("../../../packages/paperclip-runner/src/drivers/codex/codex-command.ts", import.meta.url).href)}; + try { console.log(JSON.stringify({ command: resolveCodexCommand(${JSON.stringify(path.join(root!, "issuer.js"))}, { PATH: "/missing-codex" }, ${JSON.stringify(home)}) })); } + catch (error) { console.log(JSON.stringify({ error: error.message })); } + `; + const result = JSON.parse(execFileSync(process.execPath, [ + "--no-global-search-paths", "--import", createRequire(import.meta.url).resolve("tsx"), + "--input-type=module", "--eval", source, + ], { cwd: root, env: { PATH: "/usr/bin:/bin", NODE_PATH: "", NODE_OPTIONS: "" }, + encoding: "utf8", timeout: 10000, maxBuffer: 16384 })); + if (typeof result.error === "string") throw new Error(result.error); + if (typeof result.command !== "string") throw new Error("Fixture resolver returned no executable"); + return result.command; } describe.skipIf(process.platform === "win32")("local browser subscription login", () => { - it.each([false, true])("uses the selected Codex command with its credential home and ambient HOME/cwd (legacy Linux ARM64: %s)", async (legacy) => { - if (legacy) targetPlatform("linux", "arm64"); + it.each(["native", "arm32", "musl", "glibc-2.38", "glibc-3.0", "unknown"])("uses the selected Codex command with its credential home and ambient HOME/cwd (%s)", async platform => { + const legacy = platform === "arm32"; + if (platform === "arm32") targetPlatform("linux", "arm"); + if (platform === "musl") targetPlatform("linux", "arm64", false); + if (platform === "glibc-2.38") targetPlatform("linux", "arm64", true, "2.38"); + if (platform === "glibc-3.0") targetPlatform("linux", "arm64", true, "3.0"); + if (platform === "unknown") targetPlatform("linux", "x64", true, "unknown"); const home = await fakeCli("codex", [ '[ "$1 $2" = "login --device-auth" ] || exit 11', '[ "$HOME" = "$CLAUDE_CONFIG_DIR" ] && [ "$HOME" != "$CODEX_HOME" ] && [ "$CODEX_HOME" = "$PAPERCLIP_TEST_LOGIN_CODEX_HOME" ] || exit 12', @@ -60,13 +87,60 @@ describe.skipIf(process.platform === "win32")("local browser subscription login" for (const key of ["OPENAI_API_KEY", "CODEX_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"]) vi.stubEnv(key, "unrelated-host-value"); const login = startLocalBrowserLogin("openai", home); - await vi.waitFor(() => expect(login.outcome).toBe("success"), { timeout: 5000 }); + await vi.waitFor(() => expect(login.outcome).toBeDefined(), { timeout: 5000 }); + expect(login.outcome).toBe("success"); if (legacy) expect(resolveCodexCommand).not.toHaveBeenCalled(); else expect(resolveCodexCommand).toHaveBeenCalledOnce(); expect(login.code).toBe("ABCD-EFGHJ"); }); - it.each([["linux", "x64"], ["darwin", "arm64"], ["darwin", "x64"]] as const)("reports an unavailable Codex CLI on %s/%s without revealing private details", async (platform, architecture) => { + it.each(["x64", "arm64"].flatMap(architecture => ["2.38", "musl", "unknown"].map(libc => ({ architecture, libc }))))("uses the installed Codex dependency for login on Linux $architecture/$libc without a global CLI", async ({ architecture, libc }) => { + targetPlatform("linux", architecture, libc !== "musl", libc); + const home = await fakeCli("codex", [ + 'echo owned > "$CODEX_HOME/owned-cli"', + 'printf "1. Open this link in your browser and sign in to your account\\nhttps://auth.openai.com/codex/device\\n2. Enter this one-time code (expires in 15 minutes)\\nABCD-EFGHJ\\n"', + ].join("\n")); + const dependency = path.join(root!, "node_modules/@openai/codex"); + await mkdir(path.join(dependency, "bin"), { recursive: true }); + await writeFile(path.join(dependency, "package.json"), JSON.stringify({ name: "@openai/codex", version: "0.1.0", bin: { codex: "bin/codex" } })); + await writeFile(path.join(dependency, "bin/codex"), await readFile(path.join(root!, "bin/codex")), { mode: 0o700 }); + const command = resolveFixtureCodexCommand(home); + expect(command).toBe(await realpath(path.join(dependency, "bin/codex"))); + vi.mocked(resolveCodexCommand).mockReturnValue(command); + process.env.PATH = "/usr/bin:/bin"; + const login = startLocalBrowserLogin("openai", home); + try { + await vi.waitFor(() => expect(login.outcome).toBeDefined(), { timeout: 5000 }); + expect(login.outcome).toBe("success"); + expect(await readFile(path.join(home, "owned-cli"), "utf8")).toBe("owned\n"); + expect(resolveCodexCommand).toHaveBeenCalledOnce(); + expect(process.report.getReport).not.toHaveBeenCalled(); + } finally { login.abort(); } + }); + + it.each(["2.38", "musl", "unknown"])("reports a real owned-dependency containment failure on Linux/%s without falling back to another CLI", async libc => { + targetPlatform("linux", "arm64", libc !== "musl", libc); + const home = await fakeCli("codex", 'echo invoked > "$CODEX_HOME/unreviewed-cli"'); + const dependency = path.join(root!, "node_modules/@openai/codex"); + await mkdir(dependency, { recursive: true }); + await writeFile(path.join(dependency, "package.json"), JSON.stringify({ name: "@openai/codex", version: "0.1.0", bin: { codex: "../outside-codex" } })); + let resolutionError: unknown; + try { resolveFixtureCodexCommand(home); } catch (error) { resolutionError = error; } + expect(resolutionError).toBeInstanceOf(Error); + expect((resolutionError as Error).message).toContain("executable escapes its package"); + vi.mocked(resolveCodexCommand).mockImplementation(() => { throw resolutionError; }); + const login = startLocalBrowserLogin("openai", home); + try { + await vi.waitFor(() => expect(login.outcome).toBeDefined(), { timeout: 5000 }); + expect(login.outcome).toBe("failure"); + expect(login.error).toMatch(/requires an installed Codex CLI.*Install Codex/); + expect(JSON.stringify(login)).not.toContain(root!); + await expect(readFile(path.join(home, "unreviewed-cli"))).rejects.toMatchObject({ code: "ENOENT" }); + expect(resolveCodexCommand).toHaveBeenCalledOnce(); + } finally { login.abort(); } + }); + + it.each([["linux", "x64"], ["linux", "arm64"], ["darwin", "arm64"], ["darwin", "x64"]] as const)("reports an unavailable Codex CLI on %s/%s without revealing private details", async (platform, architecture) => { targetPlatform(platform, architecture); const home = await fakeCli("codex", 'echo invoked > "$CODEX_HOME/unreviewed-cli"'); const failure = new Error("private-installation-path-or-provider-output"); @@ -79,8 +153,8 @@ describe.skipIf(process.platform === "win32")("local browser subscription login" expect(login.authorizationUrl).toBeUndefined(); }); - it("reports a missing Linux ARM64 Codex CLI without revealing the host PATH", async () => { - targetPlatform("linux", "arm64"); + it("reports a missing Linux ARM32 Codex CLI without revealing the host PATH", async () => { + targetPlatform("linux", "arm"); const home = await fakeCli("codex", "exit 0"); process.env.PATH = "/usr/bin:/bin"; const login = startLocalBrowserLogin("openai", home); diff --git a/server/src/services/local-ai-browser-login.ts b/server/src/services/local-ai-browser-login.ts index ad176b8618f..755a1e41573 100644 --- a/server/src/services/local-ai-browser-login.ts +++ b/server/src/services/local-ai-browser-login.ts @@ -56,10 +56,10 @@ export function startLocalBrowserLogin(provider: "anthropic" | "openai", home: s async start(_command: string, onData: (chunk: string) => void): Promise<{ exitCode: number | null }> { let executable = "claude"; if (provider === "openai") { - // Linux ARM64 retains its supported legacy CLI path; it is not a - // qualified native runner target. Other hosts prefer the installed - // dependency, then an existing CLI when the dependency is absent. - const legacyPlatform = process.platform === "linux" && process.arch === "arm64"; + // The installed Codex CLI is independent of the runner daemon's glibc floor. + // Supported CLI hosts use owned resolution; other platforms keep PATH lookup. + const legacyPlatform = !((process.platform === "linux" || process.platform === "darwin") + && (process.arch === "x64" || process.arch === "arm64")); try { if (legacyPlatform) { await ensureCommandResolvable("codex", home, process.env); 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 1d1cc710203..f5341c8a73e 100644 --- a/server/src/services/native-runtime/native-codex-runner.test.ts +++ b/server/src/services/native-runtime/native-codex-runner.test.ts @@ -9,9 +9,10 @@ import { buildNativeRunnerArguments, buildNativeRunnerPreparePayload, resolvePaperclipRunnerBinary, + 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 () => { @@ -45,6 +46,121 @@ describe("installed runner selection", () => { }); }); +describe("selected remote daemon target", () => { + function header(target: string) { + const bytes = Buffer.alloc(64); + if (target.startsWith("darwin")) { + bytes.writeUInt32LE(0xfeedfacf); bytes.writeUInt32LE(target.endsWith("arm64") ? 0x0100000c : 0x01000007, 4); + } else { + bytes.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); bytes.writeUInt16LE(target.endsWith("arm64") ? 183 : 62, 18); + } + return bytes; + } + const remote = (stdout: string, overrides = {}) => ({ execute: vi.fn(async () => ({ exitCode: 0, signal: null, timedOut: false, stdout, stderr: "", pid: null, startedAt: null, ...overrides })) }); + it.each([ + ["Linux", "aarch64", "linux", "arm64"], ["Linux", "x86_64", "linux", "x64"], + ["Darwin", "arm64", "darwin", "arm64"], ["Darwin", "x86_64", "darwin", "x64"], + ] as const)("uses the packaged %s/%s daemon independent of the controller", async (os, arch, platform, architecture) => { + const root = await mkdtemp(join(tmpdir(), "paperclip-ssh-daemon-")); + const binary = join(root, "paperclip-runnerd"); + try { + await writeFile(binary, header(`${platform}-${architecture}`)); + const select = vi.spyOn(runner, "defaultCapabilityRunnerdBinary").mockReturnValue(binary); + const target = remote(`${os}\n${arch}\n`); + expect(await resolveRemotePaperclipRunnerBinary({ runner: target, remoteCwd: "/selected-workspace" })).toBe(binary); + expect(select).toHaveBeenCalledExactlyOnceWith(platform, architecture); + 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 }); + if (scenario === "matching") expect(await request).toBe(binary); + else await expect(request).rejects.toThrow(scenario === "missing" ? "runner_remote_artifact_unavailable" : "runner_remote_artifact_platform_mismatch"); + expect(select).not.toHaveBeenCalled(); + } finally { await rm(root, { recursive: true, force: true }); } + }); + it("keeps missing package targets actionable without a controller or legacy fallback", async () => { + const select = vi.spyOn(runner, "defaultCapabilityRunnerdBinary").mockImplementation(() => { throw new Error("runner_remote_artifact_unavailable: install the matching release"); }); + await expect(resolveRemotePaperclipRunnerBinary({ runner: remote("Linux\naarch64\n"), remoteCwd: "/workspace" })).rejects.toThrow("runner_remote_artifact_unavailable"); + expect(select).toHaveBeenCalledExactlyOnceWith("linux", "arm64"); + }); + it.each([ + ["Linux\naarch64\n", { exitCode: 1 }], ["Linux\naarch64\n", { timedOut: true }], + ["Linux\nriscv64\n", {}], ["FreeBSD\nx86_64\n", {}], ["", {}], + ])("fails a missing, failed or unsupported platform probe (%s)", async (stdout, overrides) => { + const select = vi.spyOn(runner, "defaultCapabilityRunnerdBinary"); + await expect(resolveRemotePaperclipRunnerBinary({ runner: remote(stdout as string, overrides), remoteCwd: "/workspace" })).rejects.toThrow("runner_remote_artifact_platform_mismatch"); + expect(select).not.toHaveBeenCalled(); + }); +}); + describe("buildNativeRunnerArguments", () => { it("binds every durable identity without exposing the bootstrap ticket", () => { const args = buildNativeRunnerArguments({ diff --git a/server/src/services/native-runtime/native-codex-runner.ts b/server/src/services/native-runtime/native-codex-runner.ts index 14d25ed9a40..c5cda8a9942 100644 --- a/server/src/services/native-runtime/native-codex-runner.ts +++ b/server/src/services/native-runtime/native-codex-runner.ts @@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url"; import { and, eq } from "drizzle-orm"; import type { AdapterExecutionResult } from "@paperclipai/adapter-utils"; +import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/command-managed-runtime"; import type { Db } from "@paperclipai/db"; import { agentSessionGoalActions, agentTaskSessions } from "@paperclipai/db"; @@ -14,6 +15,7 @@ import { failRunnerGoalAction } from "../runner-goals.js"; import { createPaperclipRunnerAuthorizedToolSet, defaultCapabilityRunnerdBinary, + runnerBinaryTarget, type PaperclipSemanticToolDefinition, } from "../../vendor/paperclip-runner/index.js"; import { runnerPrpCoordinator } from "./runner-prp-coordinator.js"; @@ -206,6 +208,38 @@ export function resolvePaperclipRunnerBinary( ); } +/** Select controller-owned bytes for the actual remote host, never its controller. */ +export async function resolveRemotePaperclipRunnerBinary(input: { + runner: CommandManagedRuntimeRunner; + remoteCwd: string; + configuredPath?: string | null; +}): Promise<string> { + const probe = await input.runner.execute({ command: "sh", args: ["-c", "uname -s; uname -m"], + cwd: input.remoteCwd, bypassSession: true, timeoutMs: 10_000 }); + const [os, arch] = probe.stdout.trim().split(/\r?\n/); + 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 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"); + // 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 }); } + if (runnerBinaryTarget(bytes) !== `${platform}-${architecture}`) { + throw new Error(`runner_remote_artifact_platform_mismatch: the selected daemon does not match ${platform}-${architecture}; install the matching Paperclip release or configure PAPERCLIP_RUNNER_REMOTE_BINARY_PATH`); + } + return binary; +} + export function buildNativeRunnerArguments(input: { connectUrl: string; stateDirectory: string; 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 52844fcdd63..d49205ae65b 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -206,6 +206,7 @@ const state = vi.hoisted(() => ({ bundledPackManifestPath: null as string | null, bundledRunnerBinary: null as string | null, resolveRunnerBinary: vi.fn(() => "/tmp/paperclip-runnerd"), + resolveRemoteRunnerBinary: vi.fn(async (input: { configuredPath?: string | null }) => input.configuredPath || "/tmp/paperclip-runnerd"), release: null as null | (() => void), })); @@ -300,6 +301,7 @@ vi.mock("../issue-recovery-actions.js", () => ({ vi.mock("./native-codex-runner.js", () => ({ resolvePaperclipRunnerBinary: state.resolveRunnerBinary, + resolveRemotePaperclipRunnerBinary: state.resolveRemoteRunnerBinary, })); import { @@ -373,6 +375,7 @@ beforeEach(() => { state.bundledRunnerBinary = null; state.createAssignedMcpTools.mockReset(); state.resolveRunnerBinary.mockReset().mockReturnValue("/tmp/paperclip-runnerd"); + state.resolveRemoteRunnerBinary.mockReset().mockImplementation(async (input: { configuredPath?: string | null }) => input.configuredPath || "/tmp/paperclip-runnerd"); state.resolveCurrentWakeCommentsBinding.mockReset().mockResolvedValue(null); state.assertCurrentWakeCommentsRead.mockReset().mockResolvedValue(undefined); }); @@ -12203,22 +12206,20 @@ describe("runnerd provider runtime wiring", () => { it.each([ ...["current", "preinstalled-exact", "preinstalled-mismatch", "preinstalled-error", "preinstalled-timeout", "stale", "missing", "retained", "retained-mismatch", "retained-error", "retained-timeout", "retained-explicit"] - .map((image) => ({ image, version: "0.160.0", compatible: true })), + .map((image) => ({ image, version: "0.160.0" })), ...["0.149.0", "0.149.1", "0.153.4", "0.156.0", "0.160.1"] - .map((version) => ({ image: "current", version, compatible: true })), + .map((version) => ({ image: "current", version })), ...["0.148.9", "0.161.0", "1.0.0", "0.160.0-alpha.1", "unknown"] - .map((version) => ({ image: "current", version, compatible: false })), - ])("uses shared Codex and the server-owned replacement artifact (image=$image, Codex=$version)", async ({ image, version, compatible }) => { + .map((version) => ({ image: "current", version })), + ])("uses shared Codex and the server-owned replacement artifact (image=$image, Codex=$version)", async ({ image, version }) => { const retained = image.startsWith("retained"); const exactRetained = image === "retained" || image === "retained-explicit"; const needsReplacement = image !== "current" && image !== "preinstalled-exact" && !exactRetained; // 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( @@ -12300,16 +12301,10 @@ describe("runnerd provider runtime wiring", () => { controlPlaneRegistration: (authority: unknown) => Promise<unknown>; }; await expect(transport.controlPlaneRegistration({})).rejects.toThrow( - compatible ? "reached-preinstalled-codex-verification" : "runner_remote_provider_artifact_incompatible: supported Codex versions >=0.149.0 <0.161.0", + "reached-preinstalled-codex-verification", ); - if (!compatible) { - expect(syncIn).not.toHaveBeenCalled(); - expect(remoteExecute.mock.calls.some(([call]) => call.command === "npm" || call.args?.[1]?.includes("paperclip_codex_launcher_tmp"))).toBe(false); - expect(onLog).not.toHaveBeenCalledWith("stderr", expect.stringContaining("using compatible Codex")); - return; - } if (version !== "0.160.0") { - expect(onLog).toHaveBeenCalledWith("stderr", expect.stringContaining(`using compatible Codex ${version}`)); + expect(onLog).toHaveBeenCalledWith("stderr", expect.stringContaining(`using installed Codex ${version === "unknown" || version.includes("alpha") ? "unrecognized version" : version}`)); } if (needsReplacement) { expect(transport.runnerBinary).toBe(controllerArtifact); @@ -12355,12 +12350,9 @@ describe("runnerd provider runtime wiring", () => { ].map((scenario) => ({ ...scenario, recover: false })), { version: "0.156.0", model: "gpt-6.1-sol", floor: null, recover: true }, { version: "0.158.0", model: "gpt-6.1-sol", floor: null, recover: true }, - ])("verifies sandbox Codex $version against the prepared $model selection (floor=$floor, recover=$recover)", async ({ version, model, floor, recover }) => { - // A preinstalled Codex inside the compatibility window can still be too - // old for the configured model: the ChatGPT backend rejects every turn - // with "not supported when using Codex with a ChatGPT account". The - // verifier names the stale image instead of letting the run fail as an - // account problem. + ])("lets sandbox Codex $version reach actual model verification ($model, recover=$recover)", async ({ version, model, recover }) => { + // Version metadata is diagnostic. A working app-server reaches the model + // turn instead of being rejected or replaced by a static CLI version floor. const preparedModel = recover ? resolvePaperclipRunnerNativeProviderInput({ backend: "codex_app_server", adapterConfig: { provider: "codex", model }, codexCliVersion: version, }).model : model; @@ -12430,32 +12422,33 @@ describe("runnerd provider runtime wiring", () => { ); } await expect(transport.controlPlaneRegistration({})).rejects.toThrow( - floor - ? `runner_remote_provider_artifact_incompatible: ${model} requires Codex ${floor} or newer with ChatGPT sign-in, received ${version} from the sandbox image; promote a sandbox image with Codex 0.160.0 or configure PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC=@openai/codex@0.160.0` - : "reached-preinstalled-codex-verification", + "reached-preinstalled-codex-verification", ); - if (floor) { - // No npm spec is configured, so there is no fallback install; the run - // fails before launch instead of running against the stale CLI. - expect(remoteExecute.mock.calls.some(([call]) => call.command === "npm")).toBe(false); - expect(remoteExecute.mock.calls.some(([call]) => call.args?.[1]?.includes("paperclip_codex_launcher_tmp"))).toBe(false); - } + expect(remoteExecute.mock.calls.some(([call]) => call.command === "npm")).toBe(false); + }); - it("binds a remote launch to the configured controller-owned runner artifact", async () => { + 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/paperclip-runnerd"; + const controllerArtifact = `/controller/artifacts/${mode}/paperclip-runnerd`; + 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, @@ -12470,14 +12463,26 @@ describe("runnerd provider runtime wiring", () => { strictHostKeyChecking: true, }, }, - 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: explicit ? controllerArtifact : undefined })); + expect(state.resolveRunnerBinary).not.toHaveBeenCalled(); }); it.each([ diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 92501cfa942..b0fb8b56c98 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -18,11 +18,7 @@ import { copyBackGrokAuth } from "@paperclipai/adapter-grok-local/server"; import { inferOpenAiCompatibleBiller, type AdapterUsageCheckpoint } from "@paperclipai/adapter-utils"; import { createAgentIdentityRedactor } from "../agent-identity-redaction.js"; -import { - isSupportedRemoteCodexVersion, - parseCodexCliVersion, - REMOTE_CODEX_SUPPORTED_RANGE, -} from "./codex-runtime-compatibility.js"; +import { parseCodexCliVersion } from "./codex-runtime-compatibility.js"; import { createNativeToolTrace, type NativeToolTrace } from "./native-tool-trace.js"; import { createNativeProviderFailureObservation } from "./native-provider-failure.js"; import { createNativeGitHubAccess, type NativeGitHubAccess } from "./native-github-access.js"; @@ -31,11 +27,6 @@ import { bindManagedNativeCredentialTurn, completeManagedNativeCredentialTurn } import { createLocalNativeQuestionBridge } from "./local-native-question-bridge.js"; import { readVerifiedRemoteWorkspaceFile } from "./remote-deliverable-file.js"; import { copyBackCodexAuth } from "@paperclipai/adapter-codex-local/server"; -import { - codexCliVersionAtLeast, - minimumCodexCliVersionForModel, - normalizeCodexModel, -} from "@paperclipai/adapter-codex-local"; import { nativeCompletionFeedback } from "./native-completion-feedback.js"; import { hasAcknowledgedNativeReassignmentStopIntent, hasAcknowledgedNativeStopIntent } from "../acknowledged-native-stop.js"; import { stoppedCodexTurnIsTextOnly } from "./stopped-codex-turn.js"; @@ -181,7 +172,7 @@ import { } from "./status-arbiter.js"; import { conflict, HttpError } from "../../errors.js"; import { redactSensitiveText } from "../../redaction.js"; -import { resolvePaperclipRunnerBinary } from "./native-codex-runner.js"; +import { resolvePaperclipRunnerBinary, resolveRemotePaperclipRunnerBinary } from "./native-codex-runner.js"; import { createNativeRunTrace, isNativeRunRootHistoricalSpan, @@ -10193,7 +10184,7 @@ export function assertRemoteRunnerBuildMetadata( } } -async function stageRemoteRunnerFile(input: { +export async function stageRemoteRunnerFile(input: { target: Extract<AdapterExecutionTarget, { kind: "remote" }>; runner: CommandManagedRuntimeRunner; sourcePath: string; @@ -10585,7 +10576,7 @@ export async function syncRemoteRunnerDirectoryOut(input: { } } -async function readRemoteRunnerState(input: { +export async function readRemoteRunnerState(input: { runner: CommandManagedRuntimeRunner; stateDirectory: string; }): Promise<Record<string, unknown>> { @@ -11560,7 +11551,10 @@ 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 controllerRunnerBinary = remoteTarget + const targetMatchedCodexArtifact = remoteTarget !== null && input.execution.provider.kind === "codex"; + const controllerRunnerBinary = targetMatchedCodexArtifact + ? await resolveRemotePaperclipRunnerBinary({ runner: remoteCommandRunner!, remoteCwd: remoteTarget.remoteCwd, configuredPath: input.runnerRemoteBinaryPath }) + : remoteTarget ? input.runnerRemoteBinaryPath?.trim() || remotePiCompanion?.runnerBinary || (useBundledRemoteImageAssets ? bundledRemoteRunnerBinary() : resolvePaperclipRunnerBinary()) : resolvePaperclipRunnerBinary(); const explicitRemoteCodex = input.runnerRemoteCodexPath?.trim() || null; @@ -11651,76 +11645,6 @@ async function createRunnerdBackendWithinSessionClaim( }) : Promise.resolve(); - const verifyRemoteRunner = async ( - requiredMode: "dial_wss" | "listen_ws", - executable = remoteBinary, - ) => { - if (!remoteTarget || !remoteCommandRunner || !executable) return; - const metadataResult = await remoteCommandRunner.execute({ - command: executable, - args: ["--build-metadata"], - cwd: remoteTarget.remoteCwd, - bypassSession: true, - timeoutMs: 30_000, - }); - if (metadataResult.exitCode !== 0 || metadataResult.timedOut) { - throw new Error("runner_remote_artifact_verification_failed"); - } - let metadata: Record<string, unknown>; - try { - metadata = JSON.parse(metadataResult.stdout) as Record<string, unknown>; - } catch (error) { - throw new Error("runner_remote_artifact_metadata_invalid", { - cause: error, - }); - } - assertRemoteRunnerBuildMetadata(metadata, requiredMode); - }; - - const reportedCodexVersions = new Set<string>(); - const verifyRemoteCodex = async (executable = remoteCodexBinary) => { - if (!remoteTarget || !remoteCommandRunner || !executable) return; - const versionResult = await remoteCommandRunner.execute({ - command: executable, - args: ["--version"], - cwd: remoteTarget.remoteCwd, - bypassSession: true, - timeoutMs: 30_000, - }); - if (versionResult.exitCode !== 0 || versionResult.timedOut) { - throw new Error("runner_remote_codex_artifact_verification_failed"); - } - const versionOutput = `${versionResult.stdout}\n${versionResult.stderr}`; - const version = parseCodexCliVersion(versionOutput); - if (!version || !isSupportedRemoteCodexVersion(version)) { - throw new Error( - `runner_remote_provider_artifact_incompatible: supported Codex versions ${REMOTE_CODEX_SUPPORTED_RANGE}, received ${version ?? "an unrecognized or prerelease version"}; install a supported stable Codex release or configure PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC=@openai/codex@${REMOTE_PROVIDER_PACK_PINS.codex}`, - ); - } - // A Codex inside the compatibility window can still be too old for the - // configured model: the ChatGPT backend rejects a model from clients - // below the model's floor on every turn. Fail before launch with the - // exact gap, so a stale sandbox image is not reported as an account - // problem. When a preinstalled Codex fails here and an npm spec is - // configured, the caller falls back to installing the pinned release. - const configuredModel = input.execution.provider.kind === "codex" - ? input.execution.provider.model - : null; - const modelMinimum = minimumCodexCliVersionForModel(configuredModel); - if (modelMinimum && !codexCliVersionAtLeast(version, modelMinimum)) { - throw new Error( - `runner_remote_provider_artifact_incompatible: ${normalizeCodexModel(configuredModel)} requires Codex ${modelMinimum} or newer with ChatGPT sign-in, received ${version} from the sandbox image; promote a sandbox image with Codex ${REMOTE_PROVIDER_PACK_PINS.codex} or configure PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC=@openai/codex@${REMOTE_PROVIDER_PACK_PINS.codex}`, - ); - } - if (version !== REMOTE_PROVIDER_PACK_PINS.codex && !reportedCodexVersions.has(version)) { - reportedCodexVersions.add(version); - await input.onLog?.( - "stderr", - `[paperclip-runner] using compatible Codex ${version} (supported ${REMOTE_CODEX_SUPPORTED_RANGE}; install pin ${REMOTE_PROVIDER_PACK_PINS.codex})\n`, - ); - } - }; - const verifyRemoteProviderPack = async (packRoot: string) => { if (!remoteTarget || !remoteCommandRunner || !expectedProviderPackManifest) return; @@ -11805,41 +11729,20 @@ async function createRunnerdBackendWithinSessionClaim( return parseRemoteExecutableCandidate(result.stdout); }; - // Image policy: keep one latest stable CLI installation shared by native and - // local adapters. Preferred bin entries must point to that same installation; - // never bake an older global CLI alongside a private runner-only version. - const discoverPreinstalledExecutable = async ( - name: "paperclip-runnerd" | "codex", - ) => { - if (!remoteTarget || !remoteCommandRunner) return null; - return discoverRemoteExecutable(remoteCommandRunner, remoteTarget.remoteCwd, name); - }; - - const linkPreinstalledExecutable = async ( - sourcePath: string, - targetPath: string, - ) => { - if (!remoteTarget || !remoteCommandRunner) return; - const escapedSource = sourcePath.replaceAll("'", "'\\''"); - const escapedTarget = targetPath.replaceAll("'", "'\\''"); - const escapedDirectory = posix.dirname(targetPath).replaceAll("'", "'\\''"); - const result = await remoteCommandRunner.execute({ - command: "sh", - args: [ - "-c", - targetPath === remoteCodexBinary - ? buildRemoteCodexLauncherCommand(sourcePath, targetPath) - : `umask 077; mkdir -p '${escapedDirectory}' && ` + - `ln -sfn '${escapedSource}' '${escapedTarget}'`, - ], - cwd: remoteTarget.remoteCwd, - bypassSession: true, - timeoutMs: 10_000, - }); - if (result.exitCode !== 0 || result.timedOut) { - throw new Error("runner_remote_preinstalled_link_failed"); - } + const remoteNativeArtifacts = remoteTarget && remoteCommandRunner && remoteBinary && remoteRuntimeRoot + ? createRemoteNativeArtifactPreparation({ + target: remoteTarget, runner: remoteCommandRunner, remoteBinary, controllerRunnerBinary, + remoteRuntimeRoot, remoteCodexBinary, runnerRemoteBinaryPath: input.runnerRemoteBinaryPath, + runnerRemoteCodexPath: input.runnerRemoteCodexPath, runnerRemoteCodexNpmSpec: input.runnerRemoteCodexNpmSpec, + model: input.execution.provider.kind === "codex" ? input.execution.provider.model : null, + reuseRetainedRunner: sandboxLeaseAcquisition?.outcome === "resumed", targetPlatformArtifactVerified: remotePiCompanion !== null || useBundledRemoteImageAssets || targetMatchedCodexArtifact, + trace: input.trace, onLog: input.onLog, + }) + : null; + const verifyRemoteRunner = async (mode: "dial_wss" | "listen_ws", executable?: string | null) => { + if (remoteNativeArtifacts) await remoteNativeArtifacts.verifyRemoteRunner(mode, executable ?? undefined); }; + const verifyRemoteCodex = async () => { await remoteNativeArtifacts?.verifyRemoteCodex(); }; const prepareRemoteRunner = async ( requiredMode: "dial_wss" | "listen_ws", @@ -11861,234 +11764,7 @@ async function createRunnerdBackendWithinSessionClaim( remotePrepared = true; return; } - // Compatibility metadata does not prove artifact identity. Reuse only the - // exact controller-owned bytes when the controller artifact is available. - const matchesControllerRunnerArtifact = async (executable: string): Promise<boolean> => { - const expected = createHash("sha256") - .update(readFileSync(controllerRunnerBinary)) - .digest("hex"); - try { - const probe = await remoteCommandRunner.execute({ - command: "sh", - args: [ - "-c", - 'test -x "$1" || exit 1; if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1"; else shasum -a 256 "$1"; fi', - "paperclip-runner-artifact", - executable, - ], - cwd: remoteTarget.remoteCwd, - bypassSession: true, - timeoutMs: 10_000, - }); - return probe.exitCode === 0 && !probe.timedOut && - /^[a-f0-9]{64}\s/.test(probe.stdout) && - probe.stdout.trim().split(/\s+/)[0] === expected; - } catch { - // An unavailable checksum uses the verified staging path. - return false; - } - }; - let runnerArtifactPrepared = false; - if ( - sandboxLeaseAcquisition?.outcome === "resumed" && - existsSync(controllerRunnerBinary) - ) { - runnerArtifactPrepared = await measureNativeRunnerSpan( - input.trace, - "runner.artifact.verify_retained", - () => matchesControllerRunnerArtifact(remoteBinary), - ); - } - const explicitRemoteBinary = input.runnerRemoteBinaryPath?.trim() || null; - if (!runnerArtifactPrepared && mayUsePreinstalledRunnerArtifact(explicitRemoteBinary)) { - const preinstalledRunner = await measureNativeRunnerSpan( - input.trace, - "runner.artifact.discover", - () => discoverPreinstalledExecutable("paperclip-runnerd"), - ); - if (preinstalledRunner) { - try { - await measureNativeRunnerSpan( - input.trace, - "runner.artifact.verify_preinstalled", - () => verifyRemoteRunner(requiredMode, preinstalledRunner), - ); - if (existsSync(controllerRunnerBinary) && - !await matchesControllerRunnerArtifact(preinstalledRunner)) { - throw new Error("runner_remote_preinstalled_artifact_mismatch"); - } - await measureNativeRunnerSpan( - input.trace, - "runner.artifact.link", - () => linkPreinstalledExecutable(preinstalledRunner, remoteBinary), - ); - runnerArtifactPrepared = true; - await input.onLog?.( - "stderr", - "[paperclip-runner] using preinstalled runnerd from the sandbox image\n", - ); - } catch { - runnerArtifactPrepared = false; - } - } - } - if (!runnerArtifactPrepared) { - // Upload the same artifact used for the controller identity. The server - // vendors the runner under vendor/paperclip-runner/bin, so the package - // development fallback cannot locate it in a deployed server. - const sourceBinary = controllerRunnerBinary; - if (!existsSync(sourceBinary)) { - throw new Error("runner_remote_artifact_unavailable"); - } - if (!explicitRemoteBinary && !remotePiCompanion && !useBundledRemoteImageAssets) { - const platform = await remoteCommandRunner.execute({ - command: "sh", - args: ["-c", "uname -s; uname -m"], - cwd: remoteTarget.remoteCwd, - bypassSession: true, - timeoutMs: 10_000, - }); - const [remoteOs = "", remoteArch = ""] = platform.stdout - .trim() - .split(/\r?\n/); - const localOs = - process.platform === "darwin" - ? "Darwin" - : process.platform === "linux" - ? "Linux" - : process.platform; - const localArch = - process.arch === "x64" - ? "x86_64" - : process.arch === "arm64" - ? "aarch64" - : process.arch; - const archMatches = - remoteArch === localArch || - (localArch === "aarch64" && remoteArch === "arm64"); - if ( - platform.exitCode !== 0 || - platform.timedOut || - remoteOs !== localOs || - !archMatches - ) { - throw new Error( - "runner_remote_artifact_platform_mismatch: configure PAPERCLIP_RUNNER_REMOTE_BINARY_PATH for the remote OS and architecture", - ); - } - } - await stageRemoteRunnerFile({ - target: remoteTarget, - runner: remoteCommandRunner, - sourcePath: sourceBinary, - targetPath: remoteBinary, - mode: 0o700, - }); - } - await measureNativeRunnerSpan(input.trace, "runner.artifact.verify", () => - verifyRemoteRunner(requiredMode), - ); - if (remoteCodexBinary && explicitRemoteCodex) { - if (!existsSync(explicitRemoteCodex)) { - throw new Error("runner_remote_codex_artifact_unavailable"); - } - await stageRemoteRunnerFile({ - target: remoteTarget, - runner: remoteCommandRunner, - sourcePath: explicitRemoteCodex, - targetPath: remoteCodexBinary, - mode: 0o700, - }); - await verifyRemoteCodex(); - } - if (remoteCodexBinary && remoteCodexNpmSpec) { - let usedPreinstalledCodex = false; - const preinstalledCodex = await measureNativeRunnerSpan( - input.trace, - "harness.artifact.discover", - () => discoverPreinstalledExecutable("codex"), - ); - if (preinstalledCodex) { - try { - await measureNativeRunnerSpan( - input.trace, - "harness.artifact.verify_preinstalled", - () => verifyRemoteCodex(preinstalledCodex), - ); - await measureNativeRunnerSpan( - input.trace, - "harness.artifact.link", - () => - linkPreinstalledExecutable(preinstalledCodex, remoteCodexBinary), - ); - usedPreinstalledCodex = true; - await input.onLog?.( - "stderr", - "[paperclip-runner] using preinstalled Codex from the sandbox image\n", - ); - } catch { - usedPreinstalledCodex = false; - } - } - if (!usedPreinstalledCodex) { - const installRoot = posix.join( - remoteRuntimeRoot!, - "harnesses", - "codex", - ); - const installResult = await remoteCommandRunner.execute({ - command: "npm", - args: [ - "install", - "--prefix", - installRoot, - "--no-audit", - "--no-fund", - remoteCodexNpmSpec, - ], - cwd: remoteTarget.remoteCwd, - bypassSession: true, - timeoutMs: 180_000, - }); - if (installResult.exitCode !== 0 || installResult.timedOut) { - throw new Error("runner_remote_codex_install_failed"); - } - } - await measureNativeRunnerSpan( - input.trace, - "harness.artifact.verify", - () => verifyRemoteCodex(), - ); - } - if (remoteCodexBinary && !explicitRemoteCodex && !remoteCodexNpmSpec) { - const preinstalledCodex = await measureNativeRunnerSpan( - input.trace, - "harness.artifact.discover", - () => discoverPreinstalledExecutable("codex"), - ); - if (!preinstalledCodex) { - throw new Error( - "runner_remote_codex_artifact_unavailable: install codex in the sandbox image or configure PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC", - ); - } - await measureNativeRunnerSpan( - input.trace, - "harness.artifact.verify_preinstalled", - () => verifyRemoteCodex(preinstalledCodex), - ); - await measureNativeRunnerSpan(input.trace, "harness.artifact.link", () => - linkPreinstalledExecutable(preinstalledCodex, remoteCodexBinary), - ); - await measureNativeRunnerSpan( - input.trace, - "harness.artifact.verify", - () => verifyRemoteCodex(), - ); - await input.onLog?.( - "stderr", - "[paperclip-runner] using preinstalled Codex from the sandbox image\n", - ); - } + await remoteNativeArtifacts?.prepare(requiredMode); if ( requiresRemoteProviderPack && expectedProviderPackManifest && @@ -13712,3 +13388,350 @@ async function createRunnerdBackendWithinSessionClaim( }, } satisfies NativeSessionBackend & { bindManagedSession(session: NativeSession): NativeSession }; } + +/** Shared task/setup preparation; setup exercises the same remote artifact contract. */ +export function createRemoteNativeArtifactPreparation(input: { + target: Extract<AdapterExecutionTarget, { kind: "remote" }>; + runner: CommandManagedRuntimeRunner; + remoteBinary: string; + controllerRunnerBinary: string; + remoteRuntimeRoot: string; + remoteCodexBinary?: string | null; + runnerRemoteBinaryPath?: string | null; + runnerRemoteCodexPath?: string | null; + runnerRemoteCodexNpmSpec?: string | null; + model?: string | null; + reuseRetainedRunner?: boolean; + /** The selected Pi companion or bundled provider image verifies its target-platform artifact. */ + targetPlatformArtifactVerified?: boolean; + trace?: NativeRunTrace; + onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>; +}) { + const remoteTarget = input.target; + const remoteCommandRunner = input.runner; + const { remoteBinary, controllerRunnerBinary, remoteRuntimeRoot } = input; + const remoteCodexBinary = input.remoteCodexBinary ?? null; + const explicitRemoteCodex = input.runnerRemoteCodexPath?.trim() || null; + const remoteCodexNpmSpec = input.runnerRemoteCodexNpmSpec?.trim() || null; + if (explicitRemoteCodex && remoteCodexNpmSpec) throw new Error("runner_remote_codex_source_conflict"); + const verifyRemoteRunner = async ( + requiredMode: "dial_wss" | "listen_ws", + executable = remoteBinary, + ) => { + if (!remoteTarget || !remoteCommandRunner || !executable) return; + const metadataResult = await remoteCommandRunner.execute({ + command: executable, + args: ["--build-metadata"], + cwd: remoteTarget.remoteCwd, + bypassSession: true, + timeoutMs: 30_000, + }); + if (metadataResult.exitCode !== 0 || metadataResult.timedOut) { + throw new Error("runner_remote_artifact_verification_failed"); + } + let metadata: Record<string, unknown>; + try { + metadata = JSON.parse(metadataResult.stdout) as Record<string, unknown>; + } catch (error) { + throw new Error("runner_remote_artifact_metadata_invalid", { + cause: error, + }); + } + assertRemoteRunnerBuildMetadata(metadata, requiredMode); + }; + + const reportedCodexVersions = new Set<string | null>(); + const verifyRemoteCodex = async (executable = remoteCodexBinary) => { + if (!remoteTarget || !remoteCommandRunner || !executable) return; + const versionResult = await remoteCommandRunner.execute({ + command: executable, + args: ["--version"], + cwd: remoteTarget.remoteCwd, + bypassSession: true, + timeoutMs: 30_000, + }); + if (versionResult.exitCode !== 0 || versionResult.timedOut) { + throw new Error("runner_remote_codex_artifact_verification_failed"); + } + const versionOutput = `${versionResult.stdout}\n${versionResult.stderr}`; + const version = parseCodexCliVersion(versionOutput); + // The install pin describes our tested image, not a CLI admission rule. + // Ordinary installations reach the real app-server/model probe; a version + // difference alone must neither fail setup nor replace a working CLI. + if (version !== REMOTE_PROVIDER_PACK_PINS.codex && !reportedCodexVersions.has(version)) { + reportedCodexVersions.add(version); + await input.onLog?.( + "stderr", + `[paperclip-runner] using installed Codex ${version ?? "unrecognized version"} (image install pin ${REMOTE_PROVIDER_PACK_PINS.codex}); compatibility is checked by the native app-server\n`, + ); + } + }; + + // Image policy: keep one latest stable CLI installation shared by native and + // local adapters. Preferred bin entries must point to that same installation; + // never bake an older global CLI alongside a private runner-only version. + const discoverPreinstalledExecutable = async ( + name: "paperclip-runnerd" | "codex", + ) => { + if (!remoteTarget || !remoteCommandRunner) return null; + return discoverRemoteExecutable(remoteCommandRunner, remoteTarget.remoteCwd, name); + }; + + const linkPreinstalledExecutable = async ( + sourcePath: string, + targetPath: string, + ) => { + if (!remoteTarget || !remoteCommandRunner) return; + const escapedSource = sourcePath.replaceAll("'", "'\\''"); + const escapedTarget = targetPath.replaceAll("'", "'\\''"); + const escapedDirectory = posix.dirname(targetPath).replaceAll("'", "'\\''"); + const result = await remoteCommandRunner.execute({ + command: "sh", + args: [ + "-c", + targetPath === remoteCodexBinary + ? buildRemoteCodexLauncherCommand(sourcePath, targetPath) + : `umask 077; mkdir -p '${escapedDirectory}' && ` + + `ln -sfn '${escapedSource}' '${escapedTarget}'`, + ], + cwd: remoteTarget.remoteCwd, + bypassSession: true, + timeoutMs: 10_000, + }); + if (result.exitCode !== 0 || result.timedOut) { + throw new Error("runner_remote_preinstalled_link_failed"); + } + }; + + const prepare = async (requiredMode: "dial_wss" | "listen_ws") => { + // Compatibility metadata does not prove artifact identity. Reuse only the + // exact controller-owned bytes when the controller artifact is available. + const matchesControllerRunnerArtifact = async (executable: string): Promise<boolean> => { + const expected = createHash("sha256") + .update(readFileSync(controllerRunnerBinary)) + .digest("hex"); + try { + const probe = await remoteCommandRunner.execute({ + command: "sh", + args: [ + "-c", + 'test -x "$1" || exit 1; if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1"; else shasum -a 256 "$1"; fi', + "paperclip-runner-artifact", + executable, + ], + cwd: remoteTarget.remoteCwd, + bypassSession: true, + timeoutMs: 10_000, + }); + return probe.exitCode === 0 && !probe.timedOut && + /^[a-f0-9]{64}\s/.test(probe.stdout) && + probe.stdout.trim().split(/\s+/)[0] === expected; + } catch { + // An unavailable checksum uses the verified staging path. + return false; + } + }; + let runnerArtifactPrepared = false; + if ( + input.reuseRetainedRunner === true && + existsSync(controllerRunnerBinary) + ) { + runnerArtifactPrepared = await measureNativeRunnerSpan( + input.trace, + "runner.artifact.verify_retained", + () => matchesControllerRunnerArtifact(remoteBinary), + ); + } + const explicitRemoteBinary = input.runnerRemoteBinaryPath?.trim() || null; + if (!runnerArtifactPrepared && mayUsePreinstalledRunnerArtifact(explicitRemoteBinary)) { + const preinstalledRunner = await measureNativeRunnerSpan( + input.trace, + "runner.artifact.discover", + () => discoverPreinstalledExecutable("paperclip-runnerd"), + ); + if (preinstalledRunner) { + try { + await measureNativeRunnerSpan( + input.trace, + "runner.artifact.verify_preinstalled", + () => verifyRemoteRunner(requiredMode, preinstalledRunner), + ); + if (existsSync(controllerRunnerBinary) && + !await matchesControllerRunnerArtifact(preinstalledRunner)) { + throw new Error("runner_remote_preinstalled_artifact_mismatch"); + } + await measureNativeRunnerSpan( + input.trace, + "runner.artifact.link", + () => linkPreinstalledExecutable(preinstalledRunner, remoteBinary), + ); + runnerArtifactPrepared = true; + await input.onLog?.( + "stderr", + "[paperclip-runner] using preinstalled runnerd from the sandbox image\n", + ); + } catch { + runnerArtifactPrepared = false; + } + } + } + if (!runnerArtifactPrepared) { + // Upload the same artifact used for the controller identity. The server + // vendors the runner under vendor/paperclip-runner/bin, so the package + // development fallback cannot locate it in a deployed server. + const sourceBinary = controllerRunnerBinary; + if (!existsSync(sourceBinary)) { + throw new Error("runner_remote_artifact_unavailable"); + } + if (!explicitRemoteBinary && !input.targetPlatformArtifactVerified) { + const platform = await remoteCommandRunner.execute({ + command: "sh", + args: ["-c", "uname -s; uname -m"], + cwd: remoteTarget.remoteCwd, + bypassSession: true, + timeoutMs: 10_000, + }); + const [remoteOs = "", remoteArch = ""] = platform.stdout + .trim() + .split(/\r?\n/); + const localOs = + process.platform === "darwin" + ? "Darwin" + : process.platform === "linux" + ? "Linux" + : process.platform; + const localArch = + process.arch === "x64" + ? "x86_64" + : process.arch === "arm64" + ? "aarch64" + : process.arch; + const archMatches = + remoteArch === localArch || + (localArch === "aarch64" && remoteArch === "arm64"); + if ( + platform.exitCode !== 0 || + platform.timedOut || + remoteOs !== localOs || + !archMatches + ) { + throw new Error( + "runner_remote_artifact_platform_mismatch: configure PAPERCLIP_RUNNER_REMOTE_BINARY_PATH for the remote OS and architecture", + ); + } + } + await stageRemoteRunnerFile({ + target: remoteTarget, + runner: remoteCommandRunner, + sourcePath: sourceBinary, + targetPath: remoteBinary, + mode: 0o700, + }); + } + await measureNativeRunnerSpan(input.trace, "runner.artifact.verify", () => + verifyRemoteRunner(requiredMode), + ); + if (remoteCodexBinary && explicitRemoteCodex) { + if (!existsSync(explicitRemoteCodex)) { + throw new Error("runner_remote_codex_artifact_unavailable"); + } + await stageRemoteRunnerFile({ + target: remoteTarget, + runner: remoteCommandRunner, + sourcePath: explicitRemoteCodex, + targetPath: remoteCodexBinary, + mode: 0o700, + }); + await verifyRemoteCodex(); + } + if (remoteCodexBinary && remoteCodexNpmSpec) { + let usedPreinstalledCodex = false; + const preinstalledCodex = await measureNativeRunnerSpan( + input.trace, + "harness.artifact.discover", + () => discoverPreinstalledExecutable("codex"), + ); + if (preinstalledCodex) { + try { + await measureNativeRunnerSpan( + input.trace, + "harness.artifact.verify_preinstalled", + () => verifyRemoteCodex(preinstalledCodex), + ); + await measureNativeRunnerSpan( + input.trace, + "harness.artifact.link", + () => + linkPreinstalledExecutable(preinstalledCodex, remoteCodexBinary), + ); + usedPreinstalledCodex = true; + await input.onLog?.( + "stderr", + "[paperclip-runner] using preinstalled Codex from the sandbox image\n", + ); + } catch { + usedPreinstalledCodex = false; + } + } + if (!usedPreinstalledCodex) { + const installRoot = posix.join( + remoteRuntimeRoot!, + "harnesses", + "codex", + ); + const installResult = await remoteCommandRunner.execute({ + command: "npm", + args: [ + "install", + "--prefix", + installRoot, + "--no-audit", + "--no-fund", + remoteCodexNpmSpec, + ], + cwd: remoteTarget.remoteCwd, + bypassSession: true, + timeoutMs: 180_000, + }); + if (installResult.exitCode !== 0 || installResult.timedOut) { + throw new Error("runner_remote_codex_install_failed"); + } + } + await measureNativeRunnerSpan( + input.trace, + "harness.artifact.verify", + () => verifyRemoteCodex(), + ); + } + if (remoteCodexBinary && !explicitRemoteCodex && !remoteCodexNpmSpec) { + const preinstalledCodex = await measureNativeRunnerSpan( + input.trace, + "harness.artifact.discover", + () => discoverPreinstalledExecutable("codex"), + ); + if (!preinstalledCodex) { + throw new Error( + "runner_remote_codex_artifact_unavailable: install codex in the sandbox image or configure PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC", + ); + } + await measureNativeRunnerSpan( + input.trace, + "harness.artifact.verify_preinstalled", + () => verifyRemoteCodex(preinstalledCodex), + ); + await measureNativeRunnerSpan(input.trace, "harness.artifact.link", () => + linkPreinstalledExecutable(preinstalledCodex, remoteCodexBinary), + ); + await measureNativeRunnerSpan( + input.trace, + "harness.artifact.verify", + () => verifyRemoteCodex(), + ); + await input.onLog?.( + "stderr", + "[paperclip-runner] using preinstalled Codex from the sandbox image\n", + ); + } + }; + return { prepare, verifyRemoteRunner, verifyRemoteCodex }; +} diff --git a/server/src/vendor/paperclip-runner/index.ts b/server/src/vendor/paperclip-runner/index.ts index f4a9a810727..dd40545d7e5 100644 --- a/server/src/vendor/paperclip-runner/index.ts +++ b/server/src/vendor/paperclip-runner/index.ts @@ -110,6 +110,7 @@ export const createRunnerdCodexTransport: ( runner.createRunnerdCodexTransport; export const defaultCapabilityRunnerdBinary = runner.defaultCapabilityRunnerdBinary; +export const runnerBinaryTarget = runner.runnerBinaryTarget; export const executeNativeSession = runner.executeNativeSession; export const applyNativeSessionGoalControl = runner.applyNativeSessionGoalControl; @@ -159,3 +160,5 @@ export const CONFIGURED_ENVIRONMENT_KEYS = runner.CONFIGURED_ENVIRONMENT_KEYS; export const GENERATED_RUNTIME_ENVIRONMENT_KEYS = runner.GENERATED_RUNTIME_ENVIRONMENT_KEYS; export const configuredEnvironmentProjection = runner.configuredEnvironmentProjection; export const configuredEnvironment = runner.configuredEnvironment; + +export const probeNativeRunnerEnvironment = runner.probeNativeRunnerEnvironment; diff --git a/tests/runner-e2e/installed-cli.test.ts b/tests/runner-e2e/installed-cli.test.ts index 4020bafe8c4..64c4d5d9b3b 100644 --- a/tests/runner-e2e/installed-cli.test.ts +++ b/tests/runner-e2e/installed-cli.test.ts @@ -1,11 +1,11 @@ import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import { chmod, lstat, mkdtemp, mkdir, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, expect, it } from "vitest"; import { runnerMatrix } from "./catalog.js"; -import { assertInstalledCliSelection, installedCliKeys, verifyInstalledCli } from "./installed-cli.js"; +import { assertInstalledCliSelection, installedCliKeys, probeInstalledCodexStartup, verifyInstalledCli } from "./installed-cli.js"; import { buildPaperclipServerEnvironment, buildRunnerE2EProcessEnvironment } from "./harness-env.js"; const cells = runnerMatrix.filter(e => e.profile.id === "runner-acpx-pi"); const roots: string[] = []; @@ -25,6 +25,20 @@ it("admits all26 current Pi cases through pinned installed public CLI/server wit const { env, server } = await fixture(); expect(cells).toHaveLength(26); for (const cell of cells) expect(await verifyInstalledCli(env, [cell])).toMatchObject({ serverRoot: server, defaultRuntimeResolution: true, qualificationOverride: false }); }); +it("admits existing Codex onboarding, useful-file revision and restart journeys without broadening other cells", async () => { + const { env, server } = await fixture(); + for (const id of ["first-task.runner-codex.local.task-card-accept", "everyday-workflows.runner-codex.local.build-revise", "agent-chat.runner-codex.local.continuity-restart"]) { + const cell = runnerMatrix.find(e => e.id === id)!; + expect(cell).toBeDefined(); + expect(await verifyInstalledCli(env, [cell])).toMatchObject({ serverRoot: server, defaultRuntimeResolution: true, qualificationOverride: false }); + for (const key of ["PAPERCLIP_RUNNER_BINARY", "NODE_PATH"]) expect(() => assertInstalledCliSelection({ ...env, [key]: "foreign" }, [cell])).toThrow("forbids"); + } + for (const id of ["first-task.legacy-codex.local.task-card-accept", "first-task.runner-codex.local.interview-first-response", "everyday-workflows.runner-codex.daytona.build-revise"]) { + const cell = runnerMatrix.find(e => e.id === id)!; + expect(cell).toBeDefined(); + expect(() => assertInstalledCliSelection(env, [cell])).toThrow("explicit supported Pi or Codex"); + } +}); it("qualified providers drop ambient admission while pending Copilot retains its exact gate", () => { for (const cell of cells) expect(buildRunnerE2EProcessEnvironment({ PAPERCLIP_RUNNER_ACPX_QUALIFICATION: "ambient" }, [cell]).PAPERCLIP_RUNNER_ACPX_QUALIFICATION).toBeUndefined(); for (const agent of ["copilot"]) { @@ -92,3 +106,84 @@ it("startup-only accepts only explicit uncredentialed local hello and zero retri expect(()=>assertInstalledStartupOnly({...f.env,KIMI_MODEL_API_KEY:"dummy-not-a-real-key"},[cell],options)).toThrow("credential inputs"); expect(()=>assertInstalledStartupOnly({},[cell],options)).toThrow("startup-only"); }); + +async function startupFixture(mode: "normal" | "wrong-model" | "ambient-codex" | "startup-and-cleanup-failed" | "cleanup-failed" = "normal") { + const f = await fixture(); + const vendor = join(f.server, "dist/vendor/paperclip-runner"); + const daemon = join(vendor, "bin", `${process.platform}-${process.arch}`, "paperclip-runnerd"); + const codex = join(f.root, "node_modules/@openai/codex/bin/codex"); + const observations = join(f.root, "startup-observations.json"); + await mkdir(join(vendor, "bin", `${process.platform}-${process.arch}`), { recursive: true }); + await mkdir(join(f.server, "dist/services/native-runtime"), { recursive: true }); + await mkdir(join(f.root, "node_modules/@openai/codex/bin"), { recursive: true }); + await writeFile(daemon, "fixture-daemon"); + await writeFile(codex, "#!/bin/sh\nprintf 'codex-cli 0.149.5\\n'\n"); + await chmod(codex, 0o755); + await writeFile(join(f.server, "dist/services/native-runtime/native-codex-runner.js"), `export function resolvePaperclipRunnerBinary() { return ${JSON.stringify(daemon)}; }`); + await writeFile(join(vendor, "index.js"), ` + import {writeFileSync} from "node:fs"; + const state={calls:[],environmentKeys:[],fileAuthPinned:false};let retired=false; + const save=()=>writeFileSync(${JSON.stringify(observations)},JSON.stringify(state)); + export function resolveCodexCommand(){return ${JSON.stringify(mode === "ambient-codex" ? process.execPath : codex)};} + export function createIsolatedCodexAppServerArgs(){return ["app-server"];} + export function createRunnerdCodexTransport(options){ + state.environmentKeys=Object.keys(options.environment).sort(); + state.fileAuthPinned=options.codexArgs.includes('cli_auth_credentials_store="file"'); + state.privateStateDirectory=options.stateDirectory; + save();return {transport:{async close(){retired=true;state.calls.push("transport-close");save();${mode === "startup-and-cleanup-failed" ? 'throw new Error("fixture secondary transport cleanup failure");' : ''}}}, + evidence:()=>({runnerPid:111,codexPid:222,providerDriver:"codex_app_server",runnerExited:retired,diagnostics:["fixture provider bootstrap failed"],providerSessionId:"PRIVATE-PROVIDER-ID"})};} + export class CodexAppServerDriver { + constructor(options){this.options=options;} + async openSession(){state.calls.push("open");save(); + ${mode === "startup-and-cleanup-failed" ? 'throw new Error("fixture original provider startup failure");' : ''} + const model=${JSON.stringify(mode === "wrong-model" ? "wrong-model" : "gpt-5.6-sol")}; + return {ids:()=>({driverSessionId:"PRIVATE-DRIVER-ID",providerSessionId:"PRIVATE-PROVIDER-ID"}), + events:()=>({async *[Symbol.asyncIterator](){yield {eventType:"session.started",payload:{context:{model,sandbox:{permissionProfile:{id:"paperclip-runner-workspace-read-only"}}}}};}}), + startTurn(){state.calls.push("FORBIDDEN-TURN");save();throw new Error("No provider turn is permitted");}, + async close(){retired=true;state.calls.push("session-close");save();${mode === "cleanup-failed" ? 'throw new Error("fixture session cleanup failure");' : ''}}};} + } + `); + return { ...f, observations, expectedRunnerSha256: hash("fixture-daemon") }; +} + +it.skipIf(process.platform === "win32")("installed native startup uses closed homes, file-only auth and no turn or version equality gate", async () => { + const f = await startupFixture(); + const receipt = await probeInstalledCodexStartup({ consumerRoot: f.root, serverRoot: f.server, expectedRunnerSha256: f.expectedRunnerSha256 }); + expect(receipt).toMatchObject({ nativeSessionStarted: true, cleanupConfirmed: true, providerTurns: 0, credentialsUsed: false, + codexVersion: "codex-cli 0.149.5", defaultRuntimeResolution: true, reasoningEffortExercised: false }); + expect(await readFile(f.observations, "utf8").then(JSON.parse)).toMatchObject({ calls: ["open", "session-close"], fileAuthPinned: true, + environmentKeys: ["CODEX_HOME", "HOME", "LC_ALL", "PAPERCLIP_RUNNER_NETWORK_ACCESS", "PATH", "TMPDIR", "TZ"] }); + expect(JSON.stringify(receipt)).not.toMatch(/PRIVATE-(?:DRIVER|PROVIDER)-ID/); +}); + +it.skipIf(process.platform === "win32")("installed startup preserves the primary cause, records bounded diagnostics and attempts independent cleanup", async () => { + const f = await startupFixture("startup-and-cleanup-failed"); + const error = await probeInstalledCodexStartup({ consumerRoot: f.root, serverRoot: f.server, expectedRunnerSha256: f.expectedRunnerSha256 }).catch(error => error); + expect(error).toBeInstanceOf(AggregateError); + expect(error.message).toContain("fixture original provider startup failure"); + expect(error.message).toContain("fixture secondary transport cleanup failure"); + expect(error.message).toContain("fixture provider bootstrap failed"); + expect(error.cause.message).toBe("fixture original provider startup failure"); + expect(error.errors.map((error: Error) => error.message)).toEqual(["fixture original provider startup failure", "fixture secondary transport cleanup failure"]); + expect(error.message).not.toContain("PRIVATE-PROVIDER-ID"); + const observations = JSON.parse(await readFile(f.observations, "utf8")); + expect(observations.calls).toEqual(["open", "transport-close"]); + await expect(lstat(observations.privateStateDirectory)).rejects.toThrow(); +}); + +it.skipIf(process.platform === "win32")("installed startup cannot qualify when session cleanup fails after successful open", async () => { + const f = await startupFixture("cleanup-failed"); + await expect(probeInstalledCodexStartup({ consumerRoot: f.root, serverRoot: f.server, expectedRunnerSha256: f.expectedRunnerSha256 })).rejects.toThrow("fixture session cleanup failure"); + expect(JSON.parse(await readFile(f.observations, "utf8")).calls).toEqual(["open", "session-close", "session-close"]); +}); + +it.skipIf(process.platform === "win32")("installed startup rejects stale daemon/ambient Codex and cleans a mismatched native session", async () => { + const f = await startupFixture(); + await expect(probeInstalledCodexStartup({ consumerRoot: f.root, serverRoot: f.server, expectedRunnerSha256: "0".repeat(64) })).rejects.toThrow("reviewed pin"); + await expect(readFile(f.observations)).rejects.toThrow(); + const ambient = await startupFixture("ambient-codex"); + await expect(probeInstalledCodexStartup({ consumerRoot: ambient.root, serverRoot: ambient.server, expectedRunnerSha256: ambient.expectedRunnerSha256 })).rejects.toThrow("ambient Codex"); + const wrong = await startupFixture("wrong-model"); + await expect(probeInstalledCodexStartup({ consumerRoot: wrong.root, serverRoot: wrong.server, expectedRunnerSha256: wrong.expectedRunnerSha256 })).rejects.toThrow("matching native identity"); + expect(await readFile(wrong.observations, "utf8").then(JSON.parse)).toMatchObject({ calls: ["open", "session-close"] }); +}); diff --git a/tests/runner-e2e/installed-cli.ts b/tests/runner-e2e/installed-cli.ts index 97dc50a49f2..6bcfc6f005d 100644 --- a/tests/runner-e2e/installed-cli.ts +++ b/tests/runner-e2e/installed-cli.ts @@ -1,21 +1,33 @@ /** Optional published-install lane. No production hook or admission override. */ import { constants } from "node:fs"; -import { lstat, open, realpath } from "node:fs/promises"; +import { lstat, mkdir, mkdtemp, open, realpath, rm } from "node:fs/promises"; import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; import { findPackageJSON } from "node:module"; -import { dirname, isAbsolute, join, resolve } from "node:path"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { tmpdir } from "node:os"; import { pathToFileURL } from "node:url"; import { CREDENTIAL_NAMES, type MatrixExecution } from "./types.js"; export const installedCliKeys = ["PAPERCLIP_RUNNER_E2E_INSTALLED_CLI", "PAPERCLIP_RUNNER_E2E_INSTALLED_CLI_SHA256", "PAPERCLIP_RUNNER_E2E_INSTALLED_SERVER_ROOT", "PAPERCLIP_RUNNER_E2E_INSTALLED_SERVER_SHA256"] as const; const overrideKeys = ["PAPERCLIP_RUNNER_BINARY", "PAPERCLIP_RUNNER_REMOTE_BINARY_PATH", "PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH", "PAPERCLIP_RUNNER_ACPX_QUALIFICATION", "PAPERCLIP_ACPX_BUILTIN_ROOT", "PAPERCLIP_ACPX_PROVIDER_PACKAGE_ROOT", "PAPERCLIP_ACPX_PROVIDER_PACKAGE_MANIFEST"] as const; const suites = new Set(["extended-harnesses", "pi-native", "pi-controls", "rich-acp-warm-continuity"]); +// Reuse the production-onboarding, independent file/revision, and restart +// journeys. Installed proof does not add cases or relax their existing graders. +const installedCodexCells = new Set([ + "first-task.runner-codex.local.task-card-accept", + "everyday-workflows.runner-codex.local.build-revise", + "agent-chat.runner-codex.local.continuity-restart", +]); export function usesInstalledCli(env: NodeJS.ProcessEnv): boolean { return installedCliKeys.some(key => env[key] !== undefined); } export function assertInstalledCliSelection(env: NodeJS.ProcessEnv, executions: readonly MatrixExecution[]): void { if (!usesInstalledCli(env)) return; - if (!installedCliKeys.every(key => Boolean(env[key])) || executions.length === 0 || executions.some(e => e.profile.id !== "runner-acpx-pi" || e.profile.qualificationCandidate !== "pi" || !e.suite.manualOnly || !suites.has(e.suite.id))) throw new Error("Installed CLI proof requires complete pins and explicit supported Pi cases"); + if (!installedCliKeys.every(key => Boolean(env[key])) || executions.length === 0 || executions.some(e => !( + (e.profile.id === "runner-acpx-pi" && e.profile.qualificationCandidate === "pi" && e.suite.manualOnly && suites.has(e.suite.id)) || + (e.profile.id === "runner-codex" && e.profile.provider === "codex" && e.environment.id === "local" && installedCodexCells.has(e.id)) + ))) throw new Error("Installed CLI proof requires complete pins and explicit supported Pi or Codex cases"); for (const key of overrideKeys) if (env[key] !== undefined) throw new Error(`Installed CLI proof forbids ${key}`); if (env.NODE_OPTIONS !== undefined || env.NODE_PATH !== undefined) throw new Error("Installed CLI proof forbids ambient Node injection"); } @@ -51,6 +63,115 @@ export async function verifyInstalledCli(env: NodeJS.ProcessEnv, executions: rea return { schema: "paperclip.e2e.installed-cli-admission/v1", entry, cliRoot, cliSha256, serverRoot, serverEntry, serverSha256, version: cli.version, defaultRuntimeResolution: true, qualificationOverride: false }; } +/** Clean-install protocol proof only: no credentials, task, or provider turn. */ +export async function probeInstalledCodexStartup(input: { + consumerRoot: string; + serverRoot: string; + expectedRunnerSha256: string; + timeoutMs?: number; +}) { + for (const key of [...overrideKeys, "NODE_OPTIONS", "NODE_PATH"]) { + if (process.env[key] !== undefined) throw new Error(`Installed Codex startup forbids ${key}`); + } + const inside = (root: string, filename: string) => { + const name = relative(root, filename); + return name !== "" && name !== ".." && !name.startsWith(`..${sep}`) && !isAbsolute(name); + }; + for (const filename of [input.consumerRoot, input.serverRoot]) { + if (!isAbsolute(filename) || resolve(filename) !== filename || await realpath(filename) !== filename) throw new Error("Installed Codex startup paths must be canonical and unlinked"); + } + if (!inside(join(input.consumerRoot, "node_modules"), input.serverRoot) || !/^[a-f0-9]{64}$/.test(input.expectedRunnerSha256)) throw new Error("Installed Codex startup requires a contained server and reviewed daemon digest"); + const manifest = JSON.parse((await readOwned(join(input.serverRoot, "package.json"), 65536)).toString()); + if (manifest.name !== "@paperclipai/server" || manifest.exports?.["."]?.import !== "./dist/index.js") throw new Error("Installed Codex startup requires the published server"); + const vendorRoot = join(input.serverRoot, "dist/vendor/paperclip-runner"); + const entries = [join(vendorRoot, "index.js"), join(input.serverRoot, "dist/services/native-runtime/native-codex-runner.js")]; + for (const filename of entries) if (await realpath(filename) !== filename) throw new Error("Installed Codex startup runtime escapes its package"); + const [runtime, production] = await Promise.all(entries.map(filename => import(pathToFileURL(filename).href))); + const runnerBinary = await realpath(production.resolvePaperclipRunnerBinary()); + if (runnerBinary !== join(vendorRoot, "bin", `${process.platform}-${process.arch}`, "paperclip-runnerd")) throw new Error("Installed Codex startup requires the normal platform daemon; no source or flat fallback"); + const digest = (bytes: Buffer) => createHash("sha256").update(bytes).digest("hex"); + const runnerSha256 = digest(await readOwned(runnerBinary, 512 * 1024 * 1024)); + if (runnerSha256 !== input.expectedRunnerSha256) throw new Error("Installed Codex startup daemon differs from its reviewed pin"); + const directory = await realpath(await mkdtemp(join(tmpdir(), "pc-codex-startup-"))); + const home = join(directory, "home"), codexHome = join(directory, "codex-home"), workspace = join(directory, "workspace"); + for (const filename of [home, codexHome, workspace]) await mkdir(filename, { mode: 0o700 }); + const environment = { PATH: `${dirname(process.execPath)}:/usr/bin:/bin:/usr/sbin`, HOME: home, CODEX_HOME: codexHome, + TMPDIR: directory, LC_ALL: "C", TZ: "UTC", PAPERCLIP_RUNNER_NETWORK_ACCESS: "disabled" }; + let session: any; + let bundle: any; + let cleanupConfirmed = false; + let startupFailed = false; + let startupFailure: unknown; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(new Error("Installed Codex startup timed out")), Math.max(1, Math.min(input.timeoutMs ?? 45000, 120000))); + try { + const codexCommand = await realpath(runtime.resolveCodexCommand(undefined, environment, workspace)); + if (!inside(join(input.consumerRoot, "node_modules"), codexCommand)) throw new Error("Installed Codex startup resolved an ambient Codex executable"); + const codexSha256 = digest(await readOwned(codexCommand, 512 * 1024 * 1024)); + const codexVersion = execFileSync(codexCommand, ["--version"], { cwd: workspace, env: environment, encoding: "utf8", timeout: 10000, maxBuffer: 16384 }).trim(); + if (!codexVersion || codexVersion.length > 200) throw new Error("Installed Codex startup returned no bounded version identity"); + // Keep the production security arguments; pin file-only auth for this + // credential-free fixture so macOS keychain accounts cannot be consulted. + const args = runtime.createIsolatedCodexAppServerArgs(environment); + args.splice(args.indexOf("app-server"), 0, "-c", 'cli_auth_credentials_store="file"'); + bundle = runtime.createRunnerdCodexTransport({ provider: "codex", stateDirectory: directory, environment, + sourceCodexHome: "", runnerBinary, codexCommand, codexArgs: args, closeGraceMs: 10000, + lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null } }); + const driver = new runtime.CodexAppServerDriver({ + taskEnvelope: { schema: "paperclip.skillless_task.v1", objective: "Credential-free installed startup proof.", + completionContract: { revision: "installed-startup-v1", criteria: [] }, constraints: [], expectedResultSchema: "paperclip.run_result.v1" }, + model: "gpt-5.6-sol", conversationMode: "direct", requestedCollaborationMode: "plan", approvalPolicy: "never", + includeCollaborationModeInstructions: false, dynamicTools: [], environment, + transportFactory: () => bundle.transport, requireProviderSessionIdentity: true, + }); + session = await driver.openSession({ runId: "installed-startup", normalizedSessionId: "installed-startup", workingDirectory: workspace, signal: controller.signal }); + const event = await session.events()[Symbol.asyncIterator]().next(); + const identity = session.ids(); + const evidence = bundle.evidence(); + if (event.value?.eventType !== "session.started" || event.value.payload?.context?.model !== "gpt-5.6-sol" || + event.value.payload?.context?.sandbox?.permissionProfile?.id !== "paperclip-runner-workspace-read-only" || + !identity.driverSessionId || !identity.providerSessionId || evidence.providerDriver !== "codex_app_server" || + !(evidence.runnerPid > 0) || !(evidence.codexPid > 0)) throw new Error("Installed Codex startup returned no matching native identity or read-only model context"); + await session.close({ reason: "credential-free installed startup complete" }); + session = undefined; + if (!bundle.evidence().runnerExited) throw new Error("Installed Codex startup cleanup is incomplete"); + cleanupConfirmed = true; + return { schema: "paperclip.e2e.installed-codex-startup/v1", platform: `${process.platform}-${process.arch}`, + runnerSha256, codexSha256, codexVersion, providerDriver: "codex_app_server", model: "gpt-5.6-sol", + nativeSessionStarted: true, cleanupConfirmed: true, providerTurns: 0, credentialsUsed: false, + defaultRuntimeResolution: true, reasoningEffortExercised: false }; + } catch (error) { + startupFailed = true; + startupFailure = error; + throw error; + } finally { + clearTimeout(timer); + const cleanupFailures: unknown[] = []; + try { + if (session) await session.close({ reason: "installed startup cleanup" }); + else if (bundle && !cleanupConfirmed) await bundle.transport.close("installed startup cleanup"); + } catch (error) { cleanupFailures.push(error); } + try { + if (!bundle || bundle.evidence().runnerExited) await rm(directory, { recursive: true, force: true }); + } catch (error) { cleanupFailures.push(error); } + if (startupFailed) { + const evidence = bundle?.evidence(); + // This zero-turn fixture has empty homes and no provider credentials. + // Report only bounded failure text and lifecycle state, never identities + // or raw durable state. A close failure cannot hide the startup cause. + const bounded = (value: unknown) => String(value).replaceAll(directory, "[private-startup]").slice(0, 2000); + const primaryMessage = bounded(startupFailure instanceof Error ? startupFailure.message : startupFailure); + const diagnostic = { schema: "paperclip.e2e.installed-codex-startup-failure/v1", platform: `${process.platform}-${process.arch}`, + runnerExited: evidence?.runnerExited ?? null, runnerExitCode: evidence?.runnerExitCode ?? null, + runnerSignal: evidence?.runnerSignal ?? null, + diagnostics: (Array.isArray(evidence?.diagnostics) ? evidence.diagnostics : []).slice(-16).map(bounded), + cleanupFailures: cleanupFailures.map(error => bounded(error instanceof Error ? error.message : error)) }; + throw new AggregateError([startupFailure, ...cleanupFailures], `Installed Codex startup failed: ${primaryMessage}\n${JSON.stringify(diagnostic)}`, { cause: startupFailure }); + } + if (cleanupFailures.length) throw new AggregateError(cleanupFailures, "Installed Codex startup cleanup failed", { cause: cleanupFailures[0] }); + } +} + /** This prep lane cannot create agents or read the normal local credential file. */ export function assertInstalledStartupOnly(env: NodeJS.ProcessEnv, executions: readonly MatrixExecution[], options: { ids: string[]; maxParallel: number; maxAutomaticRetries: number; headed: boolean; ui: boolean; debug: boolean; list: boolean; matrixJson: boolean }) { assertInstalledCliSelection(env, executions); From b833c609cf2da1aacb6ab29d062e23bb1865624c Mon Sep 17 00:00:00 2001 From: Dotta <bippadotta@protonmail.com> Date: Mon, 12 Oct 2026 15:29:01 -0500 Subject: [PATCH 8/8] fix(adapter-utils): preserve SSH command positional arguments Keep shell argv intact and apply environment assignments after exec. Validate environment keys before constructing the remote command. Co-Authored-By: Paperclip <noreply@paperclip.ing> --- .../src/ssh-command-managed.test.ts | 71 +++++++++++++++++++ packages/adapter-utils/src/ssh.ts | 18 ++--- 2 files changed, 80 insertions(+), 9 deletions(-) create mode 100644 packages/adapter-utils/src/ssh-command-managed.test.ts 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 {