diff --git a/.gitattributes b/.gitattributes index 4158df58b6..a11031b742 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,8 @@ * text=auto eol=lf packaging/windows/licenses/* binary + +# Preserve the exact source bytes pinned by the registry lock on every platform. +vendor/agent-registry/** -text +# The generated index is compared byte-for-byte by the offline registry checker. +src/agents/bundled.rs text eol=lf +docs/next/api/herdr-api.schema.json text eol=lf diff --git a/.github/workflows/distribution.yml b/.github/workflows/distribution.yml index 7062dc369d..8d208688a9 100644 --- a/.github/workflows/distribution.yml +++ b/.github/workflows/distribution.yml @@ -8,17 +8,20 @@ on: - "docs/next/website/**" - "docs/preview/**" - "docs/versions/**" - - "scripts/agent_detection_manifest_check.py" - "scripts/config_reference_check.py" - "scripts/docs/**" - "scripts/docs_translation_parity.py" - "scripts/preview.py" - - "scripts/test_agent_detection_manifest_check.py" - "scripts/test_config_reference_check.py" - "scripts/test_docs_translation_parity.py" - "scripts/test_preview.py" - - "src/detect/manifest_update.rs" - - "src/detect/manifests/**" + - "src/agents/**" + - "scripts/fixtures/agent-registry-snapshot-v1.json" + - "vendor/agent-registry/**" + - "src/agents/bundled.rs" + - "scripts/agent_registry_vendor.py" + - "scripts/test_agent_registry_vendor.py" + - "src/config/**" push: branches: [master] paths: @@ -27,17 +30,20 @@ on: - "docs/next/website/**" - "docs/preview/**" - "docs/versions/**" - - "scripts/agent_detection_manifest_check.py" - "scripts/config_reference_check.py" - "scripts/docs/**" - "scripts/docs_translation_parity.py" - "scripts/preview.py" - - "scripts/test_agent_detection_manifest_check.py" - "scripts/test_config_reference_check.py" - "scripts/test_docs_translation_parity.py" - "scripts/test_preview.py" - - "src/detect/manifest_update.rs" - - "src/detect/manifests/**" + - "src/agents/**" + - "scripts/fixtures/agent-registry-snapshot-v1.json" + - "vendor/agent-registry/**" + - "src/agents/bundled.rs" + - "scripts/agent_registry_vendor.py" + - "scripts/test_agent_registry_vendor.py" + - "src/config/**" permissions: contents: read @@ -67,7 +73,7 @@ jobs: - name: Validate published contract run: | - python3 scripts/agent_detection_manifest_check.py --require-published + python3 scripts/agent_registry_vendor.py --check python3 scripts/config_reference_check.py python3 scripts/docs_translation_parity.py --docs-root docs/next/website/src/content/docs node scripts/docs/versions.mjs check @@ -76,7 +82,7 @@ jobs: - name: Test contract tooling run: | python3 -m unittest \ - scripts.test_agent_detection_manifest_check \ + scripts.test_agent_registry_vendor \ scripts.test_config_reference_check \ scripts.test_docs_translation_parity \ scripts.test_preview diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bdf0a45fe3..6e7180a227 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -185,7 +185,7 @@ jobs: - name: Validate release inputs run: | python3 scripts/changelog.py validate-product-announcement - python3 scripts/agent_detection_manifest_check.py --require-all-published + python3 scripts/agent_registry_vendor.py --check python3 scripts/config_reference_check.py node scripts/docs/versions.mjs check node scripts/docs/preview.mjs check diff --git a/.github/workflows/website-deploy.yml b/.github/workflows/website-deploy.yml index 88283d6e71..3cf8967436 100644 --- a/.github/workflows/website-deploy.yml +++ b/.github/workflows/website-deploy.yml @@ -8,7 +8,6 @@ on: - ".github/workflows/website-deploy.yml" - "docs/preview/**" - "docs/versions/**" - - "src/detect/manifests/**" - "distribution/**" permissions: diff --git a/AGENTS.md b/AGENTS.md index 92b29da9bf..6624d13934 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,7 @@ These instructions are layered. - **No god objects.** If a module is doing too many things, split it. `app/` is already split into state, actions, and input. Keep it that way. - **Platform code is isolated.** OS-specific behavior lives in the matching `src/platform/.rs` file, with only shared traits, types, wrappers, and testable contracts in `src/platform/mod.rs`. Core modules don't have `#[cfg(target_os)]`. - **Detection is decoupled.** The detector reads a screen snapshot, never touches the parser or viewport state. -- **Screen detection is evidence-based.** When changing `src/detect/manifests/`, first capture the relevant bottom-buffer state with `herdr agent read --source detection --format text` and, when styling or alternate screen behavior matters, `--format ansi`. Decide which visible controls are invariant, which are alternatives, and encode them as explicit AND/OR gates. Do not match whole-pane incidental text, and do not use the user-visible viewport for agent status because users can scroll it. +- **Screen detection is evidence-based.** When changing `agents//detection.toml` in `herdrdev/agent-registry`, first capture the relevant bottom-buffer state with `herdr agent read --source detection --format text` and, when styling or alternate screen behavior matters, `--format ansi`. Decide which visible controls are invariant, which are alternatives, and encode them as explicit AND/OR gates. Do not match whole-pane incidental text, and do not use the user-visible viewport for agent status because users can scroll it. - **UI patterns should be reused.** Herdr is a mouse-first TUI. New dialogs, onboarding, settings, and post-update flows should follow the existing UI/UX language and interaction patterns instead of inventing one-off screens. Prefer reusing existing modal/screen structure, affordances, and close actions so the app feels consistent. ### Multiplicative performance paths @@ -196,11 +196,11 @@ manual testing, reset `C:\work\repo` back to a clean checkout before finishing. ## Agent Detection Updates -Agent detection changes should use the manifest hot-reload loop. Use the project-local `herdr-throwaway-repro` skill to create a disposable named session and drive the real agent UI through Herdr's CLI/API into the target state. Read the pane with `herdr agent read --source detection --format text` and inspect matching with `herdr agent explain --json`. Update the bundled manifest in `src/detect/manifests/.toml`, copy that manifest to the local override path at `~/.config/herdr/agent-detection/.toml`, then run `herdr server reload-agent-manifests` against the session under test. Before writing the override, check whether one already exists; never overwrite or remove a pre-existing override without alignment. Once the rule is correct, remove the temporary override or restore the previous one exactly so the committed bundled manifest remains the source of truth. +Agent detection changes should use the manifest hot-reload loop. Use the project-local `herdr-throwaway-repro` skill to create a disposable named session and drive the real agent UI through Herdr's CLI/API into the target state. Read the pane with `herdr agent read --source detection --format text` and inspect matching with `herdr agent explain --json`. Update the source manifest in `agents//detection.toml` in the agent registry checkout, copy that manifest to the local override path at `~/.config/herdr/agent-detection/.toml`, then run `herdr server reload-agent-manifests` against the session under test. Agent folders use registry canonical IDs, including `agy` for Antigravity and `copilot` for GitHub Copilot. Before writing the override, check whether one already exists; never overwrite or remove a pre-existing override without alignment. Once the rule is correct, remove the temporary override or restore the previous one exactly so the registry source remains authoritative. Refresh Herdr's pinned copy with `just agent-registry-sync `; do not hand-edit `vendor/agent-registry/`. Builds use only that vendored snapshot and never fetch the registry. Do not add large agent-specific full-screen fixture suites for routine manifest tuning. Keep Rust tests focused on manifest parsing, rule semantics, skip-state semantics, source precedence, cache reload behavior, and update flow. Use live pane reads for agent-specific screen evidence. -`distribution/agent-detection/` is the remotely published catalog for released clients. Keep changes for already released agents aligned with their bundled manifests unless the validator records an exact compatibility exception. A newly bundled agent that current stable clients cannot identify may remain unpublished behind an exact version-and-digest exception, but it must be added to the catalog and the exception removed before the first stable release that ships it. `just release-docs-check` enforces that no unpublished exceptions remain. +`distribution/agent-detection/` is a legacy published catalog for released clients. Leave those public assets untouched; do not synchronize them with registry source or add compatibility exceptions. Herdr owns semantic validation (`just agent-registry-validate`); `just agent-registry-check` verifies vendored integrity offline. Reviewed immutable snapshots can be imported offline with `just agent-registry-sync-snapshot `; the matching binary validates the exact bytes and compiled integration boundary before the existing rollback-safe vendor path runs. R2 publication remains a separate, explicitly approved operation, never part of normal builds/checks. ## Vendored libghostty-vt diff --git a/Cargo.toml b/Cargo.toml index d28ba46b24..c8873cfa03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,8 +11,10 @@ keywords = ["terminal", "tui", "ai", "agents", "multiplexer"] categories = ["command-line-utilities"] include = [ "src/**/*", + "vendor/agent-registry/**/*", "assets/sounds/*", "docs/next/api/herdr-api.schema.json", + "scripts/fixtures/agent-registry-snapshot-v1.json", "skills/herdr/SKILL.md", "distribution/install.ps1", "README.md", diff --git a/docs/next/api/herdr-api.schema.json b/docs/next/api/herdr-api.schema.json index b3990e9697..d8c52e965c 100644 --- a/docs/next/api/herdr-api.schema.json +++ b/docs/next/api/herdr-api.schema.json @@ -1689,6 +1689,14 @@ ], "type": "object" }, + "Channel": { + "enum": [ + "stable", + "preview", + "staging" + ], + "type": "string" + }, "ClientShellSurfaceSetParams": { "description": "Updates whether the requesting client shell receives and controls pane presentation.", "properties": { @@ -3923,6 +3931,31 @@ ], "type": "string" }, + "RegistryReloadParams": { + "additionalProperties": false, + "description": "Reload the selected session's registry. Omission reuses its configured source.\nThis does not install integrations or grant report authority.", + "properties": { + "source": { + "description": "Absolute local source directory (at most 4096 UTF-8 bytes), not a URL.", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "RegistryUpdateParams": { + "additionalProperties": false, + "description": "Explicit remote check/update. Never starts an automatic update schedule.", + "properties": { + "channel": { + "$ref": "#/schemas/request/$defs/Channel", + "default": "stable" + } + }, + "type": "object" + }, "ReleaseNotesDismissParams": { "properties": { "version": { @@ -4843,6 +4876,86 @@ ], "type": "object" }, + { + "properties": { + "method": { + "const": "registry.status", + "type": "string" + }, + "params": { + "$ref": "#/schemas/request/$defs/EmptyParams" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + { + "properties": { + "method": { + "const": "registry.reload", + "type": "string" + }, + "params": { + "$ref": "#/schemas/request/$defs/RegistryReloadParams" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + { + "properties": { + "method": { + "const": "registry.check", + "type": "string" + }, + "params": { + "$ref": "#/schemas/request/$defs/RegistryUpdateParams" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + { + "properties": { + "method": { + "const": "registry.update", + "type": "string" + }, + "params": { + "$ref": "#/schemas/request/$defs/RegistryUpdateParams" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, + { + "properties": { + "method": { + "const": "registry.reset", + "type": "string" + }, + "params": { + "$ref": "#/schemas/request/$defs/EmptyParams" + } + }, + "required": [ + "method", + "params" + ], + "type": "object" + }, { "properties": { "method": { @@ -6833,6 +6946,79 @@ ], "type": "string" }, + "AgentSummary": { + "properties": { + "detection": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "integration": { + "type": "boolean" + }, + "process": { + "type": "boolean" + }, + "resume": { + "type": "boolean" + }, + "startable": { + "type": "boolean" + } + }, + "required": [ + "id", + "startable", + "process", + "detection", + "resume", + "integration" + ], + "type": "object" + }, + "Channel": { + "enum": [ + "stable", + "preview", + "staging" + ], + "type": "string" + }, + "ChannelPointer": { + "additionalProperties": false, + "properties": { + "channel": { + "$ref": "#/schemas/success_response/$defs/Channel" + }, + "generation": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "schema": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "snapshot_bytes": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "snapshot_sha256": { + "type": "string" + } + }, + "required": [ + "schema", + "channel", + "generation", + "snapshot_sha256", + "snapshot_bytes" + ], + "type": "object" + }, "ClientWindowTitleReason": { "enum": [ "set", @@ -9187,6 +9373,102 @@ ], "type": "string" }, + "RegistryStatus": { + "properties": { + "agents": { + "items": { + "$ref": "#/schemas/success_response/$defs/AgentSummary" + }, + "type": "array" + }, + "digest": { + "type": "string" + }, + "generation": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "last_error": { + "type": [ + "string", + "null" + ] + }, + "remote": { + "anyOf": [ + { + "$ref": "#/schemas/success_response/$defs/RemoteRevision" + }, + { + "type": "null" + } + ] + }, + "source": { + "description": "Local source directory; None selects the managed bundled/R2 source.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "generation", + "digest", + "agents" + ], + "type": "object" + }, + "RegistryUpdateCheck": { + "properties": { + "active_digest": { + "type": "string" + }, + "active_generation": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "content_sha256": { + "type": "string" + }, + "remote": { + "$ref": "#/schemas/success_response/$defs/RemoteRevision" + }, + "update_available": { + "type": "boolean" + } + }, + "required": [ + "active_generation", + "active_digest", + "remote", + "content_sha256", + "update_available" + ], + "type": "object" + }, + "RemoteRevision": { + "additionalProperties": false, + "properties": { + "commit": { + "type": "string" + }, + "origin": { + "type": "string" + }, + "pointer": { + "$ref": "#/schemas/success_response/$defs/ChannelPointer" + } + }, + "required": [ + "origin", + "pointer", + "commit" + ], + "type": "object" + }, "ResponseResult": { "oneOf": [ { @@ -9459,6 +9741,38 @@ ], "type": "object" }, + { + "properties": { + "registry": { + "$ref": "#/schemas/success_response/$defs/RegistryStatus" + }, + "type": { + "const": "agent_registry", + "type": "string" + } + }, + "required": [ + "type", + "registry" + ], + "type": "object" + }, + { + "properties": { + "registry_update": { + "$ref": "#/schemas/success_response/$defs/RegistryUpdateCheck" + }, + "type": { + "const": "registry_update_check", + "type": "string" + } + }, + "required": [ + "type", + "registry_update" + ], + "type": "object" + }, { "properties": { "agent": { diff --git a/docs/next/website/src/content/docs/agents.mdx b/docs/next/website/src/content/docs/agents.mdx index eecdb22d99..3f6f11c69d 100644 --- a/docs/next/website/src/content/docs/agents.mdx +++ b/docs/next/website/src/content/docs/agents.mdx @@ -41,7 +41,7 @@ Detected but less thoroughly tested: Gemini CLI and Cline. Unsupported agents st Herdr first detects the foreground process in each pane. After that, each pane has one status authority. -For agents with complete lifecycle hooks, the integration is authoritative when it is installed and actively reporting for the running pane. Herdr uses those hook reports for `idle`, `working`, `blocked`, and session identity. It does not also run screen manifest fallback for that same lifecycle authority. This avoids two competing sources of truth. +For agents with complete lifecycle hooks, the integration is authoritative when it is installed and actively reporting for the running pane. Herdr uses those hook reports for `idle`, `working`, `blocked`, and session identity. Screen fallback does not compete with that lifecycle authority. During managed startup, Herdr may still inspect the screen to verify that input controls are painted before accepting prompts. For agents without complete lifecycle hooks, Herdr identifies the foreground process and reads the live bottom-buffer screen snapshot. It evaluates TOML manifests against that snapshot to classify `idle`, `working`, and `blocked`. For agents that emit them, manifests can also match terminal title and progress (OSC) sequences as detection evidence; when that evidence is absent, screen rules carry detection on their own. @@ -63,19 +63,25 @@ This means unusual new agent prompts may initially show as `idle` instead of `bl ## Detection manifests -Bundled manifests live inside Herdr. Herdr also checks herdr.dev for remote manifest updates and applies valid per-agent rule updates automatically without requiring a Herdr restart. Remote manifests are stored in Herdr's state directory. Set `[update] manifest_check = false` to disable background remote manifest checks. +Herdr starts offline with its bundled registry or a saved last-known-good snapshot. Release builds then check `registry.herdr.dev` in the background at startup and every 30 minutes, validating, saving, and hot-activating compatible updates without restarting. Automatic checks follow the last successfully selected registry channel, or `stable` initially. Set `[update] manifest_check = false` to disable automatic checks; a check already in progress may finish. Debug builds do not run automatic checks. -Local overrides can replace a remote or bundled manifest from the platform config directory: +Manual updates remain available: `herdr registry check` validates an available full-registry snapshot without activating it, and `herdr registry update` downloads and hot-activates it in the selected server. Invalid or incompatible updates leave the last working snapshot active. Historical website manifest caches are ignored and left untouched. + +New registry packages can add agent IDs, launch definitions, process recognition, and detection rules without rebuilding Herdr. They can also deliver versioned updates to existing integration files, which users apply from settings or the integration CLI. Trusted reporters and installation procedures remain compiled capabilities; a snapshot containing incompatible installation layouts or invalid integration versions is rejected as a whole. + +Local detection overrides use the existing platform config directory: ```text ~/.config/herdr/agent-detection/.toml ``` -Local overrides always win. Without a local override, Herdr uses the newer compatible manifest between the cached remote manifest and the bundled manifest in the running binary. On debug builds, the same config helper may use a development directory such as `herdr-dev`. Invalid override files are ignored with a warning and Herdr falls back to the cached remote or bundled manifest for that agent. +A valid local override wins over the selected registry package. Invalid overrides produce a warning and fall back to that package. Overrides cannot add an agent absent from the selected registry. Debug builds may use a development config directory such as `herdr-dev`. Registry updates never overwrite these files. After editing an override, run `herdr server reload-agent-manifests` to refresh it offline. -Remote manifests patch detection rules for agents Herdr already knows how to identify. Adding a completely new agent still requires a Herdr binary update for process detection, labels, and integration behavior. +For registry development, `herdr registry reload /path/to/agent-registry` selects a complete local package set, not a merge with the remote registry. While local mode is selected, R2 updates are rejected. `herdr registry reset` returns to the bundled registry and re-enables R2 updates without changing local files or your automatic-check setting. An explicit `HERDR_AGENT_REGISTRY_SOURCE` server environment setting selects local mode again on restart. -The running server loads active manifests into memory on startup. Automatic remote manifest updates reload that in-memory cache after new rules are written. Run `herdr server update-agent-manifests` to fetch remote manifest updates immediately and reload the running server. After editing a local override manually, restart Herdr or run `herdr server reload-agent-manifests` to apply the file to the running server. +If a Herdr upgrade cannot use the saved packages, it keeps your local-source selection and accepted update history while using compatible source or bundled definitions. It does not silently switch a selected local registry to R2. Detection override files remain untouched. + +R2 channel pointers are fetched fresh; immutable snapshots are addressed by their exact byte digest. Malformed, incompatible, interrupted, or stale updates leave the complete active snapshot unchanged. Activation updates running detection asynchronously; it does not rename live agents or replace their pinned resume instructions. A queued restore is authorized when its plan is created: its command and readiness policy stay pinned even if a registry update arrives before it starts. Removing a package prevents new admissions, not already-authorized queued restores. Use `herdr agent explain` when a pane shows the wrong state: @@ -107,6 +113,8 @@ herdr integration status Each supported agent has its own integration name and behavior. See [Integrations](/docs/integrations/) for the per-agent details and the full install list. If you are building an agent, the [custom integration guide](/docs/integrations/#integrate-your-own-agent) shows how to report lifecycle state without adding native support to Herdr. +Agent authors can also [contribute a package to the registry](https://github.com/herdrdev/agent-registry). Native API reporting and registry packages complement each other: reports provide lifecycle state and metadata; a package supplies launch definitions, process recognition, and optional screen detection. Package updates can reach users without a new Herdr binary. Neither route automatically grants trusted native session ownership or a compiled installer adapter. + ## Custom agent labels You can rename an agent target for display: diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx index c25b64e6a2..05996f220c 100644 --- a/docs/next/website/src/content/docs/cli-reference.mdx +++ b/docs/next/website/src/content/docs/cli-reference.mdx @@ -128,7 +128,7 @@ herdr server update-agent-manifests [--json] herdr server reload-agent-manifests ``` -`herdr server` runs the headless server explicitly. Use it for supervised or service-style setups. `reload-config` applies reloadable settings without restarting panes. `agent-manifests` shows the active agent detection manifest sources, cached remote versions, and last remote update results. `update-agent-manifests` fetches remote manifest updates immediately, reloads them into the running server, and prints the updated manifest status; pass `--json` for the raw status response. `reload-agent-manifests` reloads agent detection manifests into the running server after local override edits. +`herdr server` runs the headless server explicitly. Use it for supervised or service-style setups. `reload-config` applies reloadable settings without restarting panes. `agent-manifests` shows active detection sources and versions from the accepted registry. `update-agent-manifests` is a compatibility alias for `registry update`: it fetches and activates a **full R2 registry snapshot**, including agent IDs and CLI definitions, not just detection rules. It then prints the update result and manifest status; `--json` preserves the legacy manifest-status response shape. Use `registry status` for full update provenance and errors. `reload-agent-manifests` reloads agent detection manifests into the running server after local override edits. ## Notifications @@ -344,9 +344,11 @@ herdr agent explain --file PATH --agent LABEL [--json|--verbose] Agent targets are either a unique live agent name or the pane ID that currently hosts the agent. Terminal IDs and bare agent-kind labels are not agent targets. Agents started through `agent start` require a name; manually launched agents remain unnamed and use their pane ID. -`agent start` activates an existing available shell pane: the pane's interactive shell must own the foreground, with no foreground command, editor, or agent running. Topology must be created separately. Names are unique among live agents and must match `[a-z][a-z0-9_-]{0,31}`. The kind selects Herdr's canonical interactive executable, while arguments after `--` are passed to that executable. Supported kinds are `pi`, `claude`, `codex`, `gemini`, `cursor`, `devin`, `agy`, `cline`, `omp`, `mastracode`, `opencode`, `copilot`, `kimi`, `kiro`, `droid`, `amp`, `grok`, `hermes`, `kilo`, `qodercli`, `qwen`, `maki`, and `muse`. A name follows the current pane occupant and is cleared when that agent exits, is released, or is replaced. Temporary detection uncertainty does not clear it. +`agent start` activates an existing available shell pane: the pane's interactive shell must own the foreground, with no foreground command, editor, or agent running. Topology must be created separately. Names are unique among live agents and must match `[a-z][a-z0-9_-]{0,31}`. The kind selects Herdr's canonical interactive executable, while arguments after `--` are passed to that executable. Bundled kinds are `pi`, `claude`, `codex`, `gemini`, `cursor`, `devin`, `agy`, `cline`, `omp`, `mastracode`, `opencode`, `copilot`, `kimi`, `kiro`, `droid`, `amp`, `grok`, `hermes`, `kilo`, `qodercli`, `qwen`, `maki`, and `muse`. A name follows the current pane occupant and is cleared when that agent exits, is released, or is replaced. Temporary detection uncertainty does not clear it. -A successful start returns only after the expected agent owns the same terminal and is ready for interactive input. If detection reports `blocked` during startup, the command returns `agent_not_ready` immediately. The name remains available for `agent read` and `agent send-keys`, and becomes ready for prompts after detection reports `idle`. The default startup timeout is 30000 milliseconds; explicit values must be greater than 3000 and no more than 300000. +A successful start returns only after the expected agent owns the same terminal and is ready for interactive input. If detection reports `blocked` during startup, the command returns `agent_not_ready` immediately. The name remains available for `agent read` and `agent send-keys`. For agents with screen-based input detection, including OpenCode, prompts remain unavailable until the input controls are visible and no blocker is active; an `idle` report alone is insufficient. The default startup timeout is 30000 milliseconds; explicit values must be greater than 3000 and no more than 300000. + +Cold-restored agents remain queued until their resume command is sent. They then pass the same startup readiness checks before accepting prompts; restoring a saved name or session does not itself make the agent ready. `agent prompt` honors live bracketed-paste mode and writes text followed by delayed Enter as one ordered submission, including while the agent is working. Success without `--wait` acknowledges the writes, not the start of a turn. On Windows, Codex receives a paste boundary before Enter so submission does not depend on prompt size; the caller timeout includes submission time. If the agent is already `blocked`, it returns `agent_blocked` without sending input. With `--wait`, a prompt sent from another non-working state has up to five seconds after submission to produce an observed `working` or `blocked` state or Herdr returns `agent_prompt_stalled`; if the caller timeout expires first, Herdr returns the normal `timeout` error. This prevents unrelated `idle`, `done`, or session changes from completing the wait. After activity is observed, it waits for the first requested settled status. It does not track individual turns. If the agent is already working, completion of that active turn may satisfy the wait. `--until` narrows the matching states and is rejected unless `--wait` is also present. Standalone `agent wait` returns immediately when the current status matches. Both default to `idle`, `done`, or `blocked`; use `--until unknown` explicitly when needed. @@ -436,6 +438,29 @@ herdr integration uninstall grok herdr integration status [--outdated-only] ``` +## Agent registry + +```sh +herdr registry validate /path/to/agent-registry +herdr registry validate-snapshot /path/to/snapshot.json --runtime-compatible +herdr --session work registry status +herdr --session work registry check [--channel stable|preview|staging] +herdr --session work registry update [--channel stable|preview|staging] +herdr --session work registry reload /path/to/agent-registry +herdr --session work registry reload +herdr --session work registry reset +``` + +`validate` checks local packages, detection rules, and integration assets without connecting to a server or installing anything. `validate-snapshot` also checks a bounded, immutable JSON snapshot's hashes and provenance; `--runtime-compatible` checks its assets against the current binary's compiled installers. `status` shows the selected server's active agents, generation, local or remote provenance, and last error. + +`check` fetches and validates a complete R2 snapshot without activating it. `update` explicitly activates it after validation and durable storage. Both run on the selected server and default to `stable`; `preview` and `staging` must be selected explicitly. An empty or unavailable channel is an error, not an up-to-date result. Channel pointers are fetched fresh, while immutable snapshots use digest-addressed URLs. Check/update do not install integrations or start an automatic update schedule. + +`reload DIRECTORY` activates a complete local registry for that session, replacing rather than merging the previous package set. New agent IDs, CLI definitions, and detection rules become available without rebuilding or restarting Herdr. `agent start --kind` resolves against the selected server's active registry, including newly activated kinds. Relative paths are resolved from the caller's working directory; the directory must be accessible to the selected server. With no directory, `reload` rereads the selected local source or revalidates the retained managed snapshot offline; it never fetches R2. Local mode rejects remote checks/updates. Use `reset` to return to bundled/managed mode without deleting local source or detection override files. + +A malformed package rejects the entire update and leaves the previous registry active. Successful activation saves a last-known-good package snapshot for restart, even if the source directory later disappears. Existing running agents keep their identity; detection catches up asynchronously after activation. + +Herdr starts with its pinned bundled packages unless a saved snapshot exists. `HERDR_AGENT_REGISTRY_SOURCE` explicitly selects a complete local source; only a saved snapshot belonging to that source can satisfy startup. Without an explicit setting, the last accepted local or managed source is restored. Reloading never downloads binaries, installs integrations, runs hooks, or grants trusted reporting privileges to a new agent. Integration installers and report authority remain explicitly supported by Herdr. Supported integration files can update through the registry without a new Herdr build. Apply an available update from Settings → Integrations or rerun `herdr integration install `; registry refresh alone never changes installed files. The CLI uses the selected local session's saved registry even when its server is stopped. New installation layouts or reporting capabilities still require explicit Herdr support. + ## Plugins Plugin commands install and run local executable workflow plugins. A plugin is a manifest plus out-of-process commands; Herdr owns the host surface and plugins own their implementation language. diff --git a/docs/next/website/src/content/docs/integrations.mdx b/docs/next/website/src/content/docs/integrations.mdx index 4cbd0a631b..5e63ada383 100644 --- a/docs/next/website/src/content/docs/integrations.mdx +++ b/docs/next/website/src/content/docs/integrations.mdx @@ -29,6 +29,16 @@ herdr integration install antigravity-cli herdr integration install grok ``` +## Update integrations + +Registry updates can deliver new versions of supported integration files without updating the Herdr binary. Downloading a registry snapshot does not install or replace plugins and hooks. + +Use the existing integrations tab in settings to apply an available update, or rerun `herdr integration install `. The installer uses the selected registry's files and preserves unrelated agent configuration. CLI integration commands operate locally and use the selected session's saved registry, including while that server is stopped. + +Herdr replaces each integration asset atomically. An installation with multiple files can still stop partway through; rerun the installer to repair it. Running agents are not restarted, and when they load a changed plugin depends on that agent's own behavior. + +Installation paths, config handling, and trusted reporting rules remain in Herdr. A registry update cannot introduce an arbitrary installer or grant a new agent trusted reporting authority. + ## Uninstall integrations ```bash diff --git a/docs/next/website/src/content/docs/ja/agents.mdx b/docs/next/website/src/content/docs/ja/agents.mdx index 40d6940e51..cca88e2dd6 100644 --- a/docs/next/website/src/content/docs/ja/agents.mdx +++ b/docs/next/website/src/content/docs/ja/agents.mdx @@ -41,7 +41,7 @@ Herdr は複数のコーディングエージェントを同時に動かすた Herdr はまず各ペインのフォアグラウンドプロセスを検出します。その後、各ペインはひとつの状態権威を持ちます。 -完全なライフサイクルフックを持つエージェントでは、インテグレーションがインストールされ、実行中のペインについて能動的に報告している間は、インテグレーションが権威です。Herdr はそのフック報告を `idle`、`working`、`blocked` とセッション識別に使います。同じライフサイクル権威に対してスクリーンマニフェストのフォールバックを並走させることはしません。これにより、真実の情報源が 2 つ競合する状況を避けます。 +完全なライフサイクルフックを持つエージェントでは、インテグレーションがインストールされ、実行中のペインについて能動的に報告している間は、インテグレーションが権威です。Herdr はそのフック報告を `idle`、`working`、`blocked` とセッション識別に使います。スクリーンのフォールバックがその状態権威と競合することはありません。ただし管理された起動中は、プロンプトを受け付ける前に入力コントロールの表示を画面で確認する場合があります。 完全なライフサイクルフックを持たないエージェントでは、Herdr はフォアグラウンドプロセスを識別し、ライブの下部バッファのスクリーンスナップショットを読みます。そのスナップショットに対して TOML マニフェストを評価し、`idle`、`working`、`blocked` を分類します。それらを発するエージェントでは、マニフェストはターミナルタイトルと進捗 (OSC) シーケンスも検出の証拠としてマッチできます。その証拠がない場合は、スクリーンルールが単独で検出を担います。 @@ -63,19 +63,25 @@ Linux と macOS では、ホストから見えるラッパーが実際のエー ## 検出マニフェスト -バンドルされたマニフェストは Herdr の内部にあります。Herdr は herdr.dev でリモートマニフェストの更新も確認し、有効なエージェント別ルール更新を Herdr の再起動なしで自動適用します。リモートマニフェストは Herdr の state ディレクトリに保存されます。バックグラウンドのリモートマニフェスト確認を無効にするには `[update] manifest_check = false` を設定します。 +Herdr は同梱レジストリまたは保存済みの正常なスナップショットから、オフラインで起動します。リリースビルドでは、起動時と30分ごとにバックグラウンドで `registry.herdr.dev` を確認し、互換性のある更新を検証・保存して、再起動なしで有効化します。自動確認は最後に正常に選択したレジストリチャンネルに従い、初期値は `stable` です。`[update] manifest_check = false` で自動確認を無効にできますが、実行中の確認は完了する場合があります。デバッグビルドは自動確認を行いません。 -ローカルオーバーライドは、プラットフォームの設定ディレクトリからリモートまたはバンドルのマニフェストを置き換えられます: +手動更新も利用できます。`herdr registry check` は完全なスナップショットを有効化せずに検証し、`herdr registry update` は選択したサーバーで取得して有効化します。無効または非互換の更新では、最後に正常に動作したスナップショットが維持されます。旧ウェブサイトのキャッシュは読み込まず、削除もしません。 + +ローカル検出オーバーライドは従来の設定ディレクトリを使用します: ```text ~/.config/herdr/agent-detection/.toml ``` -ローカルオーバーライドが常に優先されます。ローカルオーバーライドがない場合、Herdr はキャッシュされたリモートマニフェストと実行中バイナリにバンドルされたマニフェストのうち、新しくて互換性のある方を使います。デバッグビルドでは、同じ設定ヘルパーが `herdr-dev` のような開発用ディレクトリを使うことがあります。無効なオーバーライドファイルは警告付きで無視され、Herdr はそのエージェントについてキャッシュされたリモートまたはバンドルのマニフェストにフォールバックします。 +有効なローカルオーバーライドは、選択中のレジストリのパッケージより優先されます。無効な場合は警告を表示してそのパッケージに戻ります。選択中のレジストリに存在しないエージェントをオーバーライドだけで追加することはできません。デバッグビルドでは `herdr-dev` を使用する場合があります。更新はオーバーライドを上書きしません。編集後は `herdr server reload-agent-manifests` でオフラインの再読み込みを行います。 + +レジストリの新しいパッケージは、再ビルドなしでエージェント ID、起動定義、プロセス識別、検出ルールを追加できます。信頼済みレポーターとインストーラーは引き続きコンパイル済みの機能です。既存インテグレーションと互換性のないファイルを含む更新は、全体が拒否されます。 -リモートマニフェストは、Herdr がすでに識別方法を知っているエージェントの検出ルールにパッチを当てるものです。完全に新しいエージェントの追加には、プロセス検出、ラベル、インテグレーション挙動のために Herdr バイナリのアップデートが引き続き必要です。 +開発時は `herdr registry reload /path/to/agent-registry` で完全なローカルパッケージ集合を選択できます。リモートとのマージではありません。ローカルモードでは R2 更新を拒否します。`herdr registry reset` はローカルファイルや自動チェック設定を変更せず同梱レジストリに戻り、R2 更新を再び有効にします。サーバー環境の `HERDR_AGENT_REGISTRY_SOURCE` が設定されていれば、再起動時に再びローカルモードになります。 -実行中のサーバーは起動時にアクティブなマニフェストをメモリに読み込みます。リモートマニフェストの自動更新は、新しいルールが書き込まれた後にそのメモリ内キャッシュをリロードします。`herdr server update-agent-manifests` を実行すると、リモートマニフェストの更新を即座に取得して実行中のサーバーをリロードします。ローカルオーバーライドを手で編集した後は、Herdr を再起動するか `herdr server reload-agent-manifests` を実行して、実行中のサーバーにファイルを適用してください。 +Herdr のアップグレードで保存済みパッケージが使えなくなっても、ローカルソースの選択と受け入れ済み更新の履歴を保持し、互換性のあるソースまたは同梱の定義を使用します。選択したローカルレジストリを黙って R2 に切り替えることはありません。検出オーバーライドのファイルも変更しません。 + +R2 のチャネルポインターは毎回取得し、不変スナップショットは内容のダイジェストで指定します。不正・非互換・中断・古い更新では有効なスナップショット全体を維持します。検出は非同期で更新されますが、実行中のエージェントの ID や固定済み再開指示は置き換えません。待機中の復元は計画作成時に許可されます。開始前にレジストリが更新されても、コマンドと入力準備の判定方針は固定されたままです。パッケージの削除は新しい起動許可を止めますが、許可済みの待機中の復元は取り消しません。 ペインの状態表示がおかしいときは `herdr agent explain` を使ってください: @@ -107,6 +113,8 @@ herdr integration status 対応エージェントごとに、インテグレーションの名前と挙動は異なります。エージェント別の詳細と完全なインストール一覧は[インテグレーション](/ja/docs/integrations/)を参照してください。エージェントを開発している場合は、[カスタムインテグレーションガイド](/ja/docs/integrations/#独自エージェントを統合する)で、Herdr にネイティブサポートを追加せずにライフサイクル状態を報告する方法を確認できます。 +エージェント作者は[レジストリにパッケージを提供](https://github.com/herdrdev/agent-registry)することもできます。ネイティブ API 報告とレジストリパッケージは補完関係にあります。報告はライフサイクル状態とメタデータを提供し、パッケージは起動定義、プロセス識別、必要に応じたスクリーン検出を提供します。パッケージ更新は新しい Herdr バイナリなしで配信できます。どちらの方法も、信頼済みネイティブセッションの所有権やコンパイル済みインストーラーを自動的に付与するものではありません。 + ## カスタムエージェントラベル 表示用にエージェントターゲットの名前を変えられます: diff --git a/docs/next/website/src/content/docs/ja/cli-reference.mdx b/docs/next/website/src/content/docs/ja/cli-reference.mdx index 259fc05f38..d4d6b90f51 100644 --- a/docs/next/website/src/content/docs/ja/cli-reference.mdx +++ b/docs/next/website/src/content/docs/ja/cli-reference.mdx @@ -124,7 +124,7 @@ herdr server update-agent-manifests [--json] herdr server reload-agent-manifests ``` -`herdr server` はヘッドレスサーバーを明示的に起動します。監視下やサービス的な構成で使ってください。`reload-config` はペインを再起動せずにリロード可能な設定を適用します。`agent-manifests` は、アクティブなエージェント検出マニフェストのソース、キャッシュされたリモートバージョン、直近のリモート更新結果を表示します。`update-agent-manifests` はリモートマニフェストの更新を即座に取得し、実行中のサーバーにリロードして、更新後のマニフェスト状態を表示します。生のステータスレスポンスが欲しいときは `--json` を渡してください。`reload-agent-manifests` は、ローカルオーバーライドの編集後にエージェント検出マニフェストを実行中のサーバーにリロードします。 +`herdr server` はヘッドレスサーバーを明示的に起動します。監視下やサービス的な構成で使ってください。`reload-config` はペインを再起動せずにリロード可能な設定を適用します。`agent-manifests` は、受け入れ済みレジストリの検出ソースとバージョンを表示します。`update-agent-manifests` は `registry update` の互換エイリアスです。検出ルールだけでなく、エージェント ID と CLI 定義を含む**完全な R2 レジストリスナップショット**を取得して有効化します。その後、更新結果とマニフェスト状態を表示します。`--json` は従来のマニフェスト状態レスポンス形式を維持します。更新元の詳細やエラーは `registry status` で確認してください。`reload-agent-manifests` は、ローカルオーバーライドの編集後にエージェント検出マニフェストを実行中のサーバーにリロードします。 ## 通知 @@ -324,9 +324,11 @@ herdr agent explain --file PATH --agent LABEL [--json|--verbose] エージェントターゲットは、一意なライブエージェント名、または現在そのエージェントをホストしているペイン ID です。ターミナル ID とエージェント kind のラベルだけでは指定できません。`agent start` で起動するエージェントには名前が必須で、手動で起動したエージェントは名前なしのままペイン ID で指定します。 -`agent start` は既存の利用可能なシェルペインを起動対象にします。対話シェル自身がフォアグラウンドを所有し、フォアグラウンドのコマンド、エディタ、エージェントが動いていない必要があります。トポロジーは別に作成します。名前はライブエージェント間で一意で、`[a-z][a-z0-9_-]{0,31}` に一致する必要があります。対応する kind は `pi`、`claude`、`codex`、`gemini`、`cursor`、`devin`、`agy`、`cline`、`omp`、`mastracode`、`opencode`、`copilot`、`kimi`、`kiro`、`droid`、`amp`、`grok`、`hermes`、`kilo`、`qodercli`、`qwen`、`maki`、`muse` です。名前は現在のペイン占有者に属し、そのエージェントの終了、release、置換で消えます。一時的に検出できないだけでは消えません。 +`agent start` は既存の利用可能なシェルペインを起動対象にします。対話シェル自身がフォアグラウンドを所有し、フォアグラウンドのコマンド、エディタ、エージェントが動いていない必要があります。トポロジーは別に作成します。名前はライブエージェント間で一意で、`[a-z][a-z0-9_-]{0,31}` に一致する必要があります。同梱される kind は `pi`、`claude`、`codex`、`gemini`、`cursor`、`devin`、`agy`、`cline`、`omp`、`mastracode`、`opencode`、`copilot`、`kimi`、`kiro`、`droid`、`amp`、`grok`、`hermes`、`kilo`、`qodercli`、`qwen`、`maki`、`muse` です。名前は現在のペイン占有者に属し、そのエージェントの終了、release、置換で消えます。一時的に検出できないだけでは消えません。 -成功した start は、期待したエージェントが同じターミナルを所有し、対話入力の準備ができてから返ります。起動中の検出状態が `blocked` の場合、コマンドは直ちに `agent_not_ready` を返します。名前は `agent read` と `agent send-keys` で引き続き使用でき、検出状態が `idle` になるとプロンプトを送信できるようになります。デフォルトの起動タイムアウトは 30000 ミリ秒で、明示する値は 3000 より大きく 300000 以下でなければなりません。 +成功した start は、期待したエージェントが同じターミナルを所有し、対話入力の準備ができてから返ります。起動中の検出状態が `blocked` の場合、コマンドは直ちに `agent_not_ready` を返します。名前は `agent read` と `agent send-keys` で引き続き使用できます。OpenCode など画面上の入力欄を検出するエージェントでは、入力コントロールが表示され、ブロック状態が解除されるまでプロンプトを送信できません。`idle` レポートだけでは準備完了になりません。デフォルトの起動タイムアウトは 30000 ミリ秒で、明示する値は 3000 より大きく 300000 以下でなければなりません。 + +コールド復元されたエージェントは、再開コマンドが送信されるまでキューに留まります。その後、通常の起動と同じ準備確認を経てプロンプトを受け付けます。保存された名前やセッションの復元だけでは準備完了になりません。 `agent prompt` は現在の bracketed paste モードを尊重し、working 中でもテキストと遅延した Enter を順序付きの 1 回の送信として書き込みます。`--wait` なしの成功は書き込みの完了を示し、ターンの開始を保証しません。Windows の Codex では Enter の前にペースト境界が送られるため、送信はプロンプトのサイズに依存しません。呼び出し側のタイムアウトには送信時間も含まれます。エージェントがすでに `blocked` の場合は、入力を送信せずに `agent_blocked` を返します。`--wait` を使う場合、別の non-working 状態から受け付けたプロンプトでは、送信後の最大 5 秒間に `working` または `blocked` が観測される必要があります。観測できなければ Herdr は `agent_prompt_stalled` を返し、呼び出し側のタイムアウトが先に切れた場合は通常の `timeout` エラーを返します。これにより、無関係な `idle`、`done`、またはセッションの変化によって待機が誤って完了することを防ぎます。活動を観測した後、要求された最初の安定状態を待ちます。個々のターンは追跡しません。すでに working の場合、進行中ターンの完了が待機を満たすことがあります。`--until` は一致状態を絞り込み、`--wait` なしでは拒否されます。単独の `agent wait` は現在の状態が一致すれば即座に返ります。どちらもデフォルトは `idle`、`done`、`blocked` です。 @@ -401,6 +403,31 @@ herdr integration uninstall cursor herdr integration status [--outdated-only] ``` +## エージェントレジストリ + +`agent start --kind` は、リロードで追加された kind も含め、選択したサーバーで有効なレジストリを参照します。 + +```sh +herdr registry validate /path/to/agent-registry +herdr registry validate-snapshot /path/to/snapshot.json --runtime-compatible +herdr --session work registry status +herdr --session work registry check [--channel stable|preview|staging] +herdr --session work registry update [--channel stable|preview|staging] +herdr --session work registry reload /path/to/agent-registry +herdr --session work registry reload +herdr --session work registry reset +``` + +`validate` はローカルのパッケージ、検出ルール、インテグレーションをオフラインで検証します。`validate-snapshot` は JSON スナップショットのサイズ制限、ハッシュ、来歴も検証し、`--runtime-compatible` はコンパイル済みインストーラーとの互換性を確認します。`status` は選択したサーバーの有効なエージェント、世代、ローカルまたはリモートの来歴、最後のエラーを表示します。 + +`check` は R2 の完全なスナップショットを有効化せず検証します。`update` は検証・永続化後に明示的に有効化します。どちらも選択したサーバーで実行し、既定チャネルは `stable` です。`preview` と `staging` は明示指定します。空または利用不能なチャネルはエラーです。ポインターは毎回取得し、不変スナップショットはダイジェストで指定します。インテグレーションのインストールや自動更新の予約は行いません。 + +`reload DIRECTORY` は、そのセッションのレジストリ全体を置き換えます。既存のパッケージへの追加ではありません。新しいエージェント ID、CLI 定義、検出ルールを、Herdr の再ビルドや再起動なしで有効にできます。相対パスは呼び出し元の作業ディレクトリを基準に解決され、選択したサーバーからアクセスできる必要があります。ディレクトリを省略すると、選択中のローカル読み込み元または保持済みスナップショットをオフラインで再検証します。R2 は取得しません。ローカルモードではリモート確認・更新を拒否します。`reset` はローカルファイルを削除せず同梱レジストリに戻ります。 + +不正なパッケージがある場合、更新全体を拒否し、前のレジストリを維持します。有効化に成功したパッケージは再起動用に保存されるため、読み込み元が削除されても利用できます。実行中のエージェントの ID は維持され、検出は有効化後に非同期で更新されます。 + +保存済みの有効なスナップショットがなければ、Herdr は固定された同梱パッケージを使用します。`HERDR_AGENT_REGISTRY_SOURCE` はローカル読み込み元を明示指定し、その読み込み元に一致する保存済みスナップショットだけを復元します。明示指定がなければ最後に受け付けたローカルまたは管理対象の読み込み元を復元します。再読み込みはバイナリの取得、インテグレーションのインストール、フックの実行、新しいエージェントへの信頼済みレポート権限の付与を行いません。インストーラーとレポート権限は Herdr が明示的に対応するものに限られます。対応済みインテグレーションのファイル内容やバージョンの変更には新しい Herdr ビルドが必要で、実行時の有効化では拒否されます。 + ## プラグイン プラグインコマンドは、ローカル実行型ワークフロープラグインをインストールして実行します。プラグインはマニフェストとプロセス外コマンドの組み合わせです。ホスト側の面は Herdr が、実装言語はプラグインが担います。 diff --git a/docs/next/website/src/content/docs/ja/integrations.mdx b/docs/next/website/src/content/docs/ja/integrations.mdx index 124010e177..a9b18f96d1 100644 --- a/docs/next/website/src/content/docs/ja/integrations.mdx +++ b/docs/next/website/src/content/docs/ja/integrations.mdx @@ -31,6 +31,16 @@ herdr integration install antigravity-cli herdr integration install grok ``` +## インテグレーションを更新する + +レジストリの更新により、Herdr 本体を更新せずに対応インテグレーションの新しいファイルを取得できます。レジストリのスナップショットをダウンロードするだけでは、プラグインやフックはインストールも置換もされません。 + +設定のインテグレーションタブで更新を適用するか、`herdr integration install ` を再実行してください。インストーラーは選択したレジストリのファイルを使い、無関係なエージェント設定を保持します。CLI のインテグレーションコマンドはローカルで動作し、選択したセッションに保存されたレジストリを使います。サーバーの停止中も利用できます。 + +Herdr は各インテグレーションファイルをアトミックに置換します。ただし、複数ファイルのインストールは途中で止まる場合があります。その場合はインストーラーを再実行してください。実行中のエージェントは再起動されません。変更されたプラグインを読み込むタイミングは、そのエージェントの動作に依存します。 + +インストール先、設定の処理、信頼する報告元のルールは引き続き Herdr 本体が管理します。レジストリの更新で任意のインストーラーを追加したり、新しいエージェントに信頼された報告権限を与えたりすることはできません。 + ## インテグレーションをアンインストールする ```bash diff --git a/docs/next/website/src/content/docs/session-state.mdx b/docs/next/website/src/content/docs/session-state.mdx index ed147193c0..7f56677362 100644 --- a/docs/next/website/src/content/docs/session-state.mdx +++ b/docs/next/website/src/content/docs/session-state.mdx @@ -56,7 +56,11 @@ Native agent session restore is enabled by default. Disable it with: resume_agents_on_restore = false ``` -Herdr only resumes panes that reported a native session reference through a current official Herdr integration. +Herdr needs a trusted native session reference: one reported through a current official integration, or an explicit resume target supplied to `herdr agent start` that matches the agent's registry resume definition. Screen detection alone does not establish conversation ownership. + +Herdr also preserves explicitly supplied CLI choices, such as model or permission mode, when the agent's registry `resume.toml` allows them. It reads structured process arguments, not shell command text, and never replays prompts or substitutes another session target. Unknown options are not preserved. Switching conversations inside the same running agent updates the saved session reference while preserving its original CLI choices. A replacement process must supply its own options. An unreadable process command line means resuming without those extras. + +Allowed choices are checked again before resume. Registry updates can remove an option from replay, but cannot change an already-authorized queued restore's session or command. Detection-only local overrides do not change the resume allowlist. After a client attaches and provides terminal size and theme context, Herdr resumes eligible restored agent panes across workspaces and tabs without waiting for each pane to be focused. diff --git a/docs/next/website/src/content/docs/zh-cn/agents.mdx b/docs/next/website/src/content/docs/zh-cn/agents.mdx index 27b3a8be3d..3ab217fc34 100644 --- a/docs/next/website/src/content/docs/zh-cn/agents.mdx +++ b/docs/next/website/src/content/docs/zh-cn/agents.mdx @@ -41,7 +41,7 @@ Herdr 为同时运行多个编程智能体而生。每个智能体都待在一 Herdr 首先检测每个窗格的前台进程。之后,每个窗格有且只有一个状态权威。 -对于具备完整生命周期钩子的智能体,当集成已安装并在为运行中的窗格主动上报时,集成就是权威。Herdr 用这些钩子上报来决定 `idle`、`working`、`blocked` 和会话身份。对同一个生命周期权威,它不会再并行运行屏幕清单兜底。这样可以避免出现两个相互竞争的事实来源。 +对于具备完整生命周期钩子的智能体,当集成已安装并在为运行中的窗格主动上报时,集成就是权威。Herdr 用这些钩子上报来决定 `idle`、`working`、`blocked` 和会话身份。屏幕兜底不会与该生命周期权威争夺状态控制。不过在托管启动期间,Herdr 仍可能检查屏幕,确认输入控件已显示后才接受提示。 对于没有完整生命周期钩子的智能体,Herdr 识别前台进程,并读取实时的底部缓冲区屏幕快照。它对该快照评估 TOML 清单,来判定 `idle`、`working` 和 `blocked`。对于会发出这些信号的智能体,清单也可以把终端标题和进度 (OSC) 序列作为检测证据;当这些证据不存在时,屏幕规则独自承担检测。 @@ -63,19 +63,25 @@ Herdr 首先检测每个窗格的前台进程。之后,每个窗格有且只有 ## 检测清单 -内置清单打包在 Herdr 内部。Herdr 还会向 herdr.dev 检查远程清单更新,并自动应用有效的按智能体规则更新,不需要重启 Herdr。远程清单存放在 Herdr 的状态目录中。设置 `[update] manifest_check = false` 可以禁用后台远程清单检查。 +Herdr 使用内置注册表或保存的最后有效快照离线启动。正式构建会在启动时及每隔30分钟在后台检查 `registry.herdr.dev`,验证、保存并热启用兼容的更新,无需重启。自动检查沿用上次成功选择的注册表渠道,初始为 `stable`。设置 `[update] manifest_check = false` 可禁用自动检查,但正在进行的检查可能仍会完成。调试构建不运行自动检查。 -本地覆盖可以从平台配置目录替换远程或内置清单: +手动更新仍然可用:`herdr registry check` 验证完整快照但不启用,`herdr registry update` 则在所选服务器下载并热启用更新。无效或不兼容的更新不会替换当前有效快照。旧网站缓存不会被读取,也不会被删除。 + +本地检测覆盖继续使用原有的平台配置目录: ```text ~/.config/herdr/agent-detection/.toml ``` -本地覆盖始终优先。没有本地覆盖时,Herdr 在缓存的远程清单和运行中二进制文件内置的清单之间,选择更新且兼容的那个。在调试构建上,同一个配置助手可能使用 `herdr-dev` 之类的开发目录。无效的覆盖文件会被忽略并给出警告,Herdr 会对该智能体回退到缓存的远程或内置清单。 +有效的本地覆盖优先于所选注册表中的包。无效覆盖会产生警告并回退到该包;覆盖本身不能添加所选注册表中不存在的智能体。调试构建可能使用 `herdr-dev` 配置目录。注册表更新不会覆盖这些文件。编辑后使用 `herdr server reload-agent-manifests` 离线重新加载。 + +新注册表包可以无需重新构建 Herdr 就添加智能体 ID、启动定义、进程识别和检测规则。可信报告器和集成安装器仍是编译时提供的能力;包含不兼容内置集成文件的快照会被整体拒绝。 -远程清单只为 Herdr 已经知道如何识别的智能体修补检测规则。添加全新的智能体仍然需要更新 Herdr 二进制文件,以获得进程检测、标签和集成行为。 +开发注册表时,使用 `herdr registry reload /path/to/agent-registry` 选择完整的本地包集合,而不是与远程注册表合并。本地模式下会拒绝 R2 更新。`herdr registry reset` 返回内置注册表并重新允许 R2 更新,不修改本地文件或自动检查设置。如果服务器环境设置了 `HERDR_AGENT_REGISTRY_SOURCE`,重启后会再次选择本地模式。 -运行中的服务器在启动时把生效的清单加载进内存。自动的远程清单更新会在写入新规则后重新加载该内存缓存。运行 `herdr server update-agent-manifests` 可以立即拉取远程清单更新并重载运行中的服务器。手动编辑本地覆盖后,重启 Herdr 或运行 `herdr server reload-agent-manifests` 把文件应用到运行中的服务器。 +如果 Herdr 升级后无法使用已保存的包,它会保留本地来源选择和已接受更新的历史,同时使用兼容来源或内置定义。它不会悄悄将选定的本地注册表切换到 R2。检测覆盖文件也不会被修改。 + +R2 渠道指针每次重新获取;不可变快照通过精确字节摘要寻址。格式错误、不兼容、下载中断或过旧的更新不会改变整个有效快照。检测在启用后异步更新,不会重命名现有智能体或替换其固定的恢复指令。排队中的恢复在创建计划时获得授权:即使启动前注册表发生更新,其命令和输入就绪判定策略仍保持固定。移除包会阻止新的启动授权,但不会取消已获授权的排队恢复。 当某个窗格显示了错误状态时,用 `herdr agent explain`: @@ -107,6 +113,8 @@ herdr integration status 每个受支持的智能体都有自己的集成名称和行为。按智能体的细节和完整安装列表见[集成](/zh-cn/docs/integrations/)。如果你正在构建智能体,[自定义集成指南](/zh-cn/docs/integrations/#集成你自己的智能体)介绍了如何在不为 Herdr 添加原生支持的情况下上报生命周期状态。 +智能体作者也可以[向注册表贡献包](https://github.com/herdrdev/agent-registry)。原生 API 报告和注册表包相互补充:报告提供生命周期状态和元数据;包提供启动定义、进程识别及可选的屏幕检测。包更新无需新的 Herdr 二进制即可到达用户。两种方式都不会自动授予可信原生会话所有权或编译内置的安装适配器。 + ## 自定义智能体标签 你可以重命名智能体目标的显示名: diff --git a/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx b/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx index bc01d1f290..f2e57361a8 100644 --- a/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx +++ b/docs/next/website/src/content/docs/zh-cn/cli-reference.mdx @@ -124,7 +124,7 @@ herdr server update-agent-manifests [--json] herdr server reload-agent-manifests ``` -`herdr server` 显式运行无界面服务器,适合被监管或服务式的部署。`reload-config` 在不重启窗格的情况下应用可重载设置。`agent-manifests` 显示生效的智能体检测清单来源、缓存的远程版本和最近的远程更新结果。`update-agent-manifests` 立即拉取远程清单更新,重载到运行中的服务器,并打印更新后的清单状态;要原始状态响应就加 `--json`。`reload-agent-manifests` 在编辑本地覆盖后,把智能体检测清单重载到运行中的服务器。 +`herdr server` 显式运行无界面服务器,适合被监管或服务式的部署。`reload-config` 在不重启窗格的情况下应用可重载设置。`agent-manifests` 显示已接受注册表中的检测来源和版本。`update-agent-manifests` 是 `registry update` 的兼容别名:它获取并启用**完整的 R2 注册表快照**,包括智能体 ID 和 CLI 定义,而不只是检测规则。随后显示更新结果和清单状态;`--json` 保留旧版清单状态响应格式。使用 `registry status` 查看完整的更新来源和错误。`reload-agent-manifests` 在编辑本地覆盖后,把智能体检测清单重载到运行中的服务器。 ## 通知 @@ -324,9 +324,11 @@ herdr agent explain --file PATH --agent LABEL [--json|--verbose] 智能体目标只能是唯一的实时智能体名称,或当前承载该智能体的窗格 ID。终端 ID 和单独的智能体 kind 标签不能作为目标。通过 `agent start` 启动的智能体必须有名称;手动启动的智能体保持未命名,通过窗格 ID 寻址。 -`agent start` 会在现有可用 shell 窗格中启动智能体:交互式 shell 必须占用前台,不能有正在前台运行的命令、编辑器或智能体。拓扑必须单独创建。名称在实时智能体中必须唯一,并匹配 `[a-z][a-z0-9_-]{0,31}`。支持的 kind 是 `pi`、`claude`、`codex`、`gemini`、`cursor`、`devin`、`agy`、`cline`、`omp`、`mastracode`、`opencode`、`copilot`、`kimi`、`kiro`、`droid`、`amp`、`grok`、`hermes`、`kilo`、`qodercli`、`qwen`、`maki` 和 `muse`。名称属于当前窗格占用者,在该智能体退出、release 或被替换时清除;短暂的检测不确定不会清除它。 +`agent start` 会在现有可用 shell 窗格中启动智能体:交互式 shell 必须占用前台,不能有正在前台运行的命令、编辑器或智能体。拓扑必须单独创建。名称在实时智能体中必须唯一,并匹配 `[a-z][a-z0-9_-]{0,31}`。内置的 kind 是 `pi`、`claude`、`codex`、`gemini`、`cursor`、`devin`、`agy`、`cline`、`omp`、`mastracode`、`opencode`、`copilot`、`kimi`、`kiro`、`droid`、`amp`、`grok`、`hermes`、`kilo`、`qodercli`、`qwen`、`maki` 和 `muse`。名称属于当前窗格占用者,在该智能体退出、release 或被替换时清除;短暂的检测不确定不会清除它。 -成功的 start 只有在预期智能体占用同一终端并可接受交互输入后才返回。如果启动期间检测到 `blocked`,命令会立即返回 `agent_not_ready`。该名称仍可用于 `agent read` 和 `agent send-keys`,检测变为 `idle` 后即可用于发送提示。默认启动超时是 30000 毫秒;显式值必须大于 3000 且不超过 300000。 +成功的 start 只有在预期智能体占用同一终端并可接受交互输入后才返回。如果启动期间检测到 `blocked`,命令会立即返回 `agent_not_ready`。该名称仍可用于 `agent read` 和 `agent send-keys`。对于 OpenCode 等通过屏幕检测输入控件的智能体,必须等到输入控件可见且阻塞状态解除后才能发送提示;仅有 `idle` 报告并不足够。默认启动超时是 30000 毫秒;显式值必须大于 3000 且不超过 300000。 + +冷恢复的智能体会保持排队状态,直到恢复命令已发送。之后,它必须通过与正常启动相同的就绪检查才能接受提示;恢复保存的名称或会话本身并不代表已就绪。 `agent prompt` 遵循当前的 bracketed paste 模式,即使智能体处于 working,也会将文本和延迟的 Enter 作为一次有序提交写入。不带 `--wait` 时,成功只确认写入完成,不代表轮次已经开始。在 Windows 上,Codex 在 Enter 前会收到粘贴边界,因此提交不依赖提示大小;调用方的超时包含提交时间。如果智能体已经是 `blocked`,它不会发送输入,而是返回 `agent_blocked`。使用 `--wait` 时,从其他非 working 状态接受的提示必须在提交后的最多五秒内产生可观察的 `working` 或 `blocked` 状态,否则 Herdr 返回 `agent_prompt_stalled`;如果调用方的超时先到期,则返回普通的 `timeout` 错误。这可防止无关的 `idle`、`done` 或会话变化错误地完成等待。观察到活动后,它会等待请求的第一个稳定状态。它不会跟踪单独的轮次。如果智能体已经处于 working,当前轮次的完成可能满足等待。`--until` 用于缩小匹配状态,不带 `--wait` 时会被拒绝。独立的 `agent wait` 在当前状态匹配时立即返回。两者默认匹配 `idle`、`done` 或 `blocked`;需要 `unknown` 时请明确使用 `--until unknown`。 @@ -401,6 +403,31 @@ herdr integration uninstall cursor herdr integration status [--outdated-only] ``` +## 智能体注册表 + +`agent start --kind` 根据所选服务器的当前注册表解析类型,包括通过重载添加的新类型。 + +```sh +herdr registry validate /path/to/agent-registry +herdr registry validate-snapshot /path/to/snapshot.json --runtime-compatible +herdr --session work registry status +herdr --session work registry check [--channel stable|preview|staging] +herdr --session work registry update [--channel stable|preview|staging] +herdr --session work registry reload /path/to/agent-registry +herdr --session work registry reload +herdr --session work registry reset +``` + +`validate` 离线检查本地包、检测规则和集成文件。`validate-snapshot` 还检查 JSON 快照的边界、哈希和来源;`--runtime-compatible` 验证与当前二进制文件中安装器的兼容性。`status` 显示所选服务器当前的智能体、代数、本地或远程来源,以及最近的错误。 + +`check` 获取并验证完整 R2 快照,但不启用;`update` 在验证和持久化后显式启用。两者都由所选服务器执行,默认渠道为 `stable`;`preview` 和 `staging` 需明确指定。空渠道或不可用渠道会报错,不会显示为已是最新。渠道指针每次重新获取,不可变快照通过摘要寻址。这些命令不会安装集成或启用自动更新。 + +`reload DIRECTORY` 替换该会话的完整注册表,而不是合并到现有包中。新的智能体 ID、CLI 定义和检测规则无需重新构建或重启 Herdr 即可启用。相对路径按调用方的工作目录解析,且所选服务器必须能够访问该目录。省略目录时,`reload` 离线重新读取所选本地来源或验证保留的快照,不会获取 R2。本地模式拒绝远程检查和更新;`reset` 返回内置注册表,不删除本地来源或检测覆盖文件。 + +任何无效包都会导致整次更新被拒绝,原注册表继续生效。成功启用后,Herdr 保存经过验证的包快照供重启使用,即使来源目录随后被删除也能恢复。正在运行的智能体保留原有身份;检测在启用后异步更新。 + +没有有效的已保存快照时,Herdr 使用固定的内置包。`HERDR_AGENT_REGISTRY_SOURCE` 显式选择完整本地来源,只恢复与其匹配的已保存快照;未明确设置时恢复上次接受的本地或托管来源。重载不会下载二进制文件、安装集成、执行钩子,也不会授予新智能体可信报告权限。集成安装器和报告权限仍须由 Herdr 明确支持。修改已支持集成的文件内容或版本需要新的 Herdr 构建,运行时启用会拒绝此类修改。 + ## 插件 插件命令用于安装和运行本地可执行的工作流插件。插件是清单加进程外命令;Herdr 负责宿主侧,插件负责自己的实现语言。 diff --git a/docs/next/website/src/content/docs/zh-cn/integrations.mdx b/docs/next/website/src/content/docs/zh-cn/integrations.mdx index e5535808a1..f5d86bc7d5 100644 --- a/docs/next/website/src/content/docs/zh-cn/integrations.mdx +++ b/docs/next/website/src/content/docs/zh-cn/integrations.mdx @@ -31,6 +31,16 @@ herdr integration install antigravity-cli herdr integration install grok ``` +## 更新集成 + +注册表更新可以提供受支持集成文件的新版本,无需更新 Herdr 二进制文件。仅下载注册表快照不会安装或替换插件和钩子。 + +在设置中的集成标签页应用可用更新,或重新运行 `herdr integration install `。安装器使用所选注册表中的文件,并保留无关的智能体配置。CLI 集成命令在本地运行,使用所选会话保存的注册表,即使该会话的服务器已停止也能使用。 + +Herdr 以原子方式替换每个集成文件。但涉及多个文件的安装仍可能中途失败;此时重新运行安装器即可修复。正在运行的智能体不会重启,何时加载更新后的插件取决于智能体自身的行为。 + +安装路径、配置处理和可信上报规则仍由 Herdr 管理。注册表更新不能引入任意安装器,也不能授予新智能体可信上报权限。 + ## 卸载集成 ```bash diff --git a/docs/next/website/src/data/config-reference.json b/docs/next/website/src/data/config-reference.json index 444f6a176d..cf30c23abb 100644 --- a/docs/next/website/src/data/config-reference.json +++ b/docs/next/website/src/data/config-reference.json @@ -467,7 +467,7 @@ "key": "update.manifest_check", "type": "boolean", "default": "true", - "description": "Check herdr.dev for remote agent-detection manifest updates in the background. Bundled manifests and local overrides still apply." + "description": "Automatically check registry.herdr.dev at startup and every 30 minutes and hot-activate compatible agent registry updates. Local overrides still apply; local registry mode and debug builds do not auto-update." } ] }, diff --git a/justfile b/justfile index 77bf48d517..c4a3d90016 100644 --- a/justfile +++ b/justfile @@ -12,8 +12,26 @@ test: just docs-contract-test # Run repository maintenance contract tests -maintenance-test: - {{python}} -m unittest scripts.test_agent_detection_manifest_check scripts.test_changelog scripts.test_config_reference_check scripts.test_docs_translation_parity scripts.test_hermes_integration_asset scripts.test_package_windows_conpty scripts.test_preview scripts.test_unix_installer scripts.test_vendor_libghostty_vt scripts.test_vendor_portable_pty scripts.test_windows_cross +maintenance-test: agent-registry-check agent-registry-validate + {{python}} -m unittest scripts.test_agent_registry_vendor scripts.test_changelog scripts.test_config_reference_check scripts.test_docs_translation_parity scripts.test_hermes_integration_asset scripts.test_package_windows_conpty scripts.test_preview scripts.test_unix_installer scripts.test_vendor_libghostty_vt scripts.test_vendor_portable_pty scripts.test_windows_cross + +# Explicitly refresh the checked-in agent registry from a local source tree +agent-registry-sync source: + cargo run --locked -- registry validate "{{source}}" + {{python}} scripts/agent_registry_vendor.py sync --source "{{source}}" + +# Import a reviewed immutable snapshot OFFLINE through the existing vendor rollback path +[positional-arguments] +agent-registry-sync-snapshot snapshot sha256 validator: + {{python}} scripts/agent_registry_vendor.py sync-snapshot --snapshot "$1" --sha256 "$2" --validator "$3" + +# Herdr owns package/detection semantics; never compare against the legacy website catalog +agent-registry-validate: + cargo run --locked -- registry validate vendor/agent-registry + +# Verify the pinned registry and generated include index without source/network access +agent-registry-check: + {{python}} scripts/agent_registry_vendor.py --check # Run one nextest filter, e.g. `just test-one codex_stale_working` test-one filter: @@ -57,13 +75,13 @@ windows-lint: # Check formatting + run unit tests + Windows target lint + documentation contract tests [unix] -check: ci windows-lint +check: agent-registry-check ci windows-lint just docs-contract-test @echo "docs reminder: if this changes user-facing behavior, make sure the relevant release docs are updated or called out before release." [script("powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File")] [windows] -check: +check: agent-registry-check & .\scripts\windows_check.ps1 -Mode check # Install repo-local git hooks @@ -93,8 +111,8 @@ docs-contract-test: # Test bundled agent integration assets integration-assets-test: bun test src/integration/assets/herdr-agent-state.test.ts - bun test src/integration/assets/opencode/herdr-agent-state.test.ts - bun test src/integration/assets/opencode/herdr-tui-session.test.ts + bun test src/integration/assets/opencode-agent-state.test.ts + bun test src/integration/assets/opencode-tui-session.test.ts # Regenerate the C API bindings with bindgen-cli 0.72.1 libghostty-bindings *clang_args: @@ -105,8 +123,7 @@ build-libghostty-vt: scripts/build_vendored_libghostty_vt.sh # Check that release docs and changelog have been finalized from docs/next before release -release-docs-check: - python3 scripts/agent_detection_manifest_check.py --require-all-published +release-docs-check: agent-registry-check agent-registry-validate python3 scripts/config_reference_check.py node scripts/docs/versions.mjs check node scripts/docs/preview.mjs check diff --git a/nix/package.nix b/nix/package.nix index 48de7085be..50c595ceb0 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -42,6 +42,7 @@ rustPlatform.buildRustPackage { ../assets ../docs/next/api/herdr-api.schema.json ../src + ../vendor/agent-registry ../vendor/libghostty-vt ../vendor/libghostty-vt.vendor.json ../vendor/portable-pty diff --git a/scripts/agent_detection_manifest_check.py b/scripts/agent_detection_manifest_check.py deleted file mode 100644 index 6990a21526..0000000000 --- a/scripts/agent_detection_manifest_check.py +++ /dev/null @@ -1,393 +0,0 @@ -#!/usr/bin/env python3 -"""Validate bundled and published agent detection manifests.""" - -from __future__ import annotations - -import argparse -import hashlib -import re -import sys -import tomllib -from pathlib import Path - - -PROJECT_ROOT = Path(__file__).resolve().parents[1] -DEFAULT_BUNDLED_DIR = PROJECT_ROOT / "src" / "detect" / "manifests" -DEFAULT_PUBLISHED_DIR = PROJECT_ROOT / "distribution" / "agent-detection" -ENGINE_SOURCE = PROJECT_ROOT / "src" / "detect" / "manifest_update.rs" - -MANIFEST_KEYS = {"id", "version", "min_engine_version", "updated_at", "aliases", "rules"} -RULE_KEYS = { - "id", - "state", - "priority", - "region", - "visible_idle", - "visible_blocker", - "visible_working", - "skip_state_update", - "all", - "any", - "not", - "contains", - "regex", - "line_regex", -} -GATE_KEYS = {"all", "any", "not", "contains", "regex", "line_regex"} -STATES = {"idle", "working", "blocked", "unknown"} -REGION_RE = re.compile( - r"^(whole_recent|whole_recent_without_current_prompt_marker|after_last_prompt_marker|" - r"before_current_prompt_marker|current_prompt_block_marker|after_current_prompt_block_marker|" - r"prompt_box_body|above_prompt_box|last_non_empty_above_prompt_box|after_last_horizontal_rule|" - r"osc_title|osc_progress|" - r"bottom_lines\([1-9][0-9]*\)|bottom_non_empty_lines\([1-9][0-9]*\)|" - r"top_non_empty_lines\([1-9][0-9]*\))$" -) -REGION_COUNT_RE = re.compile(r"\(([1-9][0-9]*)\)$") -VERSION_RE = re.compile(r"^[0-9]+(?:\.[0-9]+)*$") -MAX_TOP_REGION_LINE_COUNT = 65_535 -MAX_RULES_PER_MANIFEST = 128 -MAX_GATE_DEPTH = 8 -MAX_TOTAL_GATES = 512 -MAX_MATCHERS_PER_GATE = 32 -MAX_TOTAL_MATCHERS = 1024 -MAX_MATCHER_CHARS = 512 - -# Keep engine-2 clients on the OSC-capable manifest until an engine-3 release -# can consume top_non_empty_lines. Remove this entry when the distribution -# publishes the bundled Grok manifest. -STAGED_PUBLISHED_MANIFESTS = { - "grok": ( - "2026.07.16.2", - "2026.07.16.1", - "1f35b3271a96cf830c64bed78751619bfd8013c277c0d7c0f999b7a433895f28", - ), -} - -UNPUBLISHED_BUNDLED_MANIFESTS: dict[str, tuple[str, str]] = {} - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--bundled-dir", type=Path, default=DEFAULT_BUNDLED_DIR) - parser.add_argument("--published-dir", type=Path, default=DEFAULT_PUBLISHED_DIR) - parser.add_argument("--engine-version", type=int) - parser.add_argument( - "--require-published", - action="store_true", - help="fail if published agent-detection assets or catalog are missing", - ) - parser.add_argument( - "--require-all-published", - action="store_true", - help="fail if any bundled manifest is intentionally held for a future stable release", - ) - return parser.parse_args() - - -def read_engine_version(explicit: int | None) -> int: - if explicit is not None: - return explicit - content = ENGINE_SOURCE.read_text(encoding="utf-8") - match = re.search(r"MANIFEST_ENGINE_VERSION:\s*u32\s*=\s*([0-9]+)", content) - if not match: - raise CheckError(f"could not find MANIFEST_ENGINE_VERSION in {ENGINE_SOURCE}") - return int(match.group(1)) - - -class CheckError(Exception): - pass - - -def load_toml(path: Path) -> dict: - try: - with path.open("rb") as fh: - value = tomllib.load(fh) - except tomllib.TOMLDecodeError as exc: - raise CheckError(f"{path}: invalid TOML: {exc}") from exc - if not isinstance(value, dict): - raise CheckError(f"{path}: TOML root must be a table") - return value - - -def version_tuple(value: str, path: Path) -> tuple[int, ...]: - if not isinstance(value, str) or not VERSION_RE.fullmatch(value): - raise CheckError(f"{path}: version must be dotted numeric") - return tuple(int(part) for part in value.split(".")) - - -def compare_versions(left: str, right: str, path: Path) -> int: - left_parts = list(version_tuple(left, path)) - right_parts = list(version_tuple(right, path)) - width = max(len(left_parts), len(right_parts)) - left_parts.extend([0] * (width - len(left_parts))) - right_parts.extend([0] * (width - len(right_parts))) - return (left_parts > right_parts) - (left_parts < right_parts) - - -def validate_manifest(path: Path, engine_version: int) -> dict: - manifest = load_toml(path) - unknown = sorted(set(manifest) - MANIFEST_KEYS) - if unknown: - raise CheckError(f"{path}: unknown manifest field(s): {', '.join(unknown)}") - - agent_id = manifest.get("id") - if not isinstance(agent_id, str) or not agent_id.strip(): - raise CheckError(f"{path}: id must be a non-empty string") - - version = manifest.get("version") - version_tuple(version, path) - - min_engine = manifest.get("min_engine_version") - if not isinstance(min_engine, int): - raise CheckError(f"{path}: min_engine_version must be an integer") - if min_engine > engine_version: - raise CheckError( - f"{path}: min_engine_version {min_engine} exceeds engine {engine_version}" - ) - - aliases = manifest.get("aliases", []) - if not isinstance(aliases, list) or not all(isinstance(item, str) for item in aliases): - raise CheckError(f"{path}: aliases must be an array of strings") - - rules = manifest.get("rules") - if not isinstance(rules, list) or not rules: - raise CheckError(f"{path}: rules must be a non-empty array") - if len(rules) > MAX_RULES_PER_MANIFEST: - raise CheckError(f"{path}: manifest exceeds max rule count {MAX_RULES_PER_MANIFEST}") - complexity = {"gates": 0, "matchers": 0} - for index, rule in enumerate(rules): - validate_rule(path, index, rule, complexity) - region = rule.get("region", "whole_recent") - if region.startswith("top_non_empty_lines(") and min_engine < 3: - raise CheckError( - f"{path}: rule {rule['id']} region {region!r} requires min_engine_version 3" - ) - - return manifest - - -def validate_rule(path: Path, index: int, rule: object, complexity: dict[str, int]) -> None: - if not isinstance(rule, dict): - raise CheckError(f"{path}: rule {index} must be a table") - unknown = sorted(set(rule) - RULE_KEYS) - if unknown: - raise CheckError(f"{path}: rule {index} has unknown field(s): {', '.join(unknown)}") - rule_id = rule.get("id") - if not isinstance(rule_id, str) or not rule_id.strip(): - raise CheckError(f"{path}: rule {index} id must be a non-empty string") - state = rule.get("state") - if state is not None and state not in STATES: - raise CheckError(f"{path}: rule {rule_id} has invalid state {state!r}") - region = rule.get("region", "whole_recent") - if not isinstance(region, str) or not REGION_RE.fullmatch(region): - raise CheckError(f"{path}: rule {rule_id} has invalid region {region!r}") - count_match = REGION_COUNT_RE.search(region) - if ( - region.startswith("top_non_empty_lines(") - and count_match - and int(count_match.group(1)) > MAX_TOP_REGION_LINE_COUNT - ): - raise CheckError(f"{path}: rule {rule_id} has invalid region {region!r}") - if rule.get("skip_state_update"): - if state != "unknown": - raise CheckError(f"{path}: rule {rule_id} skip_state_update requires state unknown") - if rule.get("visible_idle") or rule.get("visible_blocker") or rule.get("visible_working"): - raise CheckError(f"{path}: rule {rule_id} skip_state_update cannot set visible flags") - validate_gate(path, f"rule {rule_id}", rule, require_positive=True, depth=0, complexity=complexity) - - -def validate_gate( - path: Path, - label: str, - gate: dict, - require_positive: bool, - depth: int, - complexity: dict[str, int], -) -> None: - if depth > MAX_GATE_DEPTH: - raise CheckError(f"{path}: {label} exceeds max gate depth {MAX_GATE_DEPTH}") - complexity["gates"] += 1 - if complexity["gates"] > MAX_TOTAL_GATES: - raise CheckError(f"{path}: manifest exceeds max gate count {MAX_TOTAL_GATES}") - unknown = sorted(set(gate) - (RULE_KEYS if label.startswith("rule ") else GATE_KEYS)) - if unknown: - raise CheckError(f"{path}: {label} has unknown gate field(s): {', '.join(unknown)}") - matcher_count = 0 - for key in ("contains", "regex", "line_regex"): - values = gate.get(key, []) - if not isinstance(values, list) or not all(isinstance(item, str) for item in values): - raise CheckError(f"{path}: {label} {key} must be an array of strings") - matcher_count += len(values) - for value in values: - if len(value) > MAX_MATCHER_CHARS: - raise CheckError(f"{path}: {label} matcher exceeds max length {MAX_MATCHER_CHARS}") - if matcher_count > MAX_MATCHERS_PER_GATE: - raise CheckError(f"{path}: {label} exceeds max direct matcher count {MAX_MATCHERS_PER_GATE}") - complexity["matchers"] += matcher_count - if complexity["matchers"] > MAX_TOTAL_MATCHERS: - raise CheckError(f"{path}: manifest exceeds max matcher count {MAX_TOTAL_MATCHERS}") - nested_any = gate.get("any", []) - nested_all = gate.get("all", []) - nested_not = gate.get("not", []) - for key, values in (("any", nested_any), ("all", nested_all), ("not", nested_not)): - if not isinstance(values, list): - raise CheckError(f"{path}: {label} {key} must be an array") - if require_positive and not has_positive_matcher(gate): - raise CheckError(f"{path}: {label} must contain a positive matcher") - for idx, nested in enumerate(nested_any): - validate_nested_gate(path, f"{label} any[{idx}]", nested, require_positive=True, depth=depth + 1, complexity=complexity) - for idx, nested in enumerate(nested_all): - validate_nested_gate(path, f"{label} all[{idx}]", nested, require_positive=True, depth=depth + 1, complexity=complexity) - for idx, nested in enumerate(nested_not): - validate_nested_gate(path, f"{label} not[{idx}]", nested, require_positive=False, depth=depth + 1, complexity=complexity) - - -def validate_nested_gate( - path: Path, - label: str, - gate: object, - require_positive: bool, - depth: int, - complexity: dict[str, int], -) -> None: - if not isinstance(gate, dict): - raise CheckError(f"{path}: {label} must be a table") - if not require_positive and not has_any_matcher(gate): - raise CheckError(f"{path}: {label} must contain a matcher") - validate_gate(path, label, gate, require_positive=require_positive, depth=depth, complexity=complexity) - - -def has_positive_matcher(gate: dict) -> bool: - return bool(gate.get("contains") or gate.get("regex") or gate.get("line_regex") or gate.get("any") or gate.get("all")) - - -def has_any_matcher(gate: dict) -> bool: - return bool( - gate.get("contains") - or gate.get("regex") - or gate.get("line_regex") - or gate.get("any") - or gate.get("all") - or gate.get("not") - ) - - -def load_manifest_dir(path: Path, engine_version: int) -> dict[str, tuple[Path, dict]]: - if not path.is_dir(): - raise CheckError(f"{path}: manifest directory is missing") - manifests: dict[str, tuple[Path, dict]] = {} - for manifest_path in sorted(path.glob("*.toml")): - if manifest_path.name == "index.toml": - continue - manifest = validate_manifest(manifest_path, engine_version) - agent_id = manifest["id"] - if agent_id in manifests: - raise CheckError( - f"{manifest_path}: duplicate manifest id {agent_id!r}; already seen in {manifests[agent_id][0]}" - ) - manifests[agent_id] = (manifest_path, manifest) - if not manifests: - raise CheckError(f"{path}: no manifests found") - return manifests - - -def validate_catalog( - published_dir: Path, - bundled: dict[str, tuple[Path, dict]], - engine_version: int, - *, - allow_unpublished: bool = False, -) -> None: - catalog_path = published_dir / "index.toml" - catalog = load_toml(catalog_path) - if set(catalog) != {"schema_version", "agents"}: - raise CheckError(f"{catalog_path}: expected only schema_version and agents") - if catalog.get("schema_version") != 1: - raise CheckError(f"{catalog_path}: schema_version must be 1") - agents = catalog.get("agents") - if not isinstance(agents, list): - raise CheckError(f"{catalog_path}: agents must be an array") - - seen: dict[str, str] = {} - for entry in agents: - if not isinstance(entry, dict) or set(entry) != {"id", "path"}: - raise CheckError(f"{catalog_path}: each agent entry must contain id and path") - agent_id = entry["id"] - rel_path = entry["path"] - if not isinstance(agent_id, str) or not isinstance(rel_path, str): - raise CheckError(f"{catalog_path}: agent id and path must be strings") - if agent_id in seen: - raise CheckError(f"{catalog_path}: duplicate catalog agent {agent_id}") - if "://" in rel_path or rel_path.startswith("/") or ".." in Path(rel_path).parts: - raise CheckError(f"{catalog_path}: unsafe path for {agent_id}: {rel_path}") - if agent_id not in bundled: - raise CheckError(f"{catalog_path}: unknown agent {agent_id}; binary cannot identify it") - manifest_path = published_dir / rel_path - manifest = validate_manifest(manifest_path, engine_version) - if manifest["id"] != agent_id: - raise CheckError(f"{manifest_path}: id {manifest['id']} does not match catalog {agent_id}") - seen[agent_id] = rel_path - - bundled_path, bundled_manifest = bundled[agent_id] - cmp = compare_versions(manifest["version"], bundled_manifest["version"], manifest_path) - staged_manifest = STAGED_PUBLISHED_MANIFESTS.get(agent_id) - published_digest = hashlib.sha256(manifest_path.read_bytes()).hexdigest() - stages_new_engine_manifest = ( - staged_manifest - == (bundled_manifest["version"], manifest["version"], published_digest) - and bundled_manifest["min_engine_version"] == engine_version - and manifest["min_engine_version"] < bundled_manifest["min_engine_version"] - ) - if cmp < 0 and not stages_new_engine_manifest: - raise CheckError( - f"{manifest_path}: published version {manifest['version']} is lower than bundled " - f"{bundled_manifest['version']} in {bundled_path}" - ) - if cmp == 0 and manifest_path.read_text(encoding="utf-8") != bundled_path.read_text(encoding="utf-8"): - raise CheckError( - f"{manifest_path}: same version as bundled {bundled_manifest['version']} but content differs" - ) - - missing = sorted(set(bundled) - set(seen)) - unexpected_missing = [] - for agent_id in missing: - bundled_path, bundled_manifest = bundled[agent_id] - staged = UNPUBLISHED_BUNDLED_MANIFESTS.get(agent_id) if allow_unpublished else None - digest = hashlib.sha256(bundled_path.read_bytes()).hexdigest() - if staged != (bundled_manifest["version"], digest): - unexpected_missing.append(agent_id) - if unexpected_missing: - raise CheckError( - f"{catalog_path}: missing bundled agent(s): {', '.join(unexpected_missing)}" - ) - - catalog_paths = set(seen.values()) | {"index.toml"} - extra = sorted(path.name for path in published_dir.glob("*.toml") if path.name not in catalog_paths) - if extra: - raise CheckError(f"{published_dir}: TOML file(s) not listed in catalog: {', '.join(extra)}") - - -def main() -> int: - args = parse_args() - try: - engine_version = read_engine_version(args.engine_version) - bundled = load_manifest_dir(args.bundled_dir, engine_version) - if args.require_published or args.require_all_published or args.published_dir.exists(): - if not args.published_dir.is_dir(): - raise CheckError(f"{args.published_dir}: published manifest directory is missing") - validate_catalog( - args.published_dir, - bundled, - engine_version, - allow_unpublished=not args.require_all_published, - ) - except CheckError as exc: - print(f"error: {exc}", file=sys.stderr) - return 1 - print("agent detection manifests ok") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/agent_registry_opencode_e2e.ts b/scripts/agent_registry_opencode_e2e.ts new file mode 100644 index 0000000000..ce1f6cddfd --- /dev/null +++ b/scripts/agent_registry_opencode_e2e.ts @@ -0,0 +1,588 @@ +#!/usr/bin/env bun +/** Linux-only, real OpenCode / dynamic registry acceptance harness. + * Run only after compiling the candidate: bun run scripts/agent_registry_opencode_e2e.ts --binary target/debug/herdr + * Requires an existing Herdr session. Never launches/attaches/stops the default session. + * No reporters/plugins, report-agent calls, remote models, or global config writes. + */ +import assert from "node:assert/strict"; +import path from "node:path"; +import { appendFileSync } from "node:fs"; +import { access, chmod, cp, mkdir, mkdtemp, readFile, readdir, readlink, rename, writeFile } from "node:fs/promises"; +import { Database } from "bun:sqlite"; +import { createFixtureProvider, selfTestProvider } from "./agent_registry_opencode_provider"; + +const repo = path.resolve(import.meta.dir, ".."); +const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; +const unwrap = (value: any) => value.result ?? value; +const json = (text: string) => JSON.parse(text.trim()); +const clearedHerdr = ["HERDR_SOCKET_PATH", "HERDR_CLIENT_SOCKET_PATH", "HERDR_SESSION", "HERDR_WORKSPACE_ID", "HERDR_TAB_ID", "HERDR_PANE_ID"]; +type Check = { name: string; status: "pass" | "prerequisite" | "fail"; detail?: unknown }; +type Session = { name: string; running: boolean; socket_path: string; session_dir: string }; +type TrackedProcess = { pid: number; start: string; executable: string }; + +async function processStart(pid: number) { + const stat = await readFile(`/proc/${pid}/stat`, "utf8"); + return stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]; +} +async function signalTracked(process: TrackedProcess, signal: NodeJS.Signals) { + assert.equal(await processStart(process.pid), process.start, "refusing signal: PID was reused"); + assert.equal(await readlink(`/proc/${process.pid}/exe`), process.executable, "refusing signal: executable changed"); + globalThis.process.kill(process.pid, signal); +} + +/** Read only this run's isolated databases. Never inspect global OpenCode storage. */ +async function localSessionID(root: string, work: string): Promise { + for (const entry of await readdir(root, { withFileTypes: true })) { + const file = path.join(root, entry.name); + if (entry.isDirectory()) { + const id = await localSessionID(file, work); + if (id) return id; + } else if (entry.name.endsWith(".db") || entry.name.endsWith(".sqlite")) { + let db: Database | undefined; + try { + db = new Database(file, { readonly: true }); + const columns = db.query("PRAGMA table_info(session)").all() as { name: string }[]; + if (!["id", "directory"].every((name) => columns.some((column) => column.name === name))) continue; + const row = db.query("SELECT id FROM session WHERE directory = ? ORDER BY rowid DESC LIMIT 1").get(work) as { id?: string } | null; + if (row?.id?.startsWith("ses_")) return row.id; + } catch { /* Schema/version varies; lack of a reference is a prerequisite, not a fake pass. */ } + finally { db?.close(); } + } + } +} + +async function runHarness(binaryArg: string) { + assert.equal(process.platform, "linux", "this harness currently supports local Linux only"); + assert.equal(process.env.HERDR_ENV, "1", "requires an existing Herdr session (no CI PTY fallback yet)"); + assert(process.env.HERDR_PANE_ID, "caller pane ID is required for explicit outer-pane split"); + const binary = path.resolve(binaryArg); + await access(binary); + const opencode = Bun.which("opencode"); + const parentBinary = process.env.HERDR_BIN_PATH || Bun.which("herdr"); + assert(opencode && parentBinary, "installed opencode and parent Herdr CLI are required"); + // Keep UNIX socket paths below sockaddr_un's 108-byte limit, including named-session suffixes. + const root = await mkdtemp("/var/tmp/hr-oc-"); + const nonce = path.basename(root).split("-").at(-1)!.toLowerCase(); + const sessionName = `registry-oc-${Date.now().toString(36)}-${nonce}`; + assert(sessionName !== "default"); + const novel = `opencode-lab-${nonce}`; + const source = path.join(root, "registry-source"); + const badSource = path.join(root, "bad-source"); + const home = path.join(root, "home"); + const config = path.join(home, ".config/opencode"); + const work = path.join(root, "work"); + const commandsLog = path.join(root, "commands.jsonl"); + const delayedLaunchName = `opencode-delayed-${nonce}`; + const delayedLaunch = path.join(root, "bin", delayedLaunchName); + const checks: Check[] = []; + const provider = createFixtureProvider({ onEvent: (event) => appendFileSync(path.join(root, "provider.jsonl"), `${JSON.stringify(event)}\n`) }); + let outerPane: string | undefined; + let pane: string | undefined; + let sessionInfo: Session | undefined; + let frozen: TrackedProcess | undefined; + let sessionOwned = false; + let sessionMayExist = false; + let failure: unknown; + let cleaning = false; + let interrupted = false; + const interrupt = () => { interrupted = true; }; + process.on("SIGINT", interrupt); + process.on("SIGTERM", interrupt); + const check = (name: string, status: Check["status"], detail?: unknown) => { + checks.push({ name, status, detail }); + console.log(`${status.toUpperCase()}: ${name}`); + }; + console.log(`Artifacts: ${root}\nSession: ${sessionName}\nNovel agent: ${novel}`); + + // Clean allowlist: no auth, plugins, proxy credentials, experimental flags or parent selection. + const env: Record = { + PATH: [...new Set([path.join(root, "bin"), path.dirname(opencode), path.dirname(Bun.which("bun") || process.execPath), "/usr/local/bin", "/usr/bin", "/bin"])].join(":"), + HOME: home, SHELL: "/bin/bash", USER: process.env.USER || "fixture", LOGNAME: process.env.USER || "fixture", + TERM: process.env.TERM || "xterm-256color", COLORTERM: "truecolor", LANG: "C.UTF-8", + XDG_CONFIG_HOME: path.join(home, ".config"), XDG_DATA_HOME: path.join(root, "data"), + XDG_CACHE_HOME: path.join(root, "cache"), XDG_STATE_HOME: path.join(root, "state"), TMPDIR: path.join(root, "tmp"), + HERDR_ENV: "1", HERDR_SESSION: sessionName, HERDR_CONFIG_PATH: path.join(root, "herdr/config.toml"), + HERDR_AGENT_REGISTRY_SOURCE: source, + OPENCODE_TEST_HOME: home, OPENCODE_CONFIG_DIR: config, OPENCODE_CONFIG: path.join(config, "opencode.json"), + OPENCODE_TUI_CONFIG: path.join(config, "tui.json"), OPENCODE_TEST_MANAGED_CONFIG_DIR: path.join(root, "managed"), + OPENCODE_AUTH_CONTENT: "{}", OPENCODE_PURE: "1", OPENCODE_DISABLE_PROJECT_CONFIG: "1", + OPENCODE_DISABLE_DEFAULT_PLUGINS: "1", OPENCODE_DISABLE_EXTERNAL_SKILLS: "1", OPENCODE_DISABLE_CLAUDE_CODE: "1", + OPENCODE_DISABLE_MODELS_FETCH: "1", OPENCODE_DISABLE_AUTOUPDATE: "1", OPENCODE_DISABLE_AUTOCOMPACT: "1", + OPENCODE_DISABLE_LSP_DOWNLOAD: "1", OPENCODE_DISABLE_SHARE: "1", OPENCODE_DISABLE_FFF: "1", + // Best-effort deny background package/update traffic too; no model endpoint can leave loopback. + HTTP_PROXY: "http://127.0.0.1:9", HTTPS_PROXY: "http://127.0.0.1:9", ALL_PROXY: "http://127.0.0.1:9", + NO_PROXY: "127.0.0.1,localhost", no_proxy: "127.0.0.1,localhost", + }; + const controlEnv = { ...env }; + for (const key of clearedHerdr) delete controlEnv[key]; + controlEnv.HERDR_SESSION = sessionName; + + async function exec(executable: string, args: string[], environment = controlEnv, allowFailure = false) { + if (interrupted && !cleaning) throw new Error("interrupted; cleaning up owned resources"); + const started = Date.now(); + const child = Bun.spawn([executable, ...args], { cwd: repo, env: environment, stdin: "ignore", stdout: "pipe", stderr: "pipe" }); + let timedOut = false; + const timer = setTimeout(() => { timedOut = true; child.kill("SIGKILL"); }, 25000); + try { + const [stdout, stderr, code] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]); + appendFileSync(commandsLog, `${JSON.stringify({ at: new Date(started).toISOString(), ms: Date.now() - started, executable, args, code, timedOut, stdout, stderr })}\n`); + if (!allowFailure) assert(code === 0 && !timedOut, `${executable} ${args.join(" ")}: ${stderr || stdout}${timedOut ? " (timeout)" : ""}`); + return { stdout, stderr, code, timedOut }; + } finally { clearTimeout(timer); } + } + // Several successful mutation commands (notably pane run/send-text) deliberately print nothing. + const response = (stdout: string) => stdout.trim() ? unwrap(json(stdout)) : {}; + const cli = async (args: string[]) => response((await exec(binary, args)).stdout); + const parent = async (args: string[]) => response((await exec(parentBinary!, args, process.env as Record)).stdout); + async function rawApi(request: unknown) { + assert(sessionInfo?.socket_path, "named session socket must come from session list"); + const code = "import socket,sys; s=socket.socket(socket.AF_UNIX); s.connect(sys.argv[1]); s.sendall((sys.argv[2]+'\\n').encode()); print(s.makefile('r').readline(), end='')"; + return json((await exec("python3", ["-c", code, sessionInfo.socket_path, JSON.stringify(request)])).stdout); + } + const sessions = async (): Promise => json((await exec(binary, ["session", "list", "--json"])).stdout).sessions; + async function poll(name: string, fn: () => Promise, timeout = 20000): Promise { + const deadline = Date.now() + timeout; + let last: unknown; + while (Date.now() < deadline) { + if (interrupted && !cleaning) throw new Error("interrupted"); + try { const value = await fn(); if (value !== undefined && value !== false) return value as T; } + catch (error) { last = error; } + await Bun.sleep(150); + } + throw new Error(`timeout: ${name}${last ? `; last error: ${last}` : ""}`); + } + async function state(status: string) { + assert(pane); + return poll(`agent ${status}`, async () => { + const info = (await cli(["agent", "get", pane!])).agent; + return info.agent === novel && info.agent_status === status ? info : undefined; + }, 30000); + } + async function productReady(history = false) { + const ready = await poll("product interactive_ready", async () => { + const info = (await cli(["agent", "get", pane!])).agent; + return info.interactive_ready === true && !info.launch_pending ? info : undefined; + }, 60000); + const screen = (await exec(binary, ["pane", "read", pane!, "--source", "recent", "--format", "text"])).stdout; + assert(/^\s*╹▀{8,}\s*$/m.test(screen), "interactive_ready must correspond to the painted composer frame"); + assert(/commands/i.test(screen), "interactive_ready must correspond to a visible commands action"); + if (history) assert(screen.includes("Fixture complete."), "resumed interactive_ready must include restored conversation history"); + return { ready, screen }; + } + async function rejectPrematurePrompt(label: string) { + const startup = await poll("startup visible before interactive_ready", async () => { + const info = (await cli(["agent", "get", pane!])).agent; + if (info.interactive_ready || !info.launch_pending) return; + const screen = (await exec(binary, ["pane", "read", pane!, "--source", "recent", "--format", "text"])).stdout; + return screen.trim() ? { info, screen } : undefined; + }, 30000); + const marker = `HERDR_E2E_PREMATURE_${nonce}_${label}`; + const providerCallsBefore = provider.events.filter((event) => event.type === "request").length; + const rejected = await exec(binary, ["agent", "prompt", pane!, marker], controlEnv, true); + assert(rejected.code !== 0 && !rejected.timedOut, "premature prompt must be rejected synchronously"); + const payload = json(rejected.stderr || rejected.stdout); + assert.equal(payload.error?.code, "agent_not_ready"); + await Bun.sleep(350); + const after = (await exec(binary, ["pane", "read", pane!, "--source", "recent", "--format", "text"])).stdout; + assert(!after.includes(marker), "rejected premature prompt must not write to the PTY"); + assert.equal(provider.events.filter((event) => event.type === "request").length, providerCallsBefore, "rejected premature prompt must not call the provider"); + await writeFile(path.join(root, `${label}-premature-rejection.json`), JSON.stringify({ startup: startup.info, screen: startup.screen, response: payload, providerCallsBefore }, null, 2)); + check(`${label} rejects prompt before product readiness`, "pass"); + } + async function capture(label: string, recognized = true) { + assert(pane); + await writeFile(path.join(root, `${label}.pane.json`), JSON.stringify(await cli(["pane", "get", pane]), null, 2)); + await writeFile(path.join(root, `${label}.process.json`), JSON.stringify(await cli(["pane", "process-info", "--pane", pane]), null, 2)); + if (recognized) { + await writeFile(path.join(root, `${label}.agent.json`), JSON.stringify(await cli(["agent", "get", pane]), null, 2)); + const explain = await exec(binary, ["agent", "explain", pane, "--json"], controlEnv, true); + await writeFile(path.join(root, `${label}.explain.json`), explain.stdout || explain.stderr); + } + for (const format of ["text", "ansi"]) { + const result = await exec(binary, [recognized ? "agent" : "pane", "read", pane, "--source", recognized ? "detection" : "recent", "--format", format]); + await writeFile(path.join(root, `${label}.${format === "text" ? "txt" : "ansi"}`), result.stdout); + } + } + async function foreground(): Promise { + const info = (await cli(["pane", "process-info", "--pane", pane!])).process_info; + for (const item of info.foreground_processes ?? []) { + try { + const executable = await readlink(`/proc/${item.pid}/exe`); + if (executable === await readlinkResolved(opencode!)) return { pid: item.pid, executable, start: await processStart(item.pid) }; + } catch { /* A process may exit between API and /proc reads. */ } + } + } + async function readlinkResolved(file: string) { + // realpath supports both ELF files and symlink-based installations. + return (await import("node:fs/promises")).realpath(file); + } + async function namedLaunch(round: string) { + assert(outerPane); + sessionMayExist = true; + const launchEnv = { ...env }; + for (const key of clearedHerdr) delete launchEnv[key]; + const command = `env -i ${Object.entries(launchEnv).map(([key, value]) => `${key}=${quote(value)}`).join(" ")} ${quote(binary)} --session ${quote(sessionName)}; printf ${quote(`\\nHERDR_E2E_NAMED_EXIT_${nonce}_${round}\\n`)}`; + await parent(["pane", "run", outerPane, command]); + await poll("named session API", async () => { + const list = await sessions(); + const item = list.find((item) => item.name === sessionName && item.running); + if (!item) return; + sessionInfo = item; // IDs and socket/directory paths originate in command output. + const panes = (await cli(["pane", "list"])).panes; + if (!panes?.length) return; + sessionOwned = true; + pane = panes[0].pane_id; + return true; + }, 45000); + await writeFile(path.join(root, `session-${round}.json`), JSON.stringify(sessionInfo, null, 2)); + } + async function namedStop(round: string) { + assert(sessionOwned && sessionInfo?.name === sessionName && outerPane); + await exec(binary, ["session", "stop", sessionName, "--json"]); + await poll("named session stopped", async () => !(await sessions()).some((item) => item.name === sessionName && item.running)); + await exec(parentBinary!, ["pane", "wait-output", outerPane, "--match", `HERDR_E2E_NAMED_EXIT_${nonce}_${round}`, "--timeout", "20000"], process.env as Record); + } + async function exitOpenCode(label: string) { + assert(pane && await foreground(), "refusing exit input: target is not tracked OpenCode"); + await cli(["pane", "send-text", pane, "/exit"]); + await cli(["pane", "send-keys", pane, "Enter"]); + await poll("OpenCode exits", async () => !(await foreground())); + await poll("agent clears after exit", async () => !(await cli(["pane", "get", pane!])).pane.agent); + await capture(label, false); + } + async function turn(mode: "COMPLETE" | "PERMISSION" | "CANCEL", suffix: string) { + const marker = `HERDR_E2E_${mode}_${nonce}_${suffix}`; + await cli(["agent", "prompt", pane!, marker]); + await poll(`provider gate ${mode}`, async () => provider.events.some((event) => event.type === "gated" && event.marker === marker)); + await state("working"); + await capture(`${suffix}-working`); + if (mode === "CANCEL") { + await cli(["pane", "send-keys", pane!, "Escape"]); + await cli(["pane", "send-keys", pane!, "Escape"]); + await poll("HTTP stream aborted", async () => provider.events.some((event) => event.type === "aborted" && event.marker === marker)); + assert(!provider.events.some((event) => event.type === "finished" && event.marker === marker), "cancelled turn must not complete"); + } else { + await provider.release(marker); + if (mode === "PERMISSION") { + await state("blocked"); + await capture(`${suffix}-blocked`); + const screen = await readFile(path.join(root, `${suffix}-blocked.txt`), "utf8"); + assert(screen.includes("Permission required"), "must be real permission UI, not just a blocked state"); + await cli(["pane", "send-keys", pane!, "Escape"]); // Root permission Reject, never approve. + } + } + await state("idle"); + await capture(`${suffix}-idle`); + check(`real UI ${mode.toLowerCase()} → idle`, "pass"); + } + + try { + for (const dir of [config, work, path.dirname(delayedLaunch), env.XDG_DATA_HOME, env.XDG_CACHE_HOME, env.XDG_STATE_HOME, env.TMPDIR, env.OPENCODE_TEST_MANAGED_CONFIG_DIR, path.dirname(env.HERDR_CONFIG_PATH), path.join(source, "agents")]) await mkdir(dir, { recursive: true }); + await writeFile(delayedLaunch, `#!/bin/sh\nsleep 1.5\nexec ${quote(opencode)} "$@"\n`); + await chmod(delayedLaunch, 0o700); + await writeFile(env.HERDR_CONFIG_PATH, 'onboarding = false\n[terminal]\ndefault_shell = "/bin/bash"\nshell_mode = "non_login"\n[session]\nresume_agents_on_restore = true\n[experimental]\nallow_nested = true\n[update]\nversion_check = false\nmanifest_check = false\n'); + await writeFile(path.join(config, "tui.json"), JSON.stringify({ plugin: [] })); + await writeFile(path.join(config, "opencode.json"), JSON.stringify({ + model: "herdr-local/fixture", small_model: "herdr-local/fixture", enabled_providers: ["herdr-local"], + provider: { "herdr-local": { npm: "@ai-sdk/openai-compatible", name: "Herdr Local Fixture", env: [], + options: { baseURL: `${provider.url}/v1`, apiKey: "local-not-a-secret" }, + models: { fixture: { name: "Herdr Fixture", tool_call: true, reasoning: false, limit: { context: 32768, output: 1024 }, cost: { input: 0, output: 0 } } } } }, + permission: { "*": "deny", bash: "ask" }, agent: { title: { disable: true }, summary: { disable: true } }, + share: "disabled", autoupdate: false, snapshot: false, lsp: false, formatter: false, + }, null, 2)); + await writeFile(path.join(root, "launch-environment.json"), JSON.stringify(env, null, 2)); + const agents = path.join(repo, "vendor/agent-registry/agents"); + for (const entry of await readdir(agents)) if (entry !== "opencode") await cp(path.join(agents, entry), path.join(source, "agents", entry), { recursive: true }); + + // Installed/candidate help is the authority. These calls are noninteractive. + for (const [exe, args, environment] of [ + [binary, ["--version"], controlEnv], [binary, ["registry", "--help"], controlEnv], + [binary, ["pane", "--help"], controlEnv], [binary, ["agent", "--help"], controlEnv], + [binary, ["session", "--help"], controlEnv], [opencode, ["--version"], controlEnv], + [opencode, ["--help"], controlEnv], [parentBinary, ["pane", "split", "--help"], process.env], + ] as [string, string[], Record][]) await exec(exe, args, environment); + await exec(binary, ["registry", "validate", source]); + assert(!(await sessions()).some((item) => item.name === sessionName), "refusing to reuse existing session"); + const parentBefore = await parent(["pane", "get", process.env.HERDR_PANE_ID!]); + await writeFile(path.join(root, "parent-before.json"), JSON.stringify(parentBefore, null, 2)); + const split = await parent(["pane", "split", process.env.HERDR_PANE_ID!, "--direction", "down", "--ratio", "0.3", "--cwd", root, "--no-focus"]); + outerPane = split.pane?.pane_id; + assert(outerPane && outerPane !== process.env.HERDR_PANE_ID, "missing/unsafe outer pane ID"); + await namedLaunch("first"); + const initial = (await cli(["registry", "status"])).registry; + assert.equal(initial.source, source); + assert(!initial.agents.some((agent: any) => agent.id === "opencode" || agent.id === novel)); + await writeFile(path.join(root, "registry-initial.json"), JSON.stringify(initial, null, 2)); + const rootPane = (await cli(["pane", "get", pane!])).pane; + const rootProcess = (await cli(["pane", "process-info", "--pane", pane!])).process_info; + assert.equal(rootPane.agent, undefined); + assert(rootPane.cwd && rootProcess.shell_pid, "new disposable root must have a known cwd/shell"); + assert((rootProcess.foreground_processes ?? []).every((item: any) => item.pid === rootProcess.shell_pid), "disposable root is not at its shell"); + await writeFile(path.join(root, "disposable-root-before-launch.json"), JSON.stringify({ pane: rootPane, process: rootProcess }, null, 2)); + // Keep an intermediate noninteractive shell alive while SIGSTOP freezes its OpenCode child. + // Stopping a direct child of the interactive shell would otherwise return its job to the shell. + const ocCommand = `${quote(opencode)} --model herdr-local/fixture --agent build; printf ${quote(`\\nHERDR_E2E_OC_EXIT_${nonce}\\n`)}`; + await cli(["pane", "run", pane!, `cd ${quote(work)} && /bin/bash --noprofile --norc -c ${quote(ocCommand)}`]); + const trackedOpenCode = await poll("real OpenCode foreground", foreground, 30000); + await poll("OpenCode home screen", async () => { + const screen = (await exec(binary, ["pane", "read", pane!, "--source", "recent", "--format", "text"])).stdout; + return /Herdr Fixture|herdr-local|fixture/i.test(screen) ? true : undefined; + }, 60000); + assert(!(await cli(["pane", "get", pane!])).pane.agent, "OpenCode must initially be unrecognized"); + await capture("initial-unknown", false); + check("running OpenCode initially unknown", "pass"); + + // Stop the exact API-observed executable, not a name match or guessed PID. No input during reload. + frozen = trackedOpenCode; + await signalTracked(frozen, "SIGSTOP"); + let previous = ""; + let stable = 0; + const frozenScreen = await poll("drain pre-SIGSTOP screen", async () => { + const screen = (await exec(binary, ["pane", "read", pane!, "--source", "recent", "--format", "text"])).stdout; + stable = screen === previous ? stable + 1 : 0; + previous = screen; + return stable >= 3 ? { screen } : undefined; + }); + const novelDir = path.join(source, "agents", novel); + await mkdir(novelDir); + for (const name of ["agent.toml", "process.toml", "detection.toml", "resume.toml"]) { + let content = await readFile(path.join(agents, "opencode", name), "utf8"); + if (name === "agent.toml") { + content = content.replace(/^id = "opencode"$/m, `id = "${novel}"`).replace(/^name = "opencode"$/m, `name = "${novel}"`).replace(/^aliases = .*$/m, 'aliases = []'); + content = content.replace(/^unix = "opencode"$/m, `unix = "${delayedLaunchName}"`); + content = content.replace(/\n\[sound\][\s\S]*$/, "\n"); + } + if (name === "detection.toml") content = content.replace(/^id = "opencode"$/m, `id = "${novel}"`).replace(/^aliases = .*$/m, 'aliases = []'); + // process.toml retains actual installed opencode process names. No integration.toml/assets. + await writeFile(path.join(novelDir, name), content); + } + await exec(binary, ["registry", "validate", source]); + let activated = (await cli(["registry", "reload", source])).registry; + assert(activated.generation > initial.generation && activated.digest !== initial.digest); + const summary = activated.agents.find((agent: any) => agent.id === novel); + assert(summary?.process && summary?.detection && summary?.resume && !summary?.integration); + await poll("same frozen process recognized after reload", async () => (await cli(["pane", "get", pane!])).pane.agent === novel); + const afterScreen = (await exec(binary, ["pane", "read", pane!, "--source", "recent", "--format", "text"])).stdout; + assert.equal(afterScreen, frozenScreen.screen, "reload must not change the pane screen"); + await capture("reloaded-frozen"); + check("novel registry detects existing stopped OpenCode without input", "pass", { process: trackedOpenCode, screenUnchanged: true, byteCounterAvailable: false }); + await signalTracked(frozen, "SIGCONT"); + frozen = undefined; + await state("idle"); + await capture("recognized-idle"); + await writeFile(path.join(root, "recognized-idle.layout.json"), JSON.stringify(await cli(["pane", "layout"]), null, 2)); + const idleScreen = await readFile(path.join(root, "recognized-idle.txt"), "utf8"); + await cli(["pane", "send-keys", pane!, "Ctrl+p"]); + await poll("OpenCode command palette paints", async () => { + const screen = (await exec(binary, ["pane", "read", pane!, "--source", "recent", "--format", "text"])).stdout; + return screen !== idleScreen && /command/i.test(screen) ? true : undefined; + }); + await capture("command-palette"); + const paletteExplain = json((await exec(binary, ["agent", "explain", pane!, "--json"])).stdout); + assert.equal(paletteExplain.state, "unknown"); + assert.equal(paletteExplain.skip_state_update, true); + assert.equal(paletteExplain.matched_rule?.id, "command_palette"); + await cli(["pane", "send-keys", pane!, "Escape"]); + await poll("OpenCode command palette closes", async () => { + const screen = (await exec(binary, ["pane", "read", pane!, "--source", "recent", "--format", "text"])).stdout; + return !/select a command|search commands/i.test(screen) ? true : undefined; + }); + await capture("recognized-idle-after-command-palette"); + // Publication returned only after journal persistence. Copy the committed evidence now. + assert(sessionInfo); + const journalPath = path.join(sessionInfo.session_dir, "agent-registry/active.json"); + const journal = json(await readFile(journalPath, "utf8")); + assert.equal(journal.generation, activated.generation); + assert.equal(journal.digest, activated.digest); + await cp(journalPath, path.join(root, "committed-registry.json")); + check("published registry already persisted", "pass"); + + await mkdir(path.join(badSource, "agents", novel), { recursive: true }); + await writeFile(path.join(badSource, "agents", novel, "agent.toml"), "not valid TOML [[[\n"); + const bad = await exec(binary, ["registry", "reload", badSource], controlEnv, true); + assert(bad.code !== 0 && !bad.timedOut, "bad reload must be rejected, not time out"); + const retained = (await cli(["registry", "status"])).registry; + assert.equal(retained.generation, activated.generation); + assert.equal(retained.digest, activated.digest); + assert(retained.last_error, "bad reload should be visible in status"); + assert.equal(json(await readFile(journalPath, "utf8")).digest, activated.digest); + check("bad reload retains active generation/digest and journal", "pass"); + + const originalProcess = await foreground(); + assert(originalProcess); + const retiredPackage = path.join(root, "retired-package"); + await rename(novelDir, retiredPackage); + const retired = (await cli(["registry", "reload", source])).registry; + assert(!retired.agents.some((agent: any) => agent.id === novel)); + const denied = await exec(binary, ["agent", "start", "retired-start", "--kind", novel, "--pane", pane!], controlEnv, true); + assert(denied.code !== 0 && !denied.timedOut); + assert.equal(json(denied.stderr || denied.stdout).error?.code, "unsupported_agent_kind"); + await turn("COMPLETE", "retained-process"); + assert.deepEqual(await foreground(), originalProcess, "removal must retain the same live process"); + await rename(retiredPackage, novelDir); + const readded = (await cli(["registry", "reload", source])).registry; + assert.equal(readded.digest, activated.digest); + assert(readded.generation > retired.generation); + activated = readded; + check("removed package blocks new starts but retains live prompting and identity", "pass"); + + await turn("COMPLETE", "complete"); + await turn("PERMISSION", "permission"); + await turn("CANCEL", "cancel"); + await turn("COMPLETE", "after-cancel"); + await exitOpenCode("exited"); + check("real OpenCode exit clears identity", "pass"); + const nativeID = await localSessionID(env.XDG_DATA_HOME, work); + let beforeNativeRestart: TrackedProcess | undefined; + const resumeOptions = ["--model", "herdr-local/fixture", "--agent", "build"]; + if (nativeID) { + await writeFile(path.join(root, "native-session-reference.json"), JSON.stringify({ id: nativeID, source: "isolated OpenCode sqlite (read-only)" }, null, 2)); + // Legitimate internal launch path only; never forge a reporter/source or grant hook authority. + const started = await rawApi({ id: "e2e:native-explicit-start", method: "agent.start", params: { + name: "registry-native-resume", kind: novel, pane_id: pane!, args: ["--session", nativeID, ...resumeOptions], timeout_ms: 20000, + } }); + assert.equal(started.result?.type, "agent_started"); + assert.equal(started.result?.agent?.launch_pending, true); + assert.notEqual(started.result?.agent?.interactive_ready, true); + await rejectPrematurePrompt("native-explicit-resume"); + await productReady(true); + await state("idle"); + const info = await poll("legitimate native reference capture", async () => { + const info = (await cli(["agent", "get", pane!])).agent; + return info.agent_session?.value === nativeID && info.agent_session?.agent === novel ? info : undefined; + }); + assert.equal(info.agent_session.source, "herdr:launch", "native reference must come from internal launch capture, not a reporter"); + beforeNativeRestart = await poll("explicit native OpenCode process", foreground); + await capture("native-explicit-resume"); + check("explicit native resume launch/reference capture", "pass", { requestedSessionID: nativeID, observedSession: info.agent_session }); + // Leave this idle native session running: stopping Herdr now must persist its pinned recipe. + // Exiting OpenCode first would test only registry restart, not native restoration. + } else check("native resume/restore", "prerequisite", "No supported native ID in isolated OpenCode database; no synthetic session report submitted."); + + // Remove only our source via rename, retaining it as evidence; restart the same recorded session. + await namedStop("first"); + if (nativeID) { + const savedPath = path.join(sessionInfo!.session_dir, "session.json"); + const saved = json(await readFile(savedPath, "utf8")); + const references: any[] = []; + const collect = (value: any) => { + if (!value || typeof value !== "object") return; + if (value.agent_session?.agent === novel && value.agent_session?.value === nativeID) references.push(value.agent_session); + for (const child of Object.values(value)) collect(child); + }; + collect(saved); + assert(references.some((ref) => ref.source === "herdr:launch" && ref.recipe), "stopped session must persist legitimate native reference plus pinned recipe"); + for (const ref of references) assert.deepEqual(ref.resume_options, resumeOptions, "persist options only with the exact accepted native session"); + await cp(savedPath, path.join(root, "native-before-restart.session.json")); + check("native reference and pinned recipe persisted", "pass", references); + } + await rename(source, `${source}-removed`); + await namedLaunch("lkg"); + const restored = (await cli(["registry", "status"])).registry; + // App startup rebuilds detection overlays via replace_detection, advancing the generation. + // LKG guarantees package bytes, not an unchanged in-memory detection epoch across processes. + assert(restored.generation >= activated.generation, "restart must not regress the published generation"); + assert.equal(restored.digest, activated.digest); + const restoredJournal = json(await readFile(path.join(sessionInfo!.session_dir, "agent-registry/active.json"), "utf8")); + assert.deepEqual(restoredJournal.files, journal.files, "LKG must retain exact package bytes without the source"); + assert.equal(restoredJournal.generation, restored.generation); + assert(restored.agents.some((agent: any) => agent.id === novel)); + await writeFile(path.join(root, "registry-lkg.json"), JSON.stringify(restored, null, 2)); + check("restart with missing source reuses persisted LKG", "pass", { beforeGeneration: activated.generation, afterGeneration: restored.generation, packageBytesUnchanged: true, note: "Startup detection rebuild may advance generation" }); + if (nativeID) { + const resumedProcess = await poll("automatic native OpenCode restore", foreground, 60000); + assert(beforeNativeRestart && (resumedProcess.pid !== beforeNativeRestart.pid || resumedProcess.start !== beforeNativeRestart.start), "must observe a new OpenCode process after restart"); + const argv = (await readFile(`/proc/${resumedProcess.pid}/cmdline`, "utf8")).split("\0").filter(Boolean); + assert.deepEqual(argv.slice(1), ["--session", nativeID, ...resumeOptions], "automatic restore must preserve the pinned native session and allowed original CLI choices"); + await capture("native-restored-process-before-ui"); + await rejectPrematurePrompt("native-automatic-restore"); + await productReady(true); + await state("idle"); + const info = (await cli(["agent", "get", pane!])).agent; + assert.equal(info.agent_session?.value, nativeID); + assert.equal(info.agent_session?.agent, novel); + assert.equal(info.agent_session?.source, "herdr:launch"); + await capture("native-automatic-restore"); + check("automatic native restore from LKG with same session ID", "pass", { before: beforeNativeRestart, after: resumedProcess, argv, observedSession: info.agent_session }); + await turn("COMPLETE", "after-native-restore"); + await exitOpenCode("native-restored-exited"); + } + assert(!provider.events.some((event) => ["stream_error", "invalid_request", "unexpected_endpoint", "duplicate_turn"].includes(event.type)), "provider contract errors; inspect provider.jsonl"); + check("model requests handled only by loopback fixture", "pass", { requests: provider.events.filter((event) => event.type === "request").length, model: "herdr-local/fixture", externalModelClients: 0 }); + } catch (error) { + failure = error; + check("acceptance", "fail", String(error)); + if (outerPane) { + try { + const screen = await exec(parentBinary!, ["pane", "read", outerPane, "--source", "recent", "--format", "text"], process.env as Record, true); + await writeFile(path.join(root, "failure-outer.txt"), screen.stdout || screen.stderr); + } catch { /* Preserve original error. */ } + } + if (pane && sessionOwned) { + try { await capture("failure", false); } catch { /* Preserve original error. */ } + } + } finally { + cleaning = true; + const cleanupErrors: string[] = []; + if (frozen) { + try { await signalTracked(frozen, "SIGCONT"); } catch (error) { cleanupErrors.push(`resume owned OpenCode: ${error}`); } + } + // Stop only the unique name checked absent before launch. Re-query for partial startup failures. + let stopped = !sessionMayExist; + if (sessionMayExist) { + try { + const item = (await sessions()).find((item) => item.name === sessionName); + if (item) { + sessionInfo = item; + if (item.running) await exec(binary, ["session", "stop", sessionName, "--json"]); + await poll("cleanup session stopped", async () => !(await sessions()).some((entry) => entry.name === sessionName && entry.running)); + // Preserve logs before deleting just this session directory through the official CLI. + await cp(item.session_dir, path.join(root, "stopped-session"), { recursive: true }).catch((error) => cleanupErrors.push(`copy logs: ${error}`)); + await exec(binary, ["session", "delete", sessionName, "--json"]); + } + assert(!(await sessions()).some((item) => item.name === sessionName)); + stopped = true; + } catch (error) { cleanupErrors.push(`named session cleanup: ${error}`); } + } + if (outerPane && stopped) { + try { + // Wait until its actual foreground job has returned to the original shell before closing. + await poll("outer pane returned to shell", async () => { + const info = (await parent(["pane", "process-info", "--pane", outerPane!])).process_info; + return info.shell_pid && info.foreground_process_group_id === info.shell_pid && (info.foreground_processes ?? []).every((item: any) => item.pid === info.shell_pid) ? true : undefined; + }); + await parent(["pane", "close", outerPane]); + } catch (error) { cleanupErrors.push(`outer pane cleanup (left intact for inspection): ${error}`); } + } else if (outerPane) cleanupErrors.push(`outer pane ${outerPane} intentionally retained because named stop was not confirmed`); + await provider.stop(); + check("cleanup", cleanupErrors.length ? "fail" : "pass", cleanupErrors); + process.off("SIGINT", interrupt); + process.off("SIGTERM", interrupt); + const report = { at: new Date().toISOString(), binary, opencode, root, sessionName, novel, outerPane, pane, sessionInfo, checks, + limitations: ["Linux only; requires HERDR_ENV=1", "No raw PTY byte counter: SIGSTOP plus unchanged screen evidence", "Native launch/automatic restore run only when an isolated native session ID is available; otherwise explicitly flagged prerequisite", "Loopback model allowlist is not an OS network namespace"] }; + await writeFile(path.join(root, "report.json"), JSON.stringify(report, null, 2)); + await mkdir(path.join(repo, ".local/prd"), { recursive: true }); + appendFileSync(path.join(repo, ".local/prd/registry-dynamic-e2e.md"), `\n## Run ${report.at}\n\nArtifacts: \`${root}\`\nCandidate: \`${binary}\`\nSession: \`${sessionName}\`\n\n${checks.map((item) => `- **${item.status}** ${item.name}${item.detail ? ` — ${JSON.stringify(item.detail)}` : ""}`).join("\n")}\n`); + console.log(`Report: ${root}/report.json`); + if (failure || cleanupErrors.length) throw failure || new Error(cleanupErrors.join("\n")); + } +} + +async function main() { + const args = process.argv.slice(2); + if (args.length === 1 && args[0] === "--self-test") return selfTestProvider(); + if (args.length === 1 && args[0] === "--provider-only") { + const provider = createFixtureProvider({ onEvent: (event) => console.log(JSON.stringify(event)) }); + console.log(`Provider: ${provider.url}/v1; GET /control/status; POST /control/release {"marker":"..."}`); + await new Promise((resolve) => { + process.once("SIGINT", resolve); + process.once("SIGTERM", resolve); + }); + await provider.stop(); + return; + } + if (args.length === 2 && args[0] === "--binary") return runHarness(args[1]); + console.log("Usage: bun run scripts/agent_registry_opencode_e2e.ts --binary target/debug/herdr\n bun run scripts/agent_registry_opencode_e2e.ts --self-test | --provider-only\nRequires local Linux inside Herdr; compile candidate first. Leaves evidence under /var/tmp, cleans only owned runtime resources."); + if (args.length && !args.includes("--help")) process.exitCode = 2; +} +if (import.meta.main) main().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/scripts/agent_registry_opencode_provider.ts b/scripts/agent_registry_opencode_provider.ts new file mode 100644 index 0000000000..0bab8f23bc --- /dev/null +++ b/scripts/agent_registry_opencode_provider.ts @@ -0,0 +1,192 @@ +// Loopback-only deterministic model fixture. No upstream client, credentials, or forwarding. +import assert from "node:assert/strict"; + +export type Mode = "COMPLETE" | "PERMISSION" | "CANCEL"; +export type ProviderEvent = { at: string; type: string; marker?: string; [key: string]: unknown }; +type Gate = { marker: string; mode: Mode; release: () => void; aborted: boolean; released: boolean }; +const encoder = new TextEncoder(); +const markerPattern = /HERDR_E2E_(COMPLETE|PERMISSION|CANCEL)_[a-zA-Z0-9_-]+/g; + +function text(content: unknown): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) return content.map((part) => text(part?.text)).join("\n"); + return ""; +} + +/** Route by the most recent marked user turn, not request order (auxiliary calls may race). */ +function route(body: any): { marker: string; mode: Mode; toolResult: boolean } | undefined { + const messages: any[] = Array.isArray(body.messages) ? body.messages : []; + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role !== "user") continue; + const matches = [...text(messages[i].content).matchAll(markerPattern)]; + const match = matches.at(-1); + if (match) return { + marker: match[0], mode: match[1] as Mode, + toolResult: messages.slice(i + 1).some((m) => m.role === "tool"), + }; + } +} + +export function createFixtureProvider(options: { port?: number; onEvent?: (event: ProviderEvent) => void } = {}) { + const events: ProviderEvent[] = []; + const gates = new Map(); + const liveStreams = new Set<() => void>(); + const event = (type: string, details: Record = {}) => { + const item = { at: new Date().toISOString(), type, ...details }; + events.push(item); + options.onEvent?.(item); + }; + const server = Bun.serve({ + hostname: "127.0.0.1", port: options.port ?? 0, idleTimeout: 0, + async fetch(request) { + const url = new URL(request.url); + if (url.pathname === "/control/status" && request.method === "GET") { + return Response.json({ events, gates: [...gates.values()].map(({ marker, mode, aborted, released }) => ({ marker, mode, aborted, released })) }); + } + if (url.pathname === "/control/release" && request.method === "POST") { + const { marker } = await request.json() as { marker: string }; + const gate = gates.get(marker); + if (!gate || gate.aborted || gate.released) return Response.json({ error: "no pending gate" }, { status: 409 }); + gate.released = true; + event("released", { marker }); + gate.release(); + return Response.json({ released: marker }); + } + if (url.pathname !== "/v1/chat/completions" || request.method !== "POST") { + event("unexpected_endpoint", { path: url.pathname, method: request.method }); + return Response.json({ error: { message: "fixture only supports POST /v1/chat/completions" } }, { status: 404 }); + } + const body = await request.json() as any; + if (body.model !== "fixture" || body.stream !== true) { + event("invalid_request", { body }); + return Response.json({ error: { message: "fixture requires model=fixture, stream=true" } }, { status: 400 }); + } + const turn = route(body); + event("request", { marker: turn?.marker, toolResult: turn?.toolResult, body }); + if (turn && !turn.toolResult && gates.has(turn.marker)) { + event("duplicate_turn", { marker: turn.marker }); + return Response.json({ error: { message: "duplicate initial turn marker" } }, { status: 409 }); + } + // Abort and cancel both unblock the writer. No heartbeat/text is emitted behind the gate. + let closed = false; + let gate: Gate | undefined; + let unblock = () => {}; + const wait = new Promise((resolve) => { unblock = resolve; }); + const abort = () => { + if (closed) return; + if (gate) gate.aborted = true; + event("aborted", { marker: turn?.marker }); + closed = true; + unblock(); + liveStreams.delete(abort); + }; + request.signal.addEventListener("abort", abort, { once: true }); + if (turn && !turn.toolResult) { + gate = { marker: turn.marker, mode: turn.mode, release: unblock, aborted: false, released: false }; + gates.set(turn.marker, gate); + } + liveStreams.add(abort); + const stream = new ReadableStream({ + async start(controller) { + const send = (delta: object, finish_reason: string | null = null) => { + if (closed) return; + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ + id: "chatcmpl-herdr-fixture", object: "chat.completion.chunk", created: 1, model: "fixture", + choices: [{ index: 0, delta, finish_reason }], + ...(finish_reason ? { usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } } : {}), + })}\n\n`)); + }; + try { + send({ role: "assistant" }); + if (gate) { + send({ content: "Local fixture is processing.\n" }); + event("gated", { marker: gate.marker, mode: gate.mode }); + await wait; + } + if (closed) return; + if (turn?.mode === "PERMISSION" && !turn.toolResult) { + // Use only the advertised tool. Fail closed if the installed schema changes. + const tool = body.tools?.find((t: any) => t.type === "function" && t.function?.name === "bash"); + assert(tool, "installed OpenCode did not advertise bash"); + const args: Record = { command: "printf 'HERDR_PERMISSION_PROBE\\n'" }; + for (const key of tool.function.parameters?.required ?? []) { + if (key === "description") args.description = "Print a harmless local probe (reject this request)"; + else assert(key in args, `unsupported required bash argument: ${key}`); + } + send({ tool_calls: [{ index: 0, id: `call_${turn.marker}`, type: "function", function: { name: "bash", arguments: JSON.stringify(args) } }] }); + send({}, "tool_calls"); + event("tool_call", { marker: turn.marker, args }); + } else { + send({ content: turn?.toolResult ? "Permission rejected; fixture complete." : "Fixture complete." }); + send({}, "stop"); + } + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + closed = true; + controller.close(); + event("finished", { marker: turn?.marker, toolResult: turn?.toolResult }); + } catch (error) { + event("stream_error", { marker: turn?.marker, error: String(error) }); + closed = true; + controller.error(error); + } finally { + request.signal.removeEventListener("abort", abort); + liveStreams.delete(abort); + } + }, + cancel: abort, + }); + return new Response(stream, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" } }); + }, + }); + return { + url: `http://127.0.0.1:${server.port}`, events, + async release(marker: string) { + const response = await fetch(`http://127.0.0.1:${server.port}/control/release`, { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ marker }), + }); + assert(response.ok, `release ${marker}: ${await response.text()}`); + }, + async stop() { + for (const abort of liveStreams) abort(); + await server.stop(true); + }, + }; +} + +/** Provider-only contract check: no Herdr/OpenCode processes or external requests. */ +export async function selfTestProvider() { + const provider = createFixtureProvider(); + const request = (marker: string, more: object[] = [], signal?: AbortSignal) => fetch(`${provider.url}/v1/chat/completions`, { + method: "POST", headers: { "Content-Type": "application/json" }, signal, + body: JSON.stringify({ model: "fixture", stream: true, messages: [{ role: "user", content: marker }, ...more], + tools: [{ type: "function", function: { name: "bash", parameters: { required: ["command"] } } }] }), + }); + try { + // Independent concurrent markers prove that gates are not keyed by call count. + const complete = "HERDR_E2E_COMPLETE_selftest"; + const permission = "HERDR_E2E_PERMISSION_selftest"; + const a = await request(complete); + const b = await request(permission); + const readA = a.text(); + const readB = b.text(); + await provider.release(permission); + assert.match(await readB, /tool_calls/); + assert(!provider.events.some((e) => e.type === "finished" && e.marker === complete)); + await provider.release(complete); + assert.match(await readA, /\[DONE\]/); + const followup = await request(permission, [{ role: "assistant", content: null, tool_calls: [] }, { role: "tool", tool_call_id: `call_${permission}`, content: "Rejected" }]); + assert.match(await followup.text(), /Permission rejected/); + const controller = new AbortController(); + const cancel = "HERDR_E2E_CANCEL_selftest"; + const response = await request(cancel, [], controller.signal); + const reader = response.body!.getReader(); + await reader.read(); + controller.abort(); + await reader.cancel().catch(() => {}); + const deadline = Date.now() + 3000; + while (!provider.events.some((e) => e.type === "aborted" && e.marker === cancel) && Date.now() < deadline) await Bun.sleep(20); + assert(provider.events.some((e) => e.type === "aborted" && e.marker === cancel), "cancel closes stream"); + assert(!provider.events.some((e) => ["stream_error", "invalid_request", "duplicate_turn"].includes(e.type))); + console.log("PASS: provider concurrent gates, tool result, completion and abort (loopback only)"); + } finally { await provider.stop(); } +} diff --git a/scripts/agent_registry_vendor.py b/scripts/agent_registry_vendor.py new file mode 100644 index 0000000000..97ea555309 --- /dev/null +++ b/scripts/agent_registry_vendor.py @@ -0,0 +1,356 @@ +#!/usr/bin/env python3 +"""Explicit, offline agent-registry vendoring (never invoked by a build). + +Sync snapshots LOCALDIR/agents, pins the exact UTF-8 bytes, and writes the +checked-in Rust include index. The lock intentionally uses a content digest, +not a fabricated Git revision: uncommitted source trees are supported. Package +semantics are validated by the Rust registry CLI, not duplicated here. + +The aggregate SHA-256 covers sorted UTF-8 lines: ' \n'. +Only this maintenance command writes vendor files; --check never writes or +consults the source repository, Git, or the network. Python 3.11+ is required. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import stat +import subprocess +import sys +import tempfile + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +REPOSITORY = "https://github.com/herdrdev/agent-registry" +VENDOR_PATH = Path("vendor/agent-registry") +INDEX_PATH = Path("src/agents/bundled.rs") +MAX_SNAPSHOT_BYTES = 64 * 1024 * 1024 +MAX_FILES = 4096 +MAX_ENTRIES = 8192 +MAX_FILE_BYTES = 1024 * 1024 +MAX_TOTAL_BYTES = 32 * 1024 * 1024 +MAX_METADATA_BYTES = 4 * 1024 * 1024 +MAX_PATH_BYTES = 240 +MAX_DEPTH = 12 +COMPONENT_RE = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_.-]*\Z") +RESERVED = {"CON", "PRN", "AUX", "NUL"} | { + f"{prefix}{number}" for prefix in ("COM", "LPT") for number in range(1, 10) +} + + +class VendorError(ValueError): + pass + + +def validate_path(path: str) -> None: + """Use portable relative paths, also safe as Rust string literals.""" + parts = path.split("/") + if len(path.encode("utf-8")) > MAX_PATH_BYTES or len(parts) > MAX_DEPTH: + raise VendorError(f"path exceeds limits: {path!r}") + for part in parts: + if ( + not COMPONENT_RE.fullmatch(part) + or part.endswith(".") + or part.split(".")[0].upper() in RESERVED + ): + raise VendorError(f"unsafe path: {path!r}") + + +def reject_symlink_ancestors(path: Path) -> None: + for candidate in (path, *path.parents): + if candidate.is_symlink(): + raise VendorError(f"symlink is not allowed: {candidate}") + + +def read_regular(path: Path, limit: int | None = None) -> bytes: + if limit is None: + limit = MAX_FILE_BYTES + mode = path.lstat().st_mode + if not stat.S_ISREG(mode): + raise VendorError(f"not a regular file (symlinks are forbidden): {path}") + with path.open("rb") as stream: + content = stream.read(limit + 1) + if len(content) > limit: + raise VendorError(f"file exceeds byte limit: {path}") + try: + text = content.decode("utf-8") + except UnicodeDecodeError as exc: + raise VendorError(f"not UTF-8: {path}") from exc + if any(ord(char) < 32 and char not in "\t\n\r" for char in text) or "\x7f" in text: + raise VendorError(f"binary/control bytes are forbidden: {path}") + return content + + +def snapshot_agents(root: Path) -> dict[str, bytes]: + agents = root / "agents" + reject_symlink_ancestors(agents) + if not agents.is_dir(): + raise VendorError(f"missing agents directory: {agents}") + files: dict[str, bytes] = {} + seen: set[str] = set() + total_bytes = 0 + entries = 0 + + def walk(directory: Path) -> None: + nonlocal entries, total_bytes + # Iterate rather than materializing an unbounded directory listing. + with os.scandir(directory) as children: + for child in children: + entries += 1 + if entries > MAX_ENTRIES: + raise VendorError("tree exceeds entry count limit") + path = Path(child.path) + relative = path.relative_to(root).as_posix() + validate_path(relative) + folded = relative.casefold() + if folded in seen: + raise VendorError(f"casefold path collision: {relative}") + seen.add(folded) + if child.is_symlink(): + raise VendorError(f"symlink is not allowed: {path}") + if child.is_dir(follow_symlinks=False): + walk(path) + else: + if len(files) >= MAX_FILES: + raise VendorError("tree exceeds file count limit") + content = read_regular(path) + total_bytes += len(content) + if total_bytes > MAX_TOTAL_BYTES: + raise VendorError("tree exceeds total byte limit") + files[relative] = content + + walk(agents) + if not files: + raise VendorError("agents tree has no files") + return dict(sorted(files.items())) + + +def lock_bytes(files: dict[str, bytes]) -> bytes: + records = [ + {"path": path, "sha256": hashlib.sha256(files[path]).hexdigest()} + for path in sorted(files) + ] + aggregate = "".join(f"{item['sha256']} {item['path']}\n" for item in records) + lock = { + "schema": 1, + "repository": REPOSITORY, + "sha256": hashlib.sha256(aggregate.encode("utf-8")).hexdigest(), + "files": records, + } + return (json.dumps(lock, indent=2) + "\n").encode("utf-8") + + +def index_bytes(files: dict[str, bytes]) -> bytes: + lines = [ + "// Generated by scripts/agent_registry_vendor.py; do not edit.", + "#[rustfmt::skip]", + "pub(super) const FILES: &[(&str, &str)] = &[", + ] + for path in sorted(files): + lines.append( + f' ("{path}", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), ' + f'"/vendor/agent-registry/{path}"))),' + ) + lines.extend(["];", ""]) + return "\n".join(lines).encode("utf-8") + + +def check_paths(vendor: Path, index: Path) -> None: + reject_symlink_ancestors(vendor) + reject_symlink_ancestors(index) + if not vendor.is_dir(): + raise VendorError(f"missing vendored registry: {vendor}") + with os.scandir(vendor) as children: + for child in children: + if child.name not in {"agents", "lock.json"}: + raise VendorError(f"unexpected vendored entry: {child.name}") + files = snapshot_agents(vendor) + # Canonical byte equality checks the exact sorted file set, every file hash, + # aggregate digest, repository URL, schema, and rejects duplicate JSON keys. + if read_regular(vendor / "lock.json", MAX_METADATA_BYTES) != lock_bytes(files): + raise VendorError("lock.json does not match the exact vendored content") + if read_regular(index, MAX_METADATA_BYTES) != index_bytes(files): + raise VendorError("generated bundled.rs is out of date") + + +def check(project_root: Path = PROJECT_ROOT) -> None: + check_paths(project_root / VENDOR_PATH, project_root / INDEX_PATH) + + +def sync(source: Path, project_root: Path = PROJECT_ROOT) -> None: + # Validate and snapshot ALL input before touching the existing vendor/index. + files = snapshot_agents(source) + vendor = project_root / VENDOR_PATH + index = project_root / INDEX_PATH + reject_symlink_ancestors(vendor) + reject_symlink_ancestors(index) + if vendor.exists() and not vendor.is_dir(): + raise VendorError(f"vendor destination is not a directory: {vendor}") + if index.exists() and not index.is_file(): + raise VendorError(f"index destination is not a file: {index}") + vendor.parent.mkdir(parents=True, exist_ok=True) + index.parent.mkdir(parents=True, exist_ok=True) + # Same filesystem as the destination for rename; backups survive until both + # replacements succeed. An index replacement failure rolls the vendor back. + with tempfile.TemporaryDirectory(prefix=".agent-registry-", dir=vendor.parent) as tmp: + stage = Path(tmp) + staged_vendor = stage / "registry" + for path, content in files.items(): + target = staged_vendor / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(content) + (staged_vendor / "lock.json").write_bytes(lock_bytes(files)) + staged_index = stage / "bundled.rs" + staged_index.write_bytes(index_bytes(files)) + check_paths(staged_vendor, staged_index) + backup = stage / "previous" + had_vendor = vendor.exists() + if had_vendor: + os.replace(vendor, backup) + installed = False + try: + os.replace(staged_vendor, vendor) + installed = True + os.replace(staged_index, index) + except OSError: + if installed: + shutil.rmtree(vendor) + if had_vendor: + os.replace(backup, vendor) + raise + + +def _unique_object(pairs: list[tuple[str, object]]) -> dict: + result = {} + for key, value in pairs: + if key in result: + raise VendorError(f"duplicate JSON field: {key}") + result[key] = value + return result + + +def snapshot_files(raw: bytes) -> dict[str, bytes]: + """Integrity/framing only; the supplied Herdr binary owns semantics.""" + if not 0 < len(raw) <= MAX_SNAPSHOT_BYTES: + raise VendorError("snapshot JSON byte limit") + try: + value = json.loads(raw.decode("utf-8"), object_pairs_hook=_unique_object) + if set(value) != {"schema", "compatibility", "source", "content_sha256", "files"} or type(value["schema"]) is not int or value["schema"] != 1: + raise VendorError("unsupported snapshot fields/schema") + compatibility = value["compatibility"] + if compatibility != {"registry_api": 1, "min_detection_engine": 3} or any(type(v) is not int for v in compatibility.values()): + raise VendorError("unsupported snapshot compatibility") + source = value["source"] + if set(source) != {"repository", "commit", "agents_tree", "dirty"} or source["repository"] != REPOSITORY or source["dirty"] is not False: + raise VendorError("snapshot must have clean committed source provenance") + for field in ("commit", "agents_tree"): + if not re.fullmatch(r"(?:[0-9a-f]{40}|[0-9a-f]{64})", source[field]): + raise VendorError(f"invalid source {field}") + records = value["files"] + if not isinstance(records, list) or not 0 < len(records) <= MAX_FILES: + raise VendorError("snapshot file count limit") + files = {} + previous = "" + total = 0 + for item in records: + if set(item) != {"path", "bytes", "sha256", "text"}: + raise VendorError("invalid file record fields") + path = item["path"] + validate_path(path) + if not path.startswith("agents/") or path <= previous: + raise VendorError("snapshot paths must be sorted and unique under agents/") + previous = path + content = item["text"].encode("utf-8") + total += len(content) + limit = 256 * 1024 if path.endswith(".toml") else MAX_FILE_BYTES + if len(content) > limit or total > MAX_TOTAL_BYTES: + raise VendorError("snapshot decoded byte limit") + if type(item["bytes"]) is not int or item["bytes"] != len(content) or item["sha256"] != hashlib.sha256(content).hexdigest(): + raise VendorError("snapshot file length/hash mismatch") + files[path] = content + if value["content_sha256"] != json.loads(lock_bytes(files))["sha256"]: + raise VendorError("snapshot inventory hash mismatch") + return files + except (UnicodeError, ValueError, TypeError, KeyError, AttributeError) as exc: + raise VendorError(f"invalid snapshot: {exc}") from exc + + +def sync_snapshot(snapshot: Path, digest: str, validator: Path, project_root: Path = PROJECT_ROOT) -> None: + """Offline immutable import, with exact-byte validation before any mutation. + + Keep the existing lock/index format and ownership. The lock's inventory + digest pins source bytes; reviewed remote digest/commit belong in release + review, not fabricated Git provenance for ordinary dirty local syncs. + """ + if not re.fullmatch(r"[0-9a-f]{64}", digest): + raise VendorError("expected a reviewed lowercase SHA-256") + if not validator.is_absolute(): + raise VendorError("validator must be an absolute executable path") + reject_symlink_ancestors(snapshot) + raw = read_regular(snapshot, MAX_SNAPSHOT_BYTES) + if hashlib.sha256(raw).hexdigest() != digest: + raise VendorError("snapshot SHA-256 mismatch") + files = snapshot_files(raw) + with tempfile.TemporaryDirectory(prefix="herdr-vendor-snapshot-") as tmp: + # Our OS-provided temp root can use a symlink alias, such as macOS /var. + stage = Path(tmp).resolve() + exact = stage / "snapshot.json" + exact.write_bytes(raw) + exact.chmod(0o400) + try: + subprocess.run([str(validator), "registry", "validate-snapshot", str(exact), "--runtime-compatible"], check=True, timeout=120) + except (subprocess.SubprocessError, OSError) as exc: + raise VendorError(f"Herdr snapshot validation failed: {exc}") from exc + if read_regular(exact, MAX_SNAPSHOT_BYTES) != raw: + raise VendorError("validator modified snapshot bytes") + source = stage / "source" + for path, content in files.items(): + target = source / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(content) + # Reuse existing portable-path/collision/control checks and rollback. + sync(source, project_root) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", nargs="?", choices=["sync", "sync-snapshot"]) + parser.add_argument("--source", type=Path, help="local source repository (sync only)") + parser.add_argument("--check", action="store_true", help="verify the checked-in snapshot offline") + parser.add_argument("--snapshot", type=Path, help="verified local immutable snapshot JSON") + parser.add_argument("--sha256", help="reviewed exact snapshot SHA-256") + parser.add_argument("--validator", type=Path, help="absolute matching Herdr executable") + args = parser.parse_args(argv) + immutable_args = (args.snapshot, args.sha256, args.validator) + if args.command == "sync-snapshot": + if args.check or args.source is not None or not all(immutable_args): + parser.error("sync-snapshot requires --snapshot FILE --sha256 DIGEST --validator /absolute/herdr") + elif any(immutable_args): + parser.error("snapshot arguments require sync-snapshot") + if args.command == "sync": + if args.check or args.source is None: + parser.error("sync requires --source LOCALDIR and cannot use --check") + elif args.command != "sync-snapshot" and (not args.check or args.source is not None): + parser.error("use sync --source LOCALDIR or --check") + try: + if args.command == "sync-snapshot": + sync_snapshot(args.snapshot, args.sha256, args.validator) + elif args.command == "sync": + sync(args.source) + else: + check() + except (OSError, VendorError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + print("agent registry snapshot ok") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/config_reference_check.py b/scripts/config_reference_check.py index c23b284b3c..e9f7faad0f 100644 --- a/scripts/config_reference_check.py +++ b/scripts/config_reference_check.py @@ -20,6 +20,7 @@ import json import re import sys +import tomllib from dataclasses import dataclass, field from pathlib import Path @@ -40,6 +41,8 @@ ) RENAME_ALL_RE = re.compile(r'rename_all\s*=\s*"([^"]+)"') RENAME_RE = re.compile(r'rename\s*=\s*"([^"]+)"') +SOUND_OVERRIDES_STRUCT = "AgentSoundOverrides" +SOUND_SETTING_ENUM = "AgentSoundSetting" @dataclass @@ -67,10 +70,84 @@ def apply_rename_all(name: str, style: str | None) -> str: raise ValueError(f"unsupported serde rename_all style: {style}") -def parse_model(paths: list[Path]) -> Model: +def parse_sound_profile_keys(text: str) -> list[str]: + """Read the optional [sound] config key from one agent.toml package.""" + package = tomllib.loads(text) + if "sound" not in package: + return [] + sound = package["sound"] + if not isinstance(sound, dict) or not isinstance(sound.get("key"), str): + raise ValueError("sound profile must have a string config key") + key = sound["key"] + if not key.strip(): + raise ValueError("sound profile config keys must not be empty") + return [key] + + +def augment_sound_override_fields(model: Model, sound_keys: list[str]) -> None: + """Expose registry-owned sound keys to the config reference walker.""" + if not sound_keys: + return + if SOUND_OVERRIDES_STRUCT not in model.structs: + raise KeyError( + f"struct {SOUND_OVERRIDES_STRUCT} not found in config model" + ) + + fields = model.structs[SOUND_OVERRIDES_STRUCT] + existing = {struct_field.name for struct_field in fields} + for key in sound_keys: + if key in existing: + raise ValueError( + f"sound profile config key duplicates a literal config field: {key}" + ) + fields.append(StructField(name=key, rust_type=SOUND_SETTING_ENUM, doc="")) + existing.add(key) + + +def agent_profile_paths(source: Path) -> list[Path]: + """Return one TOML fixture or the exact per-agent package paths.""" + if source.is_file(): + return [source] + if source.is_dir(): + paths = sorted(source.glob("*/agent.toml")) + if not paths: + raise ValueError(f"no agent.toml packages found in {source}") + return paths + raise FileNotFoundError(f"agent profile source does not exist: {source}") + + +def parse_sound_profile_source(source: Path) -> list[str]: + keys: list[str] = [] + seen: set[str] = set() + for path in agent_profile_paths(source): + for key in parse_sound_profile_keys(path.read_text(encoding="utf-8")): + if key in seen: + raise ValueError(f"duplicate sound profile config key: {key}") + seen.add(key) + keys.append(key) + return keys + + +def inferred_agents_directory(paths: list[Path]) -> Path | None: + if not paths: + return None + model_roots = {path.parent for path in paths} + if len(model_roots) != 1: + return None + src = next(iter(model_roots)).parent + candidate = src.parent / "vendor" / "agent-registry" / "agents" + return candidate if candidate.is_dir() or src.name == "src" else None + + +def parse_model(paths: list[Path], agent_catalog: Path | None = None) -> Model: model = Model() for path in paths: parse_file(path.read_text(encoding="utf-8"), model) + + profile_source = agent_catalog or inferred_agents_directory(paths) + if profile_source is not None: + sound_keys = parse_sound_profile_source(profile_source) + augment_sound_override_fields(model, sound_keys) return model @@ -262,8 +339,15 @@ def reference_entries(reference_path: Path) -> tuple[dict[str, dict], list[str]] return entries, errors -def check(model_root: Path, reference_path: Path) -> list[str]: - model = parse_model(sorted(model_root.glob("*.rs"))) +def check( + model_root: Path, + reference_path: Path, + agent_catalog: Path | None = None, +) -> list[str]: + model = parse_model( + sorted(model_root.glob("*.rs")), + agent_catalog=agent_catalog, + ) code_entries = {entry["key"]: entry for entry in collect_entries(model)} doc_entries, errors = reference_entries(reference_path) code_keys = set(code_entries) @@ -292,6 +376,16 @@ def parse_args(argv: list[str]) -> argparse.Namespace: ) parser.add_argument("--model-root", default=DEFAULT_MODEL_ROOT, type=Path) parser.add_argument("--reference", default=DEFAULT_REFERENCE, type=Path) + parser.add_argument( + "--agent-profiles", + "--agent-catalog", + dest="agent_catalog", + type=Path, + help=( + "Optional agents directory or single agent.toml fixture. By default, " + "vendor/agent-registry/agents is discovered from the model root." + ), + ) parser.add_argument( "--emit", action="store_true", @@ -304,11 +398,18 @@ def main(argv: list[str] | None = None) -> int: args = parse_args(sys.argv[1:] if argv is None else argv) if args.emit: - model = parse_model(sorted(args.model_root.glob("*.rs"))) + model = parse_model( + sorted(args.model_root.glob("*.rs")), + agent_catalog=args.agent_catalog, + ) print(json.dumps(collect_entries(model), indent=2)) return 0 - errors = check(args.model_root, args.reference) + errors = check( + args.model_root, + args.reference, + agent_catalog=args.agent_catalog, + ) if errors: print("error: config reference is out of sync with src/config", file=sys.stderr) for error in errors: diff --git a/scripts/fixtures/agent-registry-snapshot-v1.json b/scripts/fixtures/agent-registry-snapshot-v1.json new file mode 100644 index 0000000000..3d1da0515c --- /dev/null +++ b/scripts/fixtures/agent-registry-snapshot-v1.json @@ -0,0 +1 @@ +{"schema":1,"compatibility":{"registry_api":1,"min_detection_engine":3},"source":{"repository":"https://github.com/herdrdev/agent-registry","commit":"1111111111111111111111111111111111111111","agents_tree":"2222222222222222222222222222222222222222","dirty":false},"content_sha256":"e2a3fb23d8fd38af640288f3d2a3e585313ee8630131281a52ac227ccaa7e382","files":[{"path":"agents/example/agent.toml","bytes":127,"sha256":"1eb921cb71d9571ce4846e0eab79c163260c14ca13ddf6d577dd480e5c43b9bd","text":"schema = 1\nid = \"example\"\nname = \"Example café\"\naliases = []\nstartable = false\n\n[launch]\nunix = \"example\"\nwindows = \"example\"\n"}]} diff --git a/scripts/test_agent_detection_manifest_check.py b/scripts/test_agent_detection_manifest_check.py deleted file mode 100644 index fed0e06f7e..0000000000 --- a/scripts/test_agent_detection_manifest_check.py +++ /dev/null @@ -1,249 +0,0 @@ -import hashlib -import tempfile -import unittest -from pathlib import Path -from unittest.mock import patch - -from scripts import agent_detection_manifest_check as check - - -def manifest(agent_id: str, version: str, contains: str = "ready") -> str: - return f'''id = "{agent_id}" -version = "{version}" -min_engine_version = 1 -updated_at = "2026-06-10T00:00:00Z" - -[[rules]] -id = "idle" -state = "idle" -contains = ["{contains}"] -''' - - -def catalog(agent_id: str = "codex", path: str = "codex.toml") -> str: - return f'''schema_version = 1 - -[[agents]] -id = "{agent_id}" -path = "{path}" -''' - - -def staged_grok_dirs(root: Path) -> tuple[Path, Path]: - bundled = root / "bundled" - published = root / "published" - bundled.mkdir() - published.mkdir() - (bundled / "grok.toml").write_bytes( - (check.DEFAULT_BUNDLED_DIR / "grok.toml").read_bytes() - ) - (published / "grok.toml").write_bytes( - (check.DEFAULT_PUBLISHED_DIR / "grok.toml").read_bytes() - ) - (published / "index.toml").write_text(catalog("grok", "grok.toml")) - return bundled, published - - -UNPUBLISHED_TEST_MANIFEST = manifest("testagent", "2026.06.10.1") -UNPUBLISHED_TEST_EXCEPTION = { - "testagent": ( - "2026.06.10.1", - hashlib.sha256(UNPUBLISHED_TEST_MANIFEST.encode()).hexdigest(), - ), -} - - -def unpublished_manifest_dirs(root: Path) -> tuple[Path, Path]: - bundled = root / "bundled" - published = root / "published" - bundled.mkdir() - published.mkdir() - (bundled / "testagent.toml").write_text(UNPUBLISHED_TEST_MANIFEST, encoding="utf-8", newline="\n") - (published / "index.toml").write_text("schema_version = 1\nagents = []\n") - return bundled, published - - -class AgentDetectionManifestCheckTests(unittest.TestCase): - def test_validates_bundled_and_matching_published_catalog(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - bundled = root / "bundled" - website = root / "website" - bundled.mkdir() - website.mkdir() - content = manifest("codex", "2026.06.10.1") - (bundled / "codex.toml").write_text(content) - (website / "codex.toml").write_text(content) - (website / "index.toml").write_text(catalog()) - - bundled_manifests = check.load_manifest_dir(bundled, engine_version=1) - check.validate_catalog(website, bundled_manifests, engine_version=1) - - def test_rejects_published_version_lower_than_bundled(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - bundled = root / "bundled" - website = root / "website" - bundled.mkdir() - website.mkdir() - (bundled / "codex.toml").write_text(manifest("codex", "2026.06.10.2")) - (website / "codex.toml").write_text(manifest("codex", "2026.06.10.1")) - (website / "index.toml").write_text(catalog()) - - bundled_manifests = check.load_manifest_dir(bundled, engine_version=1) - with self.assertRaisesRegex(check.CheckError, "lower than bundled"): - check.validate_catalog(website, bundled_manifests, engine_version=1) - - def test_allows_explicitly_staged_published_manifest(self): - with tempfile.TemporaryDirectory() as tmp: - bundled, website = staged_grok_dirs(Path(tmp)) - - bundled_manifests = check.load_manifest_dir(bundled, engine_version=3) - check.validate_catalog(website, bundled_manifests, engine_version=3) - - def test_rejects_mutated_staged_published_manifest(self): - with tempfile.TemporaryDirectory() as tmp: - bundled, website = staged_grok_dirs(Path(tmp)) - with (website / "grok.toml").open("a") as manifest_file: - manifest_file.write("\n# unexpected mutation\n") - - bundled_manifests = check.load_manifest_dir(bundled, engine_version=3) - with self.assertRaisesRegex(check.CheckError, "lower than bundled"): - check.validate_catalog(website, bundled_manifests, engine_version=3) - - def test_rejects_unlisted_published_manifest_lag_for_new_engine(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - bundled = root / "bundled" - website = root / "website" - bundled.mkdir() - website.mkdir() - bundled_content = manifest("codex", "2026.06.10.2").replace( - "min_engine_version = 1", "min_engine_version = 2" - ) - (bundled / "codex.toml").write_text(bundled_content) - (website / "codex.toml").write_text(manifest("codex", "2026.06.10.1")) - (website / "index.toml").write_text(catalog()) - - bundled_manifests = check.load_manifest_dir(bundled, engine_version=2) - with self.assertRaisesRegex(check.CheckError, "lower than bundled"): - check.validate_catalog(website, bundled_manifests, engine_version=2) - - def test_rejects_same_version_content_drift(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - bundled = root / "bundled" - website = root / "website" - bundled.mkdir() - website.mkdir() - (bundled / "codex.toml").write_text(manifest("codex", "2026.06.10.1", "ready")) - (website / "codex.toml").write_text(manifest("codex", "2026.06.10.1", "changed")) - (website / "index.toml").write_text(catalog()) - - bundled_manifests = check.load_manifest_dir(bundled, engine_version=1) - with self.assertRaisesRegex(check.CheckError, "same version"): - check.validate_catalog(website, bundled_manifests, engine_version=1) - - @patch.dict(check.UNPUBLISHED_BUNDLED_MANIFESTS, UNPUBLISHED_TEST_EXCEPTION, clear=True) - def test_allows_exact_unpublished_bundled_manifest(self): - with tempfile.TemporaryDirectory() as tmp: - bundled, published = unpublished_manifest_dirs(Path(tmp)) - bundled_manifests = check.load_manifest_dir(bundled, engine_version=3) - check.validate_catalog( - published, - bundled_manifests, - engine_version=3, - allow_unpublished=True, - ) - - @patch.dict(check.UNPUBLISHED_BUNDLED_MANIFESTS, UNPUBLISHED_TEST_EXCEPTION, clear=True) - def test_release_gate_rejects_exact_unpublished_bundled_manifest(self): - with tempfile.TemporaryDirectory() as tmp: - bundled, published = unpublished_manifest_dirs(Path(tmp)) - bundled_manifests = check.load_manifest_dir(bundled, engine_version=3) - with self.assertRaisesRegex(check.CheckError, "missing bundled agent"): - check.validate_catalog(published, bundled_manifests, engine_version=3) - - @patch.dict(check.UNPUBLISHED_BUNDLED_MANIFESTS, UNPUBLISHED_TEST_EXCEPTION, clear=True) - def test_rejects_mutated_unpublished_bundled_manifest(self): - with tempfile.TemporaryDirectory() as tmp: - bundled, published = unpublished_manifest_dirs(Path(tmp)) - with (bundled / "testagent.toml").open("a") as manifest_file: - manifest_file.write("\n# unexpected mutation\n") - bundled_manifests = check.load_manifest_dir(bundled, engine_version=3) - with self.assertRaisesRegex(check.CheckError, "missing bundled agent"): - check.validate_catalog( - published, - bundled_manifests, - engine_version=3, - allow_unpublished=True, - ) - - def test_rejects_unknown_catalog_agent(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - bundled = root / "bundled" - website = root / "website" - bundled.mkdir() - website.mkdir() - (bundled / "codex.toml").write_text(manifest("codex", "2026.06.10.1")) - (website / "newagent.toml").write_text(manifest("newagent", "2026.06.10.1")) - (website / "index.toml").write_text(catalog("newagent", "newagent.toml")) - - bundled_manifests = check.load_manifest_dir(bundled, engine_version=1) - with self.assertRaisesRegex(check.CheckError, "unknown agent"): - check.validate_catalog(website, bundled_manifests, engine_version=1) - - def test_rejects_manifest_requiring_newer_engine(self): - with tempfile.TemporaryDirectory() as tmp: - bundled = Path(tmp) / "bundled" - bundled.mkdir() - (bundled / "codex.toml").write_text( - manifest("codex", "2026.06.10.1").replace( - "min_engine_version = 1", "min_engine_version = 2" - ) - ) - - with self.assertRaisesRegex(check.CheckError, "exceeds engine"): - check.load_manifest_dir(bundled, engine_version=1) - - def test_rejects_top_non_empty_lines_below_engine_three(self): - with tempfile.TemporaryDirectory() as tmp: - bundled = Path(tmp) / "bundled" - bundled.mkdir() - content = manifest("codex", "2026.06.10.1").replace( - 'contains = ["ready"]', - 'region = "top_non_empty_lines(1)"\ncontains = ["ready"]', - ) - (bundled / "codex.toml").write_text(content) - - with self.assertRaisesRegex(check.CheckError, "requires min_engine_version 3"): - check.load_manifest_dir(bundled, engine_version=3) - - def test_top_non_empty_lines_requires_canonical_positive_bounded_count(self): - base_rule = { - "id": "test", - "state": "working", - "contains": ["ready"], - } - name = "top_non_empty_lines" - for count in ("1", str(check.MAX_TOP_REGION_LINE_COUNT)): - rule = {**base_rule, "region": f"{name}({count})"} - check.validate_rule(Path("test.toml"), 0, rule, {"gates": 0, "matchers": 0}) - for count in ( - "0", - "01", - "+1", - str(check.MAX_TOP_REGION_LINE_COUNT + 1), - "9" * 40, - ): - rule = {**base_rule, "region": f"{name}({count})"} - with self.subTest(region=rule["region"]): - with self.assertRaisesRegex(check.CheckError, "invalid region"): - check.validate_rule( - Path("test.toml"), 0, rule, {"gates": 0, "matchers": 0} - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/test_agent_registry_vendor.py b/scripts/test_agent_registry_vendor.py new file mode 100644 index 0000000000..c589a92192 --- /dev/null +++ b/scripts/test_agent_registry_vendor.py @@ -0,0 +1,376 @@ +from __future__ import annotations + +import contextlib +import hashlib +import io +import json +import os +import subprocess +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +from scripts import agent_registry_vendor as vendor + + +class AgentRegistryVendorTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name).resolve() + self.source = self.root / "source" + self.project = self.root / "project" + self.project.mkdir() + self.put("agents/zeta/agent.toml", b'schema = 1\nid = "zeta"\n') + self.put("agents/alpha/assets/hook.sh", "#!/bin/sh\r\n# café\r\n".encode()) + self.put("agents/alpha/agent.toml", b'schema = 1\nid = "alpha"\n') + self.put("README.md", b"Not part of the bundled agents tree\n") + + def put(self, name: str, content: bytes) -> Path: + path = self.source / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + return path + + @property + def vendored(self) -> Path: + return self.project / vendor.VENDOR_PATH + + @property + def index(self) -> Path: + return self.project / vendor.INDEX_PATH + + def sync(self) -> None: + vendor.sync(self.source, self.project) + + def snapshot(self) -> dict[str, bytes]: + return { + path.relative_to(self.project).as_posix(): path.read_bytes() + for path in self.project.rglob("*") if path.is_file() + } + + def test_sync_is_deterministic_pins_exact_bytes_and_checks_offline(self) -> None: + self.sync() + before = self.snapshot() + self.sync() + self.assertEqual(before, self.snapshot()) + lock = json.loads((self.vendored / "lock.json").read_bytes()) + self.assertEqual(lock["repository"], "https://github.com/herdrdev/agent-registry") + self.assertNotIn("revision", lock) + self.assertNotIn("commit", lock) + paths = [item["path"] for item in lock["files"]] + self.assertEqual(paths, sorted(paths)) + self.assertEqual(len(paths), 3) + self.assertNotIn("README.md", paths) + for item in lock["files"]: + content = (self.source / item["path"]).read_bytes() + self.assertEqual((self.vendored / item["path"]).read_bytes(), content) + self.assertEqual(item["sha256"], hashlib.sha256(content).hexdigest()) + aggregate = "".join(f"{item['sha256']} {item['path']}\n" for item in lock["files"]) + self.assertEqual(lock["sha256"], hashlib.sha256(aggregate.encode()).hexdigest()) + index = self.index.read_text() + self.assertIn("pub(super) const FILES: &[(&str, &str)] = &[", index) + for path in paths: + self.assertIn( + f'("{path}", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), ' + f'"/vendor/agent-registry/{path}")))', index, + ) + self.source.rename(self.root / "source-unavailable") + vendor.check(self.project) + self.assertEqual(before, self.snapshot()) + + def test_sync_removes_stale_files(self) -> None: + self.sync() + (self.source / "agents/zeta/agent.toml").unlink() + self.sync() + self.assertFalse((self.vendored / "agents/zeta/agent.toml").exists()) + self.assertNotIn("zeta", self.index.read_text()) + vendor.check(self.project) + + def test_check_rejects_changed_missing_extra_and_renamed_files(self) -> None: + for mutation in ("changed", "missing", "extra", "renamed", "root-extra"): + with self.subTest(mutation=mutation): + self.sync() + path = self.vendored / "agents/alpha/agent.toml" + if mutation == "changed": + path.write_bytes(path.read_bytes() + b"# drift\n") + elif mutation == "missing": + path.unlink() + elif mutation == "extra": + (path.parent / "extra.txt").write_text("extra") + elif mutation == "renamed": + path.rename(path.with_name("other.toml")) + else: + (self.vendored / "extra.txt").write_text("extra") + before = self.snapshot() + with self.assertRaises(vendor.VendorError): + vendor.check(self.project) + self.assertEqual(before, self.snapshot()) + + def test_check_rejects_lock_and_generated_index_drift(self) -> None: + for field in ("sha256", "repository", "schema", "files", "index", "json"): + with self.subTest(field=field): + self.sync() + path = self.vendored / "lock.json" + lock = json.loads(path.read_bytes()) + if field == "index": + self.index.write_text("// stale index\n") + elif field == "json": + path.write_text("{broken") + else: + lock[field] = [] if field == "files" else "incorrect" + path.write_text(json.dumps(lock, indent=2) + "\n") + with self.assertRaises(vendor.VendorError): + vendor.check(self.project) + + def test_input_validation_failure_preserves_existing_snapshot(self) -> None: + self.sync() + before = self.snapshot() + for content in (b"\xff", b"\x00binary", b"bad\x01text"): + with self.subTest(content=content): + self.put("agents/alpha/assets/bad.bin", content) + with self.assertRaises(vendor.VendorError): + self.sync() + self.assertEqual(before, self.snapshot()) + + def test_empty_or_missing_source_preserves_snapshot(self) -> None: + self.sync() + before = self.snapshot() + for source in (self.root / "missing", self.root / "empty"): + if source.name == "empty": + (source / "agents").mkdir(parents=True) + with self.assertRaises(vendor.VendorError): + vendor.sync(source, self.project) + self.assertEqual(before, self.snapshot()) + + def test_limits_are_enforced_before_replacement(self) -> None: + self.sync() + before = self.snapshot() + for limit in ("MAX_FILES", "MAX_ENTRIES", "MAX_FILE_BYTES", "MAX_TOTAL_BYTES", "MAX_PATH_BYTES", "MAX_DEPTH"): + with self.subTest(limit=limit), patch.object(vendor, limit, 1): + with self.assertRaises(vendor.VendorError): + self.sync() + self.assertEqual(before, self.snapshot()) + + def test_rejects_unsafe_portable_paths(self) -> None: + for path in ( + "../escape", "/absolute", "agents//a", "agents/./a", "agents/a/../b", + "agents/a/back\\slash", 'agents/a/quote"', "agents/a/a:b", "agents/a/a.", + "agents/a/NUL.txt", "agents/CON/hook", "agents/a/com1.sh", "agents/a/LPT9", + "agents/a/a\nline", "agents/a/sp ace", "agents/a/é", "C:/agents/a", + ): + with self.subTest(path=path), self.assertRaises(vendor.VendorError): + vendor.validate_path(path) + vendor.validate_path("agents/a/assets/__init__.py") + + def test_rejects_casefold_file_and_directory_collisions(self) -> None: + if (self.source / "agents/ALPHA").exists(): + self.skipTest("filesystem cannot represent casefold collisions") + self.sync() + before = self.snapshot() + for name in ("agents/alpha/AGENT.toml", "agents/ALPHA/other.toml"): + with self.subTest(name=name): + path = self.put(name, b"collision") + with self.assertRaisesRegex(vendor.VendorError, "collision"): + self.sync() + self.assertEqual(before, self.snapshot()) + path.unlink() + if path.parent.name == "ALPHA": + path.parent.rmdir() + + def symlink(self, target: Path, link: Path) -> None: + try: + link.symlink_to(target, target_is_directory=target.is_dir()) + except (OSError, NotImplementedError) as exc: + self.skipTest(f"symlinks unavailable: {exc}") + + def test_source_symlinks_are_rejected(self) -> None: + self.sync() + before = self.snapshot() + for target in (self.source / "README.md", self.source / "agents/alpha"): + link = self.source / "agents/link" + self.symlink(target, link) + with self.assertRaisesRegex(vendor.VendorError, "symlink"): + self.sync() + self.assertEqual(before, self.snapshot()) + link.unlink() + link = self.root / "linked-source" + self.symlink(self.source, link) + with self.assertRaisesRegex(vendor.VendorError, "symlink"): + vendor.sync(link, self.project) + + def test_check_rejects_symlink_even_with_identical_contents(self) -> None: + self.sync() + path = self.vendored / "agents/alpha/agent.toml" + path.unlink() + self.symlink(self.source / "agents/alpha/agent.toml", path) + with self.assertRaisesRegex(vendor.VendorError, "symlink"): + vendor.check(self.project) + + @unittest.skipUnless(hasattr(os, "mkfifo"), "requires FIFO support") + def test_nonregular_files_rejected_without_opening_them(self) -> None: + os.mkfifo(self.source / "agents/pipe") + with self.assertRaisesRegex(vendor.VendorError, "regular file"): + self.sync() + + def test_index_replacement_failure_rolls_back_vendor(self) -> None: + self.sync() + before = self.snapshot() + self.put("agents/alpha/agent.toml", b"new contents") + replace = os.replace + + def fail_index(source: Path, destination: Path) -> None: + if destination == self.index: + raise OSError("simulated index replacement failure") + replace(source, destination) + + with patch.object(vendor.os, "replace", side_effect=fail_index): + with self.assertRaisesRegex(OSError, "simulated"): + self.sync() + self.assertEqual(before, self.snapshot()) + vendor.check(self.project) + + def test_cli_modes_are_explicit_and_exclusive(self) -> None: + for args in ([], ["sync"], ["--source", "."], ["sync", "--source", ".", "--check"], ["--check", "--source", "."]): + with self.subTest(args=args), contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit): + vendor.main(args) + with patch.object(vendor, "check") as check, contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(vendor.main(["--check"]), 0) + check.assert_called_once_with() + with patch.object(vendor, "sync") as sync, contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(vendor.main(["sync", "--source", "/local/source"]), 0) + sync.assert_called_once_with(Path("/local/source")) + + +class ImmutableSnapshotTests(AgentRegistryVendorTests): + def setUp(self) -> None: + super().setUp() + self.raw = (Path(__file__).parent / "fixtures/agent-registry-snapshot-v1.json").read_bytes() + self.snapshot_path = self.root / "snapshot.json" + self.snapshot_path.write_bytes(self.raw) + self.digest = hashlib.sha256(self.raw).hexdigest() + self.validator = self.root / "validator with spaces" + def validate(argv, *, check, timeout): + self.assertEqual(argv[:3], [str(self.validator), "registry", "validate-snapshot"]) + self.assertEqual(argv[4:], ["--runtime-compatible"]) + self.assertTrue(check) + self.assertEqual(timeout, 120) + self.assertEqual(Path(argv[3]).read_bytes(), self.snapshot_path.read_bytes()) + self.validation_patch = patch.object(vendor.subprocess, "run", side_effect=validate) + self.validation = self.validation_patch.start() + self.addCleanup(self.validation_patch.stop) + + def import_snapshot(self) -> None: + vendor.sync_snapshot(self.snapshot_path, self.digest, self.validator, self.project) + + def test_golden_exact_digest_and_offline_import(self) -> None: + self.assertEqual(len(self.raw), 638) + self.assertEqual(self.digest, "9ae1f4c37154a28f38bee9048e85e5c3d457b7838c6ae8baa3753f6dc02130ea") + self.import_snapshot() + vendor.check(self.project) + files = vendor.snapshot_files(self.raw) + self.assertEqual((self.vendored / "agents/example/agent.toml").read_bytes(), files["agents/example/agent.toml"]) + self.assertEqual(json.loads((self.vendored / "lock.json").read_bytes())["sha256"], json.loads(self.raw)["content_sha256"]) + + def test_import_canonicalizes_its_own_temporary_directory_alias(self) -> None: + temporary = self.root / "temporary" + temporary.mkdir() + alias = self.root / "temporary-alias" + self.symlink(temporary, alias) + with patch.object(vendor.tempfile, "tempdir", str(alias)): + self.import_snapshot() + vendor.check(self.project) + self.assertEqual(list(temporary.iterdir()), []) + self.assertTrue(alias.is_symlink()) + + def test_snapshot_invalid_contract_rejected_without_vendor_changes(self) -> None: + self.sync() + before = self.snapshot() + for mutation in ("dirty", "commit", "unknown", "schema-bool", "compat-bool", "length", "hash", "inventory", "traversal", "duplicate", "order", "file-limit"): + with self.subTest(mutation=mutation): + value = json.loads(self.raw) + if mutation == "dirty": value["source"]["dirty"] = True + elif mutation == "commit": value["source"]["commit"] = "not-a-commit" + elif mutation == "unknown": value["extra"] = 1 + elif mutation == "schema-bool": value["schema"] = True + elif mutation == "compat-bool": value["compatibility"]["registry_api"] = True + elif mutation == "length": value["files"][0]["bytes"] += 1 + elif mutation == "hash": value["files"][0]["sha256"] = "0" * 64 + elif mutation == "inventory": value["content_sha256"] = "0" * 64 + elif mutation == "traversal": value["files"][0]["path"] = "agents/../escape" + elif mutation == "order": value["files"] *= 2 + elif mutation == "file-limit": value["files"][0]["text"] = " " * (256 * 1024 + 1) + raw = json.dumps(value).encode() + if mutation == "duplicate": raw = raw.replace(b'"schema": 1', b'"schema": 1,"schema": 1') + self.snapshot_path.write_bytes(raw) + self.digest = hashlib.sha256(raw).hexdigest() + with self.assertRaises(vendor.VendorError): self.import_snapshot() + self.assertEqual(self.snapshot(), before) + + def test_reviewed_hash_validator_failure_and_mutation_preserve_vendor(self) -> None: + self.sync() + before = self.snapshot() + self.digest = "0" * 64 + with self.assertRaisesRegex(vendor.VendorError, "SHA-256 mismatch"): + self.import_snapshot() + self.digest = hashlib.sha256(self.raw).hexdigest() + def mutate(argv, **kwargs): + exact = Path(argv[3]) + exact.chmod(0o600) + exact.write_text("mutated") + for failure in (subprocess.CalledProcessError(1, str(self.validator)), mutate): + self.validation.side_effect = failure + with self.assertRaises(vendor.VendorError): self.import_snapshot() + self.assertEqual(self.snapshot(), before) + + def test_immutable_import_uses_existing_index_rollback(self) -> None: + self.sync() + before = self.snapshot() + replace = os.replace + def fail_index(source, destination): + if destination == self.index: raise OSError("simulated index failure") + return replace(source, destination) + with patch.object(vendor.os, "replace", side_effect=fail_index): + with self.assertRaisesRegex(OSError, "simulated"): + self.import_snapshot() + self.assertEqual(before, self.snapshot()) + + @unittest.skipUnless(os.environ.get("HERDR_VALIDATOR"), "set HERDR_VALIDATOR to a matching absolute Herdr executable") + def test_real_validator_import_and_semantic_rejection(self) -> None: + # Parent builds/provides the executable. No Cargo, Git, or network here; + # all vendor/index writes stay in this test's temporary project. + self.validation_patch.stop() + self.validator = Path(os.environ["HERDR_VALIDATOR"]) + self.import_snapshot() + vendor.check(self.project) + before = self.snapshot() + value = json.loads(self.raw) + item = value["files"][0] + item["text"] = item["text"].split("\n[launch]")[0] + "\n" + content = item["text"].encode("utf-8") + item["bytes"] = len(content) + item["sha256"] = hashlib.sha256(content).hexdigest() + value["content_sha256"] = json.loads(vendor.lock_bytes({item["path"]: content}))["sha256"] + raw = (json.dumps(value, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8") + self.snapshot_path.write_bytes(raw) + self.digest = hashlib.sha256(raw).hexdigest() + # Framing and integrity still pass. Actual Herdr must reject the absent + # launch table even though startable=false, without replacing LKG. + vendor.snapshot_files(raw) + with self.assertRaisesRegex(vendor.VendorError, "Herdr snapshot validation failed"): + self.import_snapshot() + self.assertEqual(self.snapshot(), before) + + def test_snapshot_cli_is_explicit(self) -> None: + with patch.object(vendor, "sync_snapshot") as sync, contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(vendor.main(["sync-snapshot", "--snapshot", str(self.snapshot_path), "--sha256", self.digest, "--validator", str(self.validator)]), 0) + sync.assert_called_once_with(self.snapshot_path, self.digest, self.validator) + for args in (["sync-snapshot"], ["--check", "--snapshot", "x"], ["sync", "--source", ".", "--validator", "/x"]): + with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): + vendor.main(args) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_config_reference_check.py b/scripts/test_config_reference_check.py index b5a804b12c..b08c832f1d 100644 --- a/scripts/test_config_reference_check.py +++ b/scripts/test_config_reference_check.py @@ -8,11 +8,14 @@ from scripts.config_reference_check import ( Model, StructField, + augment_sound_override_fields, check, collect_entries, collect_keys, parse_file, parse_model, + parse_sound_profile_keys, + parse_sound_profile_source, ) @@ -86,6 +89,22 @@ } """ +SOUND_MODEL = """ +pub struct Config { + pub sound: SoundConfig, +} +pub struct SoundConfig { + pub agents: AgentSoundOverrides, +} +pub struct AgentSoundOverrides { +} +pub enum AgentSoundSetting { + Default, + On, + Off, +} +""" + def sample_model() -> Model: model = Model() @@ -93,6 +112,136 @@ def sample_model() -> Model: return model +class SoundProfileSourceTests(unittest.TestCase): + def test_extracts_literal_keys_and_augments_sound_override_entries(self) -> None: + sound_keys = parse_sound_profile_keys( + 'schema = 1\nid = "claude"\n[sound]\nkey = "claude"\ndefault = "default"\n' + ) + parse_sound_profile_keys( + 'sound = { key = "github_copilot", default = "default" }\n' + ) + self.assertEqual(sound_keys, ["claude", "github_copilot"]) + + model = Model( + structs={ + "Config": [StructField("ui", "UiConfig", "")], + "UiConfig": [StructField("sound", "SoundConfig", "")], + "SoundConfig": [ + StructField("agents", "AgentSoundOverrides", "") + ], + "AgentSoundOverrides": [], + }, + enums={"AgentSoundSetting": ["default", "on", "off"]}, + ) + augment_sound_override_fields(model, sound_keys) + entries = {entry["key"]: entry for entry in collect_entries(model)} + + self.assertEqual( + entries["ui.sound.agents.claude"], + { + "key": "ui.sound.agents.claude", + "rust_type": "AgentSoundSetting", + "doc": "", + "values": ["default", "on", "off"], + }, + ) + self.assertIn("ui.sound.agents.github_copilot", entries) + + def test_accepts_toml_whitespace_and_literal_strings(self) -> None: + catalog = " [ sound ]\n key = 'pi' # comment\n default = 'off'\n" + self.assertEqual(parse_sound_profile_keys(catalog), ["pi"]) + + def test_ignores_comments_and_unrelated_keys(self) -> None: + catalog = ( + '# sound = { key = "comment" }\n' + 'example = \'sound = { key = "example" }\'\n' + '[other]\nkey = "unrelated"\n' + ) + self.assertEqual(parse_sound_profile_keys(catalog), []) + + def test_rejects_missing_or_nonstring_sound_key(self) -> None: + for catalog in ('[sound]\ndefault = "off"', '[sound]\nkey = 42', 'sound = "pi"'): + with self.subTest(catalog=catalog), self.assertRaisesRegex(ValueError, "string config key"): + parse_sound_profile_keys(catalog) + + def test_rejects_invalid_or_duplicate_toml_fields(self) -> None: + for catalog in ('[sound', '[sound]\nkey = "pi"\nkey = "other"'): + with self.subTest(catalog=catalog), self.assertRaises(ValueError): + parse_sound_profile_keys(catalog) + + def test_rejects_empty_sound_profile_keys(self) -> None: + for key in ("", " "): + with self.subTest(key=key), self.assertRaisesRegex(ValueError, "must not be empty"): + parse_sound_profile_keys(f'[sound]\nkey = "{key}"') + + def test_discovers_sorted_vendored_packages_ignoring_nonpackage_files(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + src = Path(tmp) / "src" + config_root = src / "config" + agents_root = Path(tmp) / "vendor" / "agent-registry" / "agents" + config_root.mkdir(parents=True) + (agents_root / "zeta").mkdir(parents=True) + (agents_root / "alpha").mkdir() + (agents_root / "tests").mkdir() + (config_root / "model.rs").write_text(SOUND_MODEL, encoding="utf-8") + (agents_root / "zeta" / "agent.toml").write_text( + '[sound]\nkey = "zeta"\n', + encoding="utf-8", + ) + (agents_root / "alpha" / "agent.toml").write_text( + '[sound]\nkey = "alpha"\n', + encoding="utf-8", + ) + (agents_root / "agent.toml").write_text( + '[sound]\nkey = "shared"\n', + encoding="utf-8", + ) + (agents_root / "tests" / "example.toml").write_text( + '[sound]\nkey = "test_only"\n', + encoding="utf-8", + ) + + model = parse_model(sorted(config_root.glob("*.rs"))) + sound_entries = [ + entry["key"] + for entry in collect_entries(model) + if entry["key"].startswith("sound.agents.") + ] + + self.assertEqual( + sound_entries, + ["sound.agents.alpha", "sound.agents.zeta"], + ) + + def test_single_profile_fixture_remains_a_supported_override(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + model_path = root / "model.rs" + fixture = root / "agent.toml" + model_path.write_text(SOUND_MODEL, encoding="utf-8") + fixture.write_text( + '[sound]\nkey = "fixture"\n', + encoding="utf-8", + ) + + model = parse_model([model_path], agent_catalog=fixture) + + self.assertIn("sound.agents.fixture", collect_keys(model)) + + def test_rejects_duplicate_keys_across_profile_files(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agents_root = Path(tmp) / "agents" + (agents_root / "alpha").mkdir(parents=True) + (agents_root / "zeta").mkdir() + for name in ["alpha", "zeta"]: + (agents_root / name / "agent.toml").write_text( + '[sound]\nkey = "duplicate"\n', + encoding="utf-8", + ) + + with self.assertRaisesRegex(ValueError, "duplicate.*duplicate"): + parse_sound_profile_source(agents_root) + + class CollectKeysTests(unittest.TestCase): def test_walks_nested_structs_into_dotted_keys(self) -> None: keys = collect_keys(sample_model()) diff --git a/scripts/test_hermes_integration_asset.py b/scripts/test_hermes_integration_asset.py index f2cf38bbd7..dc0dffcb05 100644 --- a/scripts/test_hermes_integration_asset.py +++ b/scripts/test_hermes_integration_asset.py @@ -4,7 +4,7 @@ from unittest import mock -ASSET = Path(__file__).parents[1] / "src/integration/assets/hermes/__init__.py" +ASSET = Path(__file__).parents[1] / "vendor/agent-registry/agents/hermes/assets/__init__.py" def load_asset(): @@ -12,7 +12,9 @@ def load_asset(): if spec is None or spec.loader is None: raise RuntimeError("could not load Hermes integration asset") module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) + # Imports must not add bytecode to the integrity-pinned snapshot. + with mock.patch("sys.dont_write_bytecode", True): + spec.loader.exec_module(module) return module @@ -25,6 +27,11 @@ def register_hook(self, name, callback): class HermesIntegrationAssetTests(unittest.TestCase): + def test_loading_does_not_write_to_the_snapshot(self): + before = sorted(ASSET.parent.rglob("*")) + load_asset() + self.assertEqual(sorted(ASSET.parent.rglob("*")), before) + def test_reports_only_root_session_identity(self): module = load_asset() calls = [] diff --git a/src/agent_resume.rs b/src/agent_resume.rs index 075c396fe6..29e572c519 100644 --- a/src/agent_resume.rs +++ b/src/agent_resume.rs @@ -2,6 +2,8 @@ use std::path::Path; use serde::{Deserialize, Serialize}; +use crate::agents::session::{ReportReferencePreference, SessionProfile}; + const MAX_SESSION_ID_LEN: usize = 512; const MAX_SESSION_PATH_LEN: usize = 4096; @@ -22,7 +24,10 @@ pub enum AgentSessionRefKind { pub struct AgentResumePlan { pub agent: String, pub argv: Vec, + pub resume_options: Vec, pub dedupe_key: String, + /// Pinned from the same immutable registry snapshot as `argv`. + pub strict_input_readiness: bool, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -32,6 +37,179 @@ pub struct PersistedAgentSession { pub session_ref: AgentSessionRef, } +/// Instructions captured from a single registry generation, never inferred from an executable. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PinnedAgentResumeRecipe { + pub agent: String, + pub executable: String, + pub strategy: String, + pub token: String, + pub accepted_references: Vec, + pub preferred_reference: AgentSessionRefKind, +} + +/// Live capability is distinct from saved conversation metadata. `recipe: None` +/// means this acquired process has no resume capability, not "look it up later". +#[derive(Debug, Clone)] +pub(crate) struct LiveAgentResumeBinding { + pub agent: crate::detect::Agent, + pub recipe: Option, + pub process: Option<(u32, crate::platform::ForegroundProcess)>, + pub process_identity: Option, + pub observed_at: std::time::Instant, + pub managed_admission: bool, + pub resume_options_owner: Option, + pub report_proof: Option<(crate::platform::ProcessIdentity, AgentSessionRef, bool)>, + pub resume_options: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ProcessResumeOptions { + pub argv_owner: crate::platform::ProcessIdentity, + pub options: Vec, +} + +impl PinnedAgentResumeRecipe { + pub(crate) fn unavailable(agent: &str) -> Self { + Self { + agent: agent.into(), + executable: String::new(), + strategy: "unavailable".into(), + token: String::new(), + accepted_references: Vec::new(), + preferred_reference: AgentSessionRefKind::Id, + } + } + + pub(crate) fn select_report_reference( + &self, + id: Option, + path: Option, + ) -> Option { + let id = id + .and_then(AgentSessionRef::id) + .filter(|_| self.accepted_references.contains(&AgentSessionRefKind::Id)); + let path = path.and_then(AgentSessionRef::path).filter(|_| { + self.accepted_references + .contains(&AgentSessionRefKind::Path) + }); + match self.preferred_reference { + AgentSessionRefKind::Id => id, + AgentSessionRefKind::Path => path.or(id), + } + } + + pub(crate) fn capture(profile: &crate::agents::AgentProfile) -> Option { + use crate::agents::source::ResumeStrategy; + let session = profile.session()?; + Some(Self { + agent: profile.canonical_id().into(), + executable: profile.launch().executable().into(), + strategy: match session.strategy { + ResumeStrategy::SeparateFlag => "separate_flag", + ResumeStrategy::JoinedFlag => "joined_flag", + ResumeStrategy::Subcommand => "subcommand", + } + .into(), + token: session.token.clone(), + accepted_references: [AgentSessionRefKind::Id, AgentSessionRefKind::Path] + .into_iter() + .filter(|kind| session_profile_accepts_kind(session, *kind)) + .collect(), + preferred_reference: match session.report_preference() { + ReportReferencePreference::IdOnly => AgentSessionRefKind::Id, + ReportReferencePreference::AbsolutePathThenId => AgentSessionRefKind::Path, + }, + }) + } +} + +pub(crate) fn recipe_for_report(source: &str, agent: &str) -> Option { + let registry = crate::agents::registry(); + let (profile, _) = registry.session_profile_for_exact_report_pair(source, agent)?; + PinnedAgentResumeRecipe::capture(profile) +} + +/// Metadata is retained even when its package disappears. Only planning grants execution. +pub(crate) fn retained_snapshot_session( + source: &str, + agent: &str, + kind: AgentSessionRefKind, + value: &str, +) -> Option { + crate::detect::Agent::parse(agent).ok()?; + if source != "herdr:launch" && !crate::agents::bundled_report_pair(source, agent) { + return None; + } + Some(PersistedAgentSession { + source: source.into(), + agent: agent.into(), + session_ref: match kind { + AgentSessionRefKind::Id => AgentSessionRef::id(value)?, + AgentSessionRefKind::Path => AgentSessionRef::path(value)?, + }, + }) +} + +pub(crate) fn pinned_plan( + registry: &crate::agents::RegistrySnapshot, + session: &PersistedAgentSession, + pinned: Option<&PinnedAgentResumeRecipe>, +) -> Result { + let profile = registry + .profile_by_id(&session.agent) + .ok_or("agent package is missing")?; + if !profile.is_startable() { + return Err("agent package is not startable"); + } + let active = PinnedAgentResumeRecipe::capture(profile).ok_or("agent resume is unavailable")?; + let baseline; + let expected = match pinned { + Some(pinned) => pinned, + None => { + baseline = crate::agents::bundled_profile(&session.agent) + .and_then(PinnedAgentResumeRecipe::capture) + .ok_or("unpinned session is not a bundled agent")?; + &baseline + } + }; + if &active != expected { + return Err("agent resume recipe changed; explicit launch required"); + } + if !active + .accepted_references + .contains(&session.session_ref.kind) + { + return Err("agent resume reference kind is unsupported"); + } + Ok(AgentResumePlan { + agent: session.agent.clone(), + argv: profile + .session() + .ok_or("agent resume is unavailable")? + .argv(&active.executable, &session.session_ref.value), + resume_options: Vec::new(), + dedupe_key: dedupe_key(&session.source, &session.agent, &session.session_ref), + strict_input_readiness: crate::detect::manifest::requires_screen_visible_idle( + registry, + profile.legacy_agent(), + ), + }) +} + +impl AgentResumePlan { + pub(crate) fn replay_argv(&self, registry: &crate::agents::AgentRegistry) -> Vec { + let mut argv = self.argv.clone(); + if let Some(session) = registry + .profile_by_id(&self.agent) + .and_then(|profile| profile.session()) + { + argv.extend(session.resume_options.filter(&self.resume_options)); + } + argv + } +} + impl AgentSessionRef { pub fn id(value: impl Into) -> Option { let value = value.into(); @@ -50,40 +228,101 @@ impl AgentSessionRef { } } +#[cfg(test)] pub fn session_ref_from_report( source: &str, agent: &str, agent_session_id: Option, - _agent_session_path: Option, + agent_session_path: Option, ) -> Option { - if !is_official_agent_source(source, agent) { - return None; - } + let registry = crate::agents::registry(); + let (_, session) = registry.session_profile_for_exact_report_pair(source, agent)?; - if agent == "pi" || agent == "omp" { - return _agent_session_path + match session.report_preference() { + ReportReferencePreference::IdOnly => agent_session_id.and_then(AgentSessionRef::id), + ReportReferencePreference::AbsolutePathThenId => agent_session_path .and_then(AgentSessionRef::path) - .or_else(|| agent_session_id.and_then(AgentSessionRef::id)); + .or_else(|| agent_session_id.and_then(AgentSessionRef::id)), } - - agent_session_id.and_then(AgentSessionRef::id) } +#[cfg(test)] pub fn persisted_session_from_launch_args( agent: crate::detect::Agent, args: &[String], ) -> Option { - let [command, session_id] = args else { + let registry = crate::agents::registry(); + persisted_session_from_profile_launch_args(registry.profile_by_id(agent.as_str())?, args) +} + +pub(crate) fn persisted_session_from_profile_launch_args( + profile: &crate::agents::AgentProfile, + args: &[String], +) -> Option { + use crate::agents::source::ResumeStrategy; + let session = profile.session()?; + if args.len() > crate::agents::session::MAX_RESUME_OPTION_ARGS + 2 { return None; - }; - if agent != crate::detect::Agent::Codex || command != "resume" || session_id.starts_with('-') { + } + let mut value = None; + let mut extras = Vec::new(); + let mut index = 0; + while let Some(arg) = args.get(index) { + let selected = match session.strategy { + ResumeStrategy::SeparateFlag | ResumeStrategy::Subcommand if arg == &session.token => { + Some((args.get(index + 1)?.as_str(), 2)) + } + ResumeStrategy::JoinedFlag => arg.strip_prefix(&session.token).map(|value| (value, 1)), + _ => None, + }; + if let Some((selected, count)) = selected { + if value.replace(selected).is_some() { + return None; + } + index += count; + } else { + let count = session.resume_options.argument_count(&args[index..]); + if count == 0 { + return None; + } + extras.extend_from_slice(&args[index..index + count]); + index += count; + } + } + if session.resume_options.filter(&extras) != extras { return None; } - + let value = value?; + if value.starts_with('-') { + return None; + } + let session_ref = if session.accepts_path() { + AgentSessionRef::path(value).or_else(|| { + session + .accepts_id() + .then(|| AgentSessionRef::id(value)) + .flatten() + })? + } else if session.accepts_id() { + AgentSessionRef::id(value)? + } else { + return None; + }; + // Preserve core builtin ownership semantics; packages cannot declare trusted report pairs. + let builtin_source = if profile.canonical_id() == "agy" { + "herdr:antigravity_cli".into() + } else { + format!("herdr:{}", profile.canonical_id()) + }; + let source = if crate::agents::bundled_report_pair(&builtin_source, profile.canonical_id()) { + builtin_source + } else { + "herdr:launch".into() + }; Some(PersistedAgentSession { - source: "herdr:codex".into(), - agent: "codex".into(), - session_ref: AgentSessionRef::id(session_id.clone())?, + source, + agent: profile.canonical_id().into(), + session_ref, }) } @@ -98,33 +337,31 @@ pub fn normalize_session_start_source(value: Option) -> Option { } pub fn is_reserved_native_state_source(source: &str, agent: &str) -> bool { - matches!( - (source, agent), - ("herdr:claude", "claude") - | ("herdr:codex", "codex") - | ("herdr:copilot", "copilot") - | ("herdr:devin", "devin") - | ("herdr:droid", "droid") - | ("herdr:qodercli", "qodercli") - | ("herdr:qwen", "qwen") - | ("herdr:cursor", "cursor") - | ("herdr:grok", "grok") - ) + crate::agents::registry().is_reserved_native_state_source(source, agent) } +fn session_profile_accepts_kind(session: &SessionProfile, kind: AgentSessionRefKind) -> bool { + match kind { + AgentSessionRefKind::Id => session.accepts_id(), + AgentSessionRefKind::Path => session.accepts_path(), + } +} + +#[cfg(test)] pub fn session_ref_from_snapshot( source: &str, agent: &str, kind: AgentSessionRefKind, value: &str, ) -> Option { - if !is_official_agent_source(source, agent) { + let registry = crate::agents::registry(); + let (_, session) = registry.session_profile_for_exact_report_pair(source, agent)?; + if !session_profile_accepts_kind(session, kind) { return None; } - let session_ref = match (agent, kind) { - ("pi" | "omp", AgentSessionRefKind::Path) => AgentSessionRef::path(value)?, - (_, AgentSessionRefKind::Id) => AgentSessionRef::id(value)?, - _ => return None, + let session_ref = match kind { + AgentSessionRefKind::Id => AgentSessionRef::id(value)?, + AgentSessionRefKind::Path => AgentSessionRef::path(value)?, }; Some(PersistedAgentSession { source: source.to_string(), @@ -133,105 +370,24 @@ pub fn session_ref_from_snapshot( }) } +#[cfg(test)] pub fn plan(source: &str, agent: &str, session_ref: &AgentSessionRef) -> Option { - if !is_official_agent_source(source, agent) { + let registry = crate::agents::registry(); + let (profile, session) = registry.session_profile_for_exact_report_pair(source, agent)?; + if !session_profile_accepts_kind(session, session_ref.kind) { return None; } - - let argv = match (source, agent, session_ref.kind) { - ("herdr:claude", "claude", AgentSessionRefKind::Id) => { - vec![ - "claude".into(), - "--resume".into(), - session_ref.value.clone(), - ] - } - ("herdr:codex", "codex", AgentSessionRefKind::Id) => { - vec!["codex".into(), "resume".into(), session_ref.value.clone()] - } - ("herdr:copilot", "copilot", AgentSessionRefKind::Id) => { - vec!["copilot".into(), format!("--resume={}", session_ref.value)] - } - ("herdr:devin", "devin", AgentSessionRefKind::Id) => { - vec!["devin".into(), "--resume".into(), session_ref.value.clone()] - } - ("herdr:droid", "droid", AgentSessionRefKind::Id) => { - vec!["droid".into(), "--resume".into(), session_ref.value.clone()] - } - ("herdr:kimi", "kimi", AgentSessionRefKind::Id) => { - vec!["kimi".into(), "--session".into(), session_ref.value.clone()] - } - ("herdr:mastracode", "mastracode", AgentSessionRefKind::Id) => { - vec![ - "mastracode".into(), - "--thread".into(), - session_ref.value.clone(), - ] - } - ("herdr:pi", "pi", AgentSessionRefKind::Path | AgentSessionRefKind::Id) => { - vec!["pi".into(), "--session".into(), session_ref.value.clone()] - } - ("herdr:omp", "omp", AgentSessionRefKind::Path | AgentSessionRefKind::Id) => { - // omp resume is `-r, --resume=` (ID prefix or path); it has no - // `--session` flag, unlike pi. - vec!["omp".into(), format!("--resume={}", session_ref.value)] - } - ("herdr:hermes", "hermes", AgentSessionRefKind::Id) => { - vec![ - "hermes".into(), - "--resume".into(), - session_ref.value.clone(), - ] - } - ("herdr:opencode", "opencode", AgentSessionRefKind::Id) => { - vec![ - "opencode".into(), - "--session".into(), - session_ref.value.clone(), - ] - } - ("herdr:qodercli", "qodercli", AgentSessionRefKind::Id) => { - vec![ - "qodercli".into(), - "--resume".into(), - session_ref.value.clone(), - ] - } - ("herdr:qwen", "qwen", AgentSessionRefKind::Id) => { - vec!["qwen".into(), "--resume".into(), session_ref.value.clone()] - } - ("herdr:kilo", "kilo", AgentSessionRefKind::Id) => { - vec!["kilo".into(), "--session".into(), session_ref.value.clone()] - } - ("herdr:cursor", "cursor", AgentSessionRefKind::Id) => { - vec![ - if cfg!(windows) { - "cursor-agent.cmd" - } else { - "cursor-agent" - } - .into(), - "--resume".into(), - session_ref.value.clone(), - ] - } - ("herdr:antigravity_cli", "agy", AgentSessionRefKind::Id) => { - vec![ - "agy".into(), - "--conversation".into(), - session_ref.value.clone(), - ] - } - ("herdr:grok", "grok", AgentSessionRefKind::Id) => { - vec!["grok".into(), "--resume".into(), session_ref.value.clone()] - } - _ => return None, - }; + let argv = session.argv(profile.launch().executable(), &session_ref.value); Some(AgentResumePlan { agent: agent.to_string(), argv, + resume_options: Vec::new(), dedupe_key: dedupe_key(source, agent, session_ref), + strict_input_readiness: crate::detect::manifest::requires_screen_visible_idle( + ®istry, + profile.legacy_agent(), + ), }) } @@ -243,26 +399,7 @@ pub fn dedupe_key(source: &str, agent: &str, session_ref: &AgentSessionRef) -> S } pub(crate) fn is_official_agent_source(source: &str, agent: &str) -> bool { - matches!( - (source, agent), - ("herdr:claude", "claude") - | ("herdr:codex", "codex") - | ("herdr:copilot", "copilot") - | ("herdr:devin", "devin") - | ("herdr:droid", "droid") - | ("herdr:kimi", "kimi") - | ("herdr:omp", "omp") - | ("herdr:mastracode", "mastracode") - | ("herdr:pi", "pi") - | ("herdr:hermes", "hermes") - | ("herdr:opencode", "opencode") - | ("herdr:qodercli", "qodercli") - | ("herdr:qwen", "qwen") - | ("herdr:kilo", "kilo") - | ("herdr:cursor", "cursor") - | ("herdr:antigravity_cli", "agy") - | ("herdr:grok", "grok") - ) + crate::agents::bundled_report_pair(source, agent) } fn valid_session_id(value: &str) -> bool { @@ -276,10 +413,189 @@ fn valid_session_path(value: &str) -> bool { && Path::new(value).is_absolute() } +#[cfg(test)] +pub(crate) fn test_registry( + id: &str, + executable: &str, + strategy: &str, + token: &str, +) -> std::sync::Arc { + crate::agents::store::snapshot_for_test(vec![ + (format!("agents/{id}/agent.toml"), format!("schema = 1\nid = '{id}'\nname = '{id}'\naliases = ['novel alias']\nstartable = true\n[launch]\nunix = '{executable}'\nwindows = '{executable}'\n")), + (format!("agents/{id}/resume.toml"), format!("accepted_references = ['id']\npreferred_reference = 'id'\nstrategy = '{strategy}'\ntoken = '{token}'\n")), + ], 17).unwrap() +} + +#[cfg(test)] +pub(crate) fn resume_options_test_registry( + generation: u64, + policy: &str, +) -> std::sync::Arc { + crate::agents::store::snapshot_for_test(vec![ + ("agents/novel-options/agent.toml".into(), "schema = 1\nid = 'novel-options'\nname = 'Novel'\naliases = []\nstartable = true\n[launch]\nunix = 'options-cli'\nwindows = 'options-cli'\n".into()), + ("agents/novel-options/process.toml".into(), "names = ['options-cli']\n".into()), + ("agents/novel-options/resume.toml".into(), format!("accepted_references = ['id']\npreferred_reference = 'id'\nstrategy = 'separate_flag'\ntoken = '--resume'\n[resume_options]\n{policy}\n")), + ], generation).unwrap() +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn explicit_resume_with_options_requires_one_session_and_only_declared_arguments() { + let registry = resume_options_test_registry(1, "flags=['--yolo']\noptions=['--model']"); + let profile = registry.profile_by_id("novel-options").unwrap(); + for args in [ + vec!["--model", "model name", "--resume", "native", "--yolo"], + vec!["--resume", "native", "--model=model name"], + ] { + let args = args.into_iter().map(str::to_owned).collect::>(); + assert_eq!( + persisted_session_from_profile_launch_args(profile, &args) + .unwrap() + .session_ref + .value, + "native" + ); + } + for args in [ + vec!["--resume", "native", "--resume", "other"], + vec!["--resume", "native", "prompt"], + vec!["--model", "--resume", "native"], + vec!["--resume", "native", "--unknown"], + vec!["--resume", "native", "--continue"], + vec!["--resume", "native", "--", "--yolo"], + ] { + let args = args.into_iter().map(str::to_owned).collect::>(); + assert!( + persisted_session_from_profile_launch_args(profile, &args).is_none(), + "{args:?}" + ); + } + } + + #[test] + fn queued_resume_revalidates_only_options_without_retargeting_admitted_recipe() { + let initial = resume_options_test_registry(1, "flags=['--yolo']\noptions=['--model']"); + let narrowed = resume_options_test_registry(2, "options=['--model']"); + let owner = PersistedAgentSession { + source: "herdr:launch".into(), + agent: "novel-options".into(), + session_ref: AgentSessionRef::id("native").unwrap(), + }; + let recipe = + PinnedAgentResumeRecipe::capture(initial.profile_by_id("novel-options").unwrap()) + .unwrap(); + assert_eq!( + Some(recipe.clone()), + PinnedAgentResumeRecipe::capture(narrowed.profile_by_id("novel-options").unwrap()) + ); + let mut plan = pinned_plan(&initial, &owner, Some(&recipe)).unwrap(); + plan.resume_options = vec![ + "--model".into(), + "model name".into(), + "--yolo".into(), + "--resume=other".into(), + ]; + let admitted = plan.clone(); + assert_eq!( + plan.replay_argv(&initial), + [ + "options-cli", + "--resume", + "native", + "--model", + "model name", + "--yolo" + ] + ); + assert_eq!( + plan.replay_argv(&narrowed), + ["options-cli", "--resume", "native", "--model", "model name"] + ); + assert_eq!( + plan.replay_argv(&crate::agents::AgentRegistry::default()), + plan.argv + ); + assert_eq!(plan, admitted); + } + + #[test] + fn arbitrary_ids_capture_only_closed_explicit_launch_recipes() { + for (strategy, token, args) in [ + ( + "separate_flag", + "--session", + vec!["--session".into(), "abc; data".into()], + ), + ( + "subcommand", + "continue", + vec!["continue".into(), "abc; data".into()], + ), + ( + "joined_flag", + "--thread=", + vec!["--thread=abc; data".into()], + ), + ] { + let registry = test_registry("novel-42", "shared-cli", strategy, token); + let profile = registry.profile_by_id("novel-42").unwrap(); + let captured = persisted_session_from_profile_launch_args(profile, &args).unwrap(); + assert_eq!(captured.source, "herdr:launch"); + assert_eq!(captured.agent, "novel-42"); + assert_eq!(captured.session_ref.value, "abc; data"); + let recipe = PinnedAgentResumeRecipe::capture(profile).unwrap(); + let plan = pinned_plan(®istry, &captured, Some(&recipe)).unwrap(); + assert_eq!(plan.argv[0], "shared-cli"); + assert_eq!(&plan.argv[1..], args); + assert!(pinned_plan(®istry, &captured, None).is_err()); + assert!(registry + .profile_for_exact_report_pair("herdr:launch", "novel-42") + .is_none()); + assert!(registry + .profile_for_exact_report_pair("herdr:novel-42", "novel-42") + .is_none()); + let mut extra = args.clone(); + extra.push("--last".into()); + assert!(persisted_session_from_profile_launch_args(profile, &extra).is_none()); + } + } + + #[test] + fn pinned_resume_rejects_recipe_changes_missing_packages_and_unpinned_downgrade() { + let original = test_registry("novel-42", "shared-cli", "separate_flag", "--session"); + let profile = original.profile_by_id("novel-42").unwrap(); + let session = + persisted_session_from_profile_launch_args(profile, &["--session".into(), "id".into()]) + .unwrap(); + let recipe = PinnedAgentResumeRecipe::capture(profile).unwrap(); + for changed in [ + test_registry("novel-42", "changed-cli", "separate_flag", "--session"), + test_registry("novel-42", "shared-cli", "separate_flag", "--resume"), + test_registry("novel-42", "shared-cli", "subcommand", "resume"), + test_registry("other-package", "shared-cli", "separate_flag", "--session"), + ] { + assert!(pinned_plan(&changed, &session, Some(&recipe)).is_err()); + } + let mut changed = recipe.clone(); + changed.accepted_references.push(AgentSessionRefKind::Path); + assert!(pinned_plan(&original, &session, Some(&changed)).is_err()); + assert_eq!( + retained_snapshot_session("herdr:launch", "novel-42", AgentSessionRefKind::Id, "id"), + Some(session) + ); + let builtin = PersistedAgentSession { + source: "herdr:codex".into(), + agent: "codex".into(), + session_ref: AgentSessionRef::id("id").unwrap(), + }; + assert!(pinned_plan(&crate::agents::registry(), &builtin, None).is_ok()); + let changed = test_registry("codex", "codex", "separate_flag", "--session"); + assert!(pinned_plan(&changed, &builtin, None).is_err()); + } + fn absolute_test_path(name: &str) -> String { std::env::current_dir() .unwrap() @@ -288,16 +604,57 @@ mod tests { .to_string() } + const OFFICIAL_SESSION_PAIRS: [(&str, &str); 17] = [ + ("herdr:pi", "pi"), + ("herdr:claude", "claude"), + ("herdr:codex", "codex"), + ("herdr:cursor", "cursor"), + ("herdr:devin", "devin"), + ("herdr:antigravity_cli", "agy"), + ("herdr:omp", "omp"), + ("herdr:mastracode", "mastracode"), + ("herdr:opencode", "opencode"), + ("herdr:copilot", "copilot"), + ("herdr:kimi", "kimi"), + ("herdr:droid", "droid"), + ("herdr:grok", "grok"), + ("herdr:hermes", "hermes"), + ("herdr:kilo", "kilo"), + ("herdr:qodercli", "qodercli"), + ("herdr:qwen", "qwen"), + ]; + #[test] - fn native_state_reservation_excludes_full_lifecycle_sources() { - assert!(is_reserved_native_state_source("herdr:claude", "claude")); - assert!(is_reserved_native_state_source("herdr:codex", "codex")); - assert!(is_reserved_native_state_source("herdr:devin", "devin")); - assert!(!is_reserved_native_state_source("herdr:kimi", "kimi")); - assert!(!is_reserved_native_state_source( - "herdr:opencode", - "opencode" - )); + fn official_source_identity_requires_every_exact_source_and_canonical_pair() { + for (index, (source, agent)) in OFFICIAL_SESSION_PAIRS.into_iter().enumerate() { + assert!(is_official_agent_source(source, agent), "{source} {agent}"); + assert!(!is_official_agent_source("custom:agent", agent)); + + let other_agent = OFFICIAL_SESSION_PAIRS[(index + 1) % OFFICIAL_SESSION_PAIRS.len()].1; + assert!(!is_official_agent_source(source, other_agent)); + } + + for (source, alias) in [ + ("herdr:claude", "claude-code"), + ("herdr:cursor", "cursor-agent"), + ("herdr:devin", "devin-cli"), + ("herdr:antigravity_cli", "antigravity"), + ("herdr:mastracode", "mastra-code"), + ("herdr:opencode", "open-code"), + ("herdr:copilot", "github-copilot"), + ("herdr:kimi", "kimi-code"), + ("herdr:grok", "grok-build"), + ("herdr:hermes", "hermes-agent"), + ("herdr:kilo", "kilo-code"), + ("herdr:qodercli", "qoder"), + ("herdr:qwen", "qwen-code"), + ] { + assert!(!is_official_agent_source(source, alias), "{source} {alias}"); + } + + for agent in ["gemini", "cline", "kiro", "amp", "maki"] { + assert!(!is_official_agent_source("herdr:custom", agent)); + } } #[test] @@ -418,6 +775,16 @@ mod tests { .argv, vec!["pi", "--session", pi_session.as_str()] ); + assert_eq!( + plan( + "herdr:pi", + "pi", + &AgentSessionRef::id("pi-session-id").unwrap() + ) + .unwrap() + .argv, + vec!["pi", "--session", "pi-session-id"] + ); assert_eq!( plan( "herdr:omp", @@ -428,6 +795,16 @@ mod tests { .argv, vec!["omp", format!("--resume={omp_session}").as_str()] ); + assert_eq!( + plan( + "herdr:omp", + "omp", + &AgentSessionRef::id("omp-session-id").unwrap() + ) + .unwrap() + .argv, + vec!["omp", "--resume=omp-session-id"] + ); assert_eq!( plan( "herdr:hermes", @@ -536,159 +913,110 @@ mod tests { } #[test] - fn report_ref_prefers_pi_and_omp_paths_and_validates_values() { - let pi_session = absolute_test_path("pi-session.jsonl"); - let omp_session = absolute_test_path("omp-session.jsonl"); - let claude_session = absolute_test_path("claude-session"); - let copilot_session = absolute_test_path("copilot-session"); - let session_ref = session_ref_from_report( - "herdr:pi", - "pi", - Some("pi-id".into()), - Some(pi_session.clone()), - ) - .unwrap(); - assert_eq!(session_ref.kind, AgentSessionRefKind::Path); - assert_eq!(session_ref.value, pi_session); - + fn report_reference_validation_rejects_malformed_values_and_custom_sources() { assert!(session_ref_from_report("herdr:pi", "pi", Some("bad\nid".into()), None).is_none()); assert!( session_ref_from_report("herdr:pi", "pi", None, Some("relative.jsonl".into())) .is_none() ); assert!(session_ref_from_report("custom:pi", "pi", Some("pi-id".into()), None).is_none()); + } - let session_ref = session_ref_from_report( - "herdr:omp", - "omp", - Some("omp-id".into()), - Some(omp_session.clone()), - ) - .unwrap(); - assert_eq!(session_ref.kind, AgentSessionRefKind::Path); - assert_eq!(session_ref.value, omp_session); - - let session_ref = - session_ref_from_report("herdr:omp", "omp", Some("omp-id".into()), None).unwrap(); - assert_eq!(session_ref.kind, AgentSessionRefKind::Id); - assert_eq!(session_ref.value, "omp-id"); - let session_ref = session_ref_from_report( - "herdr:omp", - "omp", - Some("omp-id".into()), - Some("relative.jsonl".into()), - ) - .unwrap(); - assert_eq!(session_ref.kind, AgentSessionRefKind::Id); - assert_eq!(session_ref.value, "omp-id"); - assert!( - session_ref_from_report("herdr:omp", "omp", None, Some("relative.jsonl".into())) - .is_none() - ); + #[test] + fn report_reference_policy_is_id_only_except_for_pi_and_omp_path_preference() { + let absolute_path = absolute_test_path("reported-session.jsonl"); - assert!( - session_ref_from_report("herdr:claude", "claude", None, Some(claude_session)).is_none() - ); + for (source, agent) in OFFICIAL_SESSION_PAIRS { + let selected = session_ref_from_report( + source, + agent, + Some(format!("{agent}-id")), + Some(absolute_path.clone()), + ) + .unwrap(); + let path_preferred = matches!(agent, "pi" | "omp"); + assert_eq!( + selected.kind, + if path_preferred { + AgentSessionRefKind::Path + } else { + AgentSessionRefKind::Id + }, + "{source} {agent}" + ); + let expected_value = if path_preferred { + absolute_path.clone() + } else { + format!("{agent}-id") + }; + assert_eq!(selected.value, expected_value); + if !path_preferred { + assert!( + session_ref_from_report(source, agent, None, Some(absolute_path.clone())) + .is_none() + ); + } + } - let session_ref = - session_ref_from_report("herdr:copilot", "copilot", Some("copilot-id".into()), None) - .unwrap(); - assert_eq!(session_ref.kind, AgentSessionRefKind::Id); - assert_eq!(session_ref.value, "copilot-id"); - assert!( - session_ref_from_report("herdr:copilot", "copilot", None, Some(copilot_session)) - .is_none() - ); + for (source, agent) in [("herdr:pi", "pi"), ("herdr:omp", "omp")] { + let fallback = session_ref_from_report( + source, + agent, + Some(format!("{agent}-id")), + Some("relative-session.jsonl".into()), + ) + .unwrap(); + assert_eq!(fallback.kind, AgentSessionRefKind::Id); + assert_eq!(fallback.value, format!("{agent}-id")); + } + } - let session_ref = - session_ref_from_report("herdr:devin", "devin", Some("devin-id".into()), None).unwrap(); - assert_eq!(session_ref.kind, AgentSessionRefKind::Id); - assert_eq!(session_ref.value, "devin-id"); - - let session_ref = - session_ref_from_report("herdr:droid", "droid", Some("droid-id".into()), None).unwrap(); - assert_eq!(session_ref.kind, AgentSessionRefKind::Id); - assert_eq!(session_ref.value, "droid-id"); - assert!(session_ref_from_report( - "herdr:droid", - "droid", - None, - Some("/tmp/droid-session".into()) - ) - .is_none()); + #[test] + fn snapshot_reference_kinds_match_the_exact_session_capability_matrix() { + let absolute_path = absolute_test_path("snapshot-session.jsonl"); - let session_ref = - session_ref_from_report("herdr:kimi", "kimi", Some("kimi-id".into()), None).unwrap(); - assert_eq!(session_ref.kind, AgentSessionRefKind::Id); - assert_eq!(session_ref.value, "kimi-id"); + for (source, agent) in OFFICIAL_SESSION_PAIRS { + assert!(session_ref_from_snapshot( + source, + agent, + AgentSessionRefKind::Id, + "session-id" + ) + .is_some()); + assert_eq!( + session_ref_from_snapshot(source, agent, AgentSessionRefKind::Path, &absolute_path) + .is_some(), + matches!(agent, "pi" | "omp"), + "{source} {agent}" + ); + } - let session_ref = session_ref_from_report( - "herdr:mastracode", - "mastracode", - Some("mastracode-id".into()), - None, + assert!(session_ref_from_snapshot( + "custom:pi", + "pi", + AgentSessionRefKind::Id, + "session-id" ) - .unwrap(); - assert_eq!(session_ref.kind, AgentSessionRefKind::Id); - assert_eq!(session_ref.value, "mastracode-id"); - - let session_ref = - session_ref_from_report("herdr:kilo", "kilo", Some("kilo-id".into()), None).unwrap(); - assert_eq!(session_ref.kind, AgentSessionRefKind::Id); - assert_eq!(session_ref.value, "kilo-id"); - - let session_ref = - session_ref_from_report("herdr:qodercli", "qodercli", Some("qoder-id".into()), None) - .unwrap(); - assert_eq!(session_ref.kind, AgentSessionRefKind::Id); - assert_eq!(session_ref.value, "qoder-id"); - - let session_ref = - session_ref_from_report("herdr:qwen", "qwen", Some("qwen-id".into()), None).unwrap(); - assert_eq!(session_ref.kind, AgentSessionRefKind::Id); - assert_eq!(session_ref.value, "qwen-id"); - - let session_ref = - session_ref_from_report("herdr:antigravity_cli", "agy", Some("agy-id".into()), None) - .unwrap(); - assert_eq!(session_ref.kind, AgentSessionRefKind::Id); - assert_eq!(session_ref.value, "agy-id"); + .is_none()); + assert!(session_ref_from_snapshot( + "herdr:qwen", + "qwen-code", + AgentSessionRefKind::Id, + "session-id" + ) + .is_none()); } #[test] fn normalize_session_start_source_allows_known_values() { - assert_eq!( - normalize_session_start_source(Some("startup".into())), - Some("startup".into()) - ); - assert_eq!( - normalize_session_start_source(Some("resume".into())), - Some("resume".into()) - ); - assert_eq!( - normalize_session_start_source(Some("clear".into())), - Some("clear".into()) - ); - assert_eq!( - normalize_session_start_source(Some("compact".into())), - Some("compact".into()) - ); - assert_eq!( - normalize_session_start_source(Some("branch".into())), - Some("branch".into()) - ); - assert_eq!( - normalize_session_start_source(Some("new".into())), - Some("new".into()) - ); - assert_eq!( - normalize_session_start_source(Some("fork".into())), - Some("fork".into()) - ); - assert_eq!( - normalize_session_start_source(Some("select".into())), - Some("select".into()) - ); + for source in [ + "startup", "resume", "clear", "compact", "branch", "new", "fork", "select", + ] { + assert_eq!( + normalize_session_start_source(Some(source.into())), + Some(source.into()) + ); + } assert_eq!( normalize_session_start_source(Some(" resume ".into())), Some("resume".into()) @@ -716,97 +1044,18 @@ mod tests { } #[test] - fn planner_rejects_path_refs_for_id_only_agents() { - let hermes_session = absolute_test_path("hermes-session"); - let opencode_session = absolute_test_path("opencode-session"); - let kilo_session = absolute_test_path("kilo-session"); - let copilot_session = absolute_test_path("copilot-session"); - let devin_session = absolute_test_path("devin-session"); - assert!(plan( - "herdr:hermes", - "hermes", - &AgentSessionRef::path(&hermes_session).unwrap() - ) - .is_none()); - assert!(plan( - "herdr:opencode", - "opencode", - &AgentSessionRef::path(&opencode_session).unwrap() - ) - .is_none()); - assert!(plan( - "herdr:kilo", - "kilo", - &AgentSessionRef::path(&kilo_session).unwrap() - ) - .is_none()); - assert!(plan( - "herdr:copilot", - "copilot", - &AgentSessionRef::path(&copilot_session).unwrap() - ) - .is_none()); - assert!(plan( - "herdr:devin", - "devin", - &AgentSessionRef::path(&devin_session).unwrap() - ) - .is_none()); - assert!(session_ref_from_snapshot( - "herdr:mastracode", - "mastracode", - AgentSessionRefKind::Id, - "mastracode-session" - ) - .is_some()); - assert!(session_ref_from_snapshot( - "herdr:hermes", - "hermes", - AgentSessionRefKind::Id, - "hermes-session" - ) - .is_some()); - assert!(session_ref_from_snapshot( - "herdr:opencode", - "opencode", - AgentSessionRefKind::Id, - "opencode-session" - ) - .is_some()); - assert!(session_ref_from_snapshot( - "herdr:kilo", - "kilo", - AgentSessionRefKind::Id, - "kilo-session" - ) - .is_some()); - assert!(session_ref_from_snapshot( - "herdr:copilot", - "copilot", - AgentSessionRefKind::Id, - "copilot-session" - ) - .is_some()); - assert!(session_ref_from_snapshot( - "herdr:devin", - "devin", - AgentSessionRefKind::Id, - "devin-session" - ) - .is_some()); - assert!(session_ref_from_snapshot( - "herdr:antigravity_cli", - "agy", - AgentSessionRefKind::Id, - "agy-session" - ) - .is_some()); - let agy_session = absolute_test_path("agy-session"); - assert!(plan( - "herdr:antigravity_cli", - "agy", - &AgentSessionRef::path(&agy_session).unwrap() - ) - .is_none()); + fn planner_rejects_path_refs_for_every_id_only_agent() { + let absolute_path = absolute_test_path("id-only-session"); + let session_ref = AgentSessionRef::path(absolute_path).unwrap(); + + for (source, agent) in OFFICIAL_SESSION_PAIRS { + if matches!(agent, "pi" | "omp") { + continue; + } + assert!( + plan(source, agent, &session_ref).is_none(), + "{source} {agent}" + ); + } } } diff --git a/src/agents/bundled.rs b/src/agents/bundled.rs new file mode 100644 index 0000000000..06d208c7c7 --- /dev/null +++ b/src/agents/bundled.rs @@ -0,0 +1,136 @@ +// Generated by scripts/agent_registry_vendor.py; do not edit. +#[rustfmt::skip] +pub(super) const FILES: &[(&str, &str)] = &[ + ("agents/agy/agent.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/agy/agent.toml"))), + ("agents/agy/assets/herdr-agent-state.ps1", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/agy/assets/herdr-agent-state.ps1"))), + ("agents/agy/assets/herdr-agent-state.sh", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/agy/assets/herdr-agent-state.sh"))), + ("agents/agy/detection.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/agy/detection.toml"))), + ("agents/agy/integration.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/agy/integration.toml"))), + ("agents/agy/process.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/agy/process.toml"))), + ("agents/agy/resume.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/agy/resume.toml"))), + ("agents/amp/agent.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/amp/agent.toml"))), + ("agents/amp/detection.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/amp/detection.toml"))), + ("agents/amp/process.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/amp/process.toml"))), + ("agents/claude/agent.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/claude/agent.toml"))), + ("agents/claude/assets/herdr-agent-state.ps1", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/claude/assets/herdr-agent-state.ps1"))), + ("agents/claude/assets/herdr-agent-state.sh", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/claude/assets/herdr-agent-state.sh"))), + ("agents/claude/detection.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/claude/detection.toml"))), + ("agents/claude/integration.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/claude/integration.toml"))), + ("agents/claude/process.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/claude/process.toml"))), + ("agents/claude/resume.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/claude/resume.toml"))), + ("agents/cline/agent.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/cline/agent.toml"))), + ("agents/cline/detection.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/cline/detection.toml"))), + ("agents/cline/process.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/cline/process.toml"))), + ("agents/codex/agent.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/codex/agent.toml"))), + ("agents/codex/assets/herdr-agent-state.ps1", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/codex/assets/herdr-agent-state.ps1"))), + ("agents/codex/assets/herdr-agent-state.sh", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/codex/assets/herdr-agent-state.sh"))), + ("agents/codex/detection.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/codex/detection.toml"))), + ("agents/codex/integration.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/codex/integration.toml"))), + ("agents/codex/process.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/codex/process.toml"))), + ("agents/codex/resume.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/codex/resume.toml"))), + ("agents/copilot/agent.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/copilot/agent.toml"))), + ("agents/copilot/assets/herdr-agent-state.ps1", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/copilot/assets/herdr-agent-state.ps1"))), + ("agents/copilot/assets/herdr-agent-state.sh", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/copilot/assets/herdr-agent-state.sh"))), + ("agents/copilot/detection.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/copilot/detection.toml"))), + ("agents/copilot/integration.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/copilot/integration.toml"))), + ("agents/copilot/process.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/copilot/process.toml"))), + ("agents/copilot/resume.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/copilot/resume.toml"))), + ("agents/cursor/agent.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/cursor/agent.toml"))), + ("agents/cursor/assets/herdr-agent-state.ps1", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/cursor/assets/herdr-agent-state.ps1"))), + ("agents/cursor/assets/herdr-agent-state.sh", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/cursor/assets/herdr-agent-state.sh"))), + ("agents/cursor/detection.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/cursor/detection.toml"))), + ("agents/cursor/integration.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/cursor/integration.toml"))), + ("agents/cursor/process.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/cursor/process.toml"))), + ("agents/cursor/resume.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/cursor/resume.toml"))), + ("agents/devin/agent.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/devin/agent.toml"))), + ("agents/devin/assets/herdr-agent-state.ps1", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/devin/assets/herdr-agent-state.ps1"))), + ("agents/devin/assets/herdr-agent-state.sh", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/devin/assets/herdr-agent-state.sh"))), + ("agents/devin/detection.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/devin/detection.toml"))), + ("agents/devin/integration.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/devin/integration.toml"))), + ("agents/devin/process.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/devin/process.toml"))), + ("agents/devin/resume.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/devin/resume.toml"))), + ("agents/droid/agent.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/droid/agent.toml"))), + ("agents/droid/assets/herdr-agent-state.ps1", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/droid/assets/herdr-agent-state.ps1"))), + ("agents/droid/assets/herdr-agent-state.sh", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/droid/assets/herdr-agent-state.sh"))), + ("agents/droid/detection.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/droid/detection.toml"))), + ("agents/droid/integration.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/droid/integration.toml"))), + ("agents/droid/process.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/droid/process.toml"))), + ("agents/droid/resume.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/droid/resume.toml"))), + ("agents/gemini/agent.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/gemini/agent.toml"))), + ("agents/gemini/detection.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/gemini/detection.toml"))), + ("agents/gemini/process.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/gemini/process.toml"))), + ("agents/grok/agent.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/grok/agent.toml"))), + ("agents/grok/assets/herdr-agent-state.ps1", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/grok/assets/herdr-agent-state.ps1"))), + ("agents/grok/assets/herdr-agent-state.sh", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/grok/assets/herdr-agent-state.sh"))), + ("agents/grok/detection.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/grok/detection.toml"))), + ("agents/grok/integration.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/grok/integration.toml"))), + ("agents/grok/process.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/grok/process.toml"))), + ("agents/grok/resume.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/grok/resume.toml"))), + ("agents/hermes/agent.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/hermes/agent.toml"))), + ("agents/hermes/assets/__init__.py", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/hermes/assets/__init__.py"))), + ("agents/hermes/assets/plugin.yaml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/hermes/assets/plugin.yaml"))), + ("agents/hermes/detection.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/hermes/detection.toml"))), + ("agents/hermes/integration.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/hermes/integration.toml"))), + ("agents/hermes/process.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/hermes/process.toml"))), + ("agents/hermes/resume.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/hermes/resume.toml"))), + ("agents/kilo/agent.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/kilo/agent.toml"))), + ("agents/kilo/assets/herdr-agent-state.js", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/kilo/assets/herdr-agent-state.js"))), + ("agents/kilo/detection.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/kilo/detection.toml"))), + ("agents/kilo/integration.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/kilo/integration.toml"))), + ("agents/kilo/process.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/kilo/process.toml"))), + ("agents/kilo/resume.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/kilo/resume.toml"))), + ("agents/kimi/agent.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/kimi/agent.toml"))), + ("agents/kimi/assets/herdr-agent-state.ps1", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/kimi/assets/herdr-agent-state.ps1"))), + ("agents/kimi/assets/herdr-agent-state.sh", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/kimi/assets/herdr-agent-state.sh"))), + ("agents/kimi/detection.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/kimi/detection.toml"))), + ("agents/kimi/integration.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/kimi/integration.toml"))), + ("agents/kimi/process.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/kimi/process.toml"))), + ("agents/kimi/resume.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/kimi/resume.toml"))), + ("agents/kiro/agent.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/kiro/agent.toml"))), + ("agents/kiro/detection.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/kiro/detection.toml"))), + ("agents/kiro/process.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/kiro/process.toml"))), + ("agents/maki/agent.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/maki/agent.toml"))), + ("agents/maki/detection.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/maki/detection.toml"))), + ("agents/maki/process.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/maki/process.toml"))), + ("agents/mastracode/agent.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/mastracode/agent.toml"))), + ("agents/mastracode/assets/herdr-agent-state.ps1", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/mastracode/assets/herdr-agent-state.ps1"))), + ("agents/mastracode/assets/herdr-agent-state.sh", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/mastracode/assets/herdr-agent-state.sh"))), + ("agents/mastracode/integration.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/mastracode/integration.toml"))), + ("agents/mastracode/process.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/mastracode/process.toml"))), + ("agents/mastracode/resume.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/mastracode/resume.toml"))), + ("agents/muse/agent.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/muse/agent.toml"))), + ("agents/muse/detection.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/muse/detection.toml"))), + ("agents/muse/process.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/muse/process.toml"))), + ("agents/omp/agent.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/omp/agent.toml"))), + ("agents/omp/assets/herdr-agent-state.ts", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/omp/assets/herdr-agent-state.ts"))), + ("agents/omp/integration.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/omp/integration.toml"))), + ("agents/omp/process.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/omp/process.toml"))), + ("agents/omp/resume.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/omp/resume.toml"))), + ("agents/opencode/agent.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/opencode/agent.toml"))), + ("agents/opencode/assets/herdr-agent-state.js", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/opencode/assets/herdr-agent-state.js"))), + ("agents/opencode/assets/herdr-tui-session.js", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/opencode/assets/herdr-tui-session.js"))), + ("agents/opencode/detection.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/opencode/detection.toml"))), + ("agents/opencode/integration.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/opencode/integration.toml"))), + ("agents/opencode/process.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/opencode/process.toml"))), + ("agents/opencode/resume.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/opencode/resume.toml"))), + ("agents/pi/agent.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/pi/agent.toml"))), + ("agents/pi/assets/herdr-agent-state.ts", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/pi/assets/herdr-agent-state.ts"))), + ("agents/pi/detection.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/pi/detection.toml"))), + ("agents/pi/integration.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/pi/integration.toml"))), + ("agents/pi/process.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/pi/process.toml"))), + ("agents/pi/resume.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/pi/resume.toml"))), + ("agents/qodercli/agent.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/qodercli/agent.toml"))), + ("agents/qodercli/assets/herdr-agent-state.ps1", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/qodercli/assets/herdr-agent-state.ps1"))), + ("agents/qodercli/assets/herdr-agent-state.sh", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/qodercli/assets/herdr-agent-state.sh"))), + ("agents/qodercli/detection.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/qodercli/detection.toml"))), + ("agents/qodercli/integration.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/qodercli/integration.toml"))), + ("agents/qodercli/process.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/qodercli/process.toml"))), + ("agents/qodercli/resume.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/qodercli/resume.toml"))), + ("agents/qwen/agent.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/qwen/agent.toml"))), + ("agents/qwen/assets/herdr-agent-session.ps1", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/qwen/assets/herdr-agent-session.ps1"))), + ("agents/qwen/assets/herdr-agent-session.sh", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/qwen/assets/herdr-agent-session.sh"))), + ("agents/qwen/detection.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/qwen/detection.toml"))), + ("agents/qwen/integration.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/qwen/integration.toml"))), + ("agents/qwen/process.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/qwen/process.toml"))), + ("agents/qwen/resume.toml", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/vendor/agent-registry/agents/qwen/resume.toml"))), +]; diff --git a/src/agents/files.rs b/src/agents/files.rs new file mode 100644 index 0000000000..d751d2f82f --- /dev/null +++ b/src/agents/files.rs @@ -0,0 +1,367 @@ +//! Bounded, read-only agent source ingestion shared by local validation and reload. + +use std::fs::{self, File, Metadata, OpenOptions}; +use std::io::Read; +use std::path::{Path, PathBuf}; + +pub(crate) const MAX_FILES: usize = 4096; +const MAX_ENTRIES: usize = 8192; +pub(crate) const MAX_FILE_BYTES: u64 = 1024 * 1024; +pub(crate) const MAX_TOTAL_BYTES: u64 = 32 * 1024 * 1024; +const MAX_DEPTH: usize = 4; + +#[derive(Debug)] +struct SourceFile { + path: PathBuf, + relative: String, +} + +#[derive(Default)] +struct Inventory { + files: Vec, + entries: usize, + bytes: u64, +} + +fn metadata(path: &Path) -> Result { + let metadata = fs::symlink_metadata(path).map_err(|error| at(path, error))?; + if metadata.file_type().is_symlink() { + return Err(at(path, "symlinks are forbidden")); + } + Ok(metadata) +} + +fn require_directory(path: &Path) -> Result<(), String> { + if !metadata(path)?.is_dir() { + return Err(at(path, "expected a directory")); + } + Ok(()) +} + +fn check_file(path: &Path, metadata: &Metadata, remaining: u64) -> Result<(), String> { + if !metadata.is_file() { + return Err(at(path, "expected a regular file")); + } + if metadata.len() > MAX_FILE_BYTES { + return Err(at(path, "file exceeds 1 MiB limit")); + } + if metadata.len() > remaining { + return Err(at(path, "source exceeds 32 MiB total limit")); + } + Ok(()) +} + +fn inventory( + directory: &Path, + relative: &str, + depth: usize, + result: &mut Inventory, +) -> Result<(), String> { + // Do not collect a directory iterator before enforcing the entry bound. + for entry in fs::read_dir(directory).map_err(|error| at(directory, error))? { + result.entries += 1; + if result.entries > MAX_ENTRIES { + return Err("source exceeds directory entry limit".into()); + } + let entry = entry.map_err(|error| at(directory, error))?; + let path = entry.path(); + if depth + 1 > MAX_DEPTH { + return Err(at(&path, "source exceeds maximum path depth of 4")); + } + let name = entry.file_name(); + let name = name + .to_str() + .ok_or_else(|| at(&path, "path is not UTF-8"))?; + if name.contains('\\') || name.chars().any(char::is_control) { + return Err(at(&path, "unsafe source path")); + } + let relative = format!("{relative}/{name}"); + if relative.len() > 256 { + return Err(at(&path, "source path too long")); + } + let metadata = metadata(&path)?; + if metadata.is_dir() { + inventory(&path, &relative, depth + 1, result)?; + } else { + if result.files.len() >= MAX_FILES { + return Err("source exceeds 4096 file limit".into()); + } + check_file(&path, &metadata, MAX_TOTAL_BYTES - result.bytes)?; + result.bytes += metadata.len(); + result.files.push(SourceFile { path, relative }); + } + } + Ok(()) +} + +pub(crate) fn read_source(root: &Path) -> Result, String> { + require_directory(root)?; + let agents = root.join("agents"); + require_directory(&agents)?; + // Check the complete inventory's counts and declared byte sizes before + // reading any content. Ignore everything outside the agents/ subtree. + let mut found = Inventory::default(); + inventory(&agents, "agents", 1, &mut found)?; + found + .files + .sort_by(|left, right| left.relative.cmp(&right.relative)); + let mut files = Vec::with_capacity(found.files.len()); + let mut total = 0; + for source in found.files { + let remaining = MAX_TOTAL_BYTES - total; + check_file(&source.path, &metadata(&source.path)?, remaining)?; + let file = open_regular(&source.path)?; + check_file( + &source.path, + &file.metadata().map_err(|error| at(&source.path, error))?, + remaining, + )?; + // A file may grow after metadata inspection. Never use read_to_string + // without a cap, and enforce the aggregate bound on the actual bytes. + let text = read_capped(file, MAX_FILE_BYTES.min(remaining)) + .map_err(|error| at(&source.path, error))?; + total += text.len() as u64; + files.push((source.relative, text)); + } + Ok(files) +} + +// Close the final-component symlink/FIFO race between inventory and open on +// Unix. Recheck the opened handle as well; file growth remains separately capped. +pub(crate) fn open_regular(path: &Path) -> Result { + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); + } + let file = options.open(path).map_err(|error| at(path, error))?; + if !file.metadata().map_err(|error| at(path, error))?.is_file() { + return Err(at(path, "expected a regular file")); + } + Ok(file) +} + +pub(crate) fn read_capped(reader: impl Read, limit: u64) -> Result { + let mut bytes = Vec::new(); + reader + .take(limit + 1) + .read_to_end(&mut bytes) + .map_err(|error| error.to_string())?; + if bytes.len() as u64 > limit { + return Err("source grew beyond the file or total byte limit".into()); + } + String::from_utf8(bytes).map_err(|_| "file is not UTF-8".into()) +} + +fn at(path: &Path, error: impl std::fmt::Display) -> String { + format!("{}: {error}", path.display()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::time::{SystemTime, UNIX_EPOCH}; + + const AGENT: &str = "schema = 1\nid = 'future-agent'\nname = 'Future agent'\naliases = []\nstartable = true\n[launch]\nunix = 'future-agent'\nwindows = 'future-agent.cmd'\n"; + + struct Fixture(PathBuf); + + impl Fixture { + fn new() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + #[cfg(unix)] + let base = PathBuf::from("/var/tmp"); + #[cfg(not(unix))] + let base = std::env::temp_dir(); + let path = base.join(format!( + "herdr-registry-test-{}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(), + NEXT.fetch_add(1, Ordering::Relaxed), + )); + fs::create_dir(&path).unwrap(); + let fixture = Self(path); + fixture.put("agents/future-agent/agent.toml", AGENT.as_bytes()); + fixture + } + + fn put(&self, relative: &str, bytes: &[u8]) -> PathBuf { + let path = self.0.join(relative); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, bytes).unwrap(); + path + } + } + + impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + fn validate_directory(root: &Path) -> Result { + let files = read_source(root)?; + let borrowed: Vec<_> = files + .iter() + .map(|(path, text)| (path.as_str(), text.as_str())) + .collect(); + crate::agents::validate_packages(&borrowed).map(|packages| packages.len()) + } + + #[test] + fn registry_reader_is_sorted_preserves_bytes_and_ignores_repository_metadata() { + let fixture = Fixture::new(); + fixture.put("README.md", &[0xff]); + fixture.put(".git/config", &[0xff]); + fixture.put("agents/future-agent/assets/z.sh", b"#!/bin/sh\r\n"); + fixture.put("agents/future-agent/assets/a.py", "# café\n".as_bytes()); + let files = read_source(&fixture.0).unwrap(); + assert_eq!( + files + .iter() + .map(|(path, _)| path.as_str()) + .collect::>(), + [ + "agents/future-agent/agent.toml", + "agents/future-agent/assets/a.py", + "agents/future-agent/assets/z.sh", + ] + ); + assert_eq!(files[2].1, "#!/bin/sh\r\n"); + } + + #[test] + fn registry_reader_rejects_missing_empty_binary_and_deep_trees() { + let fixture = Fixture::new(); + assert!(read_source(&fixture.0.join("missing")).is_err()); + let file = fixture.0.join("agents/future-agent/agent.toml"); + assert!(read_source(&file).is_err()); + fs::remove_file(&file).unwrap(); + assert!(validate_directory(&fixture.0).is_err()); + fixture.put("agents/future-agent/agent.toml", &[0xff]); + assert!(read_source(&fixture.0).unwrap_err().contains("UTF-8")); + fixture.put("agents/future-agent/assets/nested/hook.sh", b"text"); + assert!(read_source(&fixture.0).unwrap_err().contains("depth")); + } + + #[test] + fn registry_reader_caps_growth_after_metadata_and_rejects_non_utf8() { + assert_eq!(read_capped(&b"text"[..], 4).unwrap(), "text"); + assert!(read_capped(&b"grown"[..], 4).unwrap_err().contains("limit")); + assert!(read_capped(&b"\xff"[..], 1).unwrap_err().contains("UTF-8")); + } + + #[test] + fn registry_reader_rejects_oversized_files_before_reading_any_contents() { + let fixture = Fixture::new(); + fixture.put("agents/future-agent/agent.toml", &[0xff]); + let big = fixture.put("agents/future-agent/assets/big.sh", b""); + File::options() + .write(true) + .open(big) + .unwrap() + .set_len(MAX_FILE_BYTES + 1) + .unwrap(); + assert!(read_source(&fixture.0).unwrap_err().contains("1 MiB")); + } + + #[test] + fn registry_reader_bounds_aggregate_bytes_before_reading_any_contents() { + let fixture = Fixture::new(); + for index in 0..32 { + let big = fixture.put(&format!("agents/future-agent/assets/{index}.sh"), b""); + File::options() + .write(true) + .open(big) + .unwrap() + .set_len(MAX_FILE_BYTES) + .unwrap(); + } + assert!(read_source(&fixture.0).unwrap_err().contains("32 MiB")); + } + + #[test] + fn registry_reader_bounds_file_count_before_collecting() { + let fixture = Fixture::new(); + for index in 0..MAX_FILES { + fixture.put(&format!("agents/future-agent/assets/{index}.sh"), b""); + } + assert!(read_source(&fixture.0).unwrap_err().contains("4096")); + } + + #[test] + fn registry_reader_bounds_source_paths_before_reading_content() { + let fixture = Fixture::new(); + fixture.put( + &format!("agents/future-agent/assets/{}", "x".repeat(240)), + b"text", + ); + assert!(read_source(&fixture.0) + .unwrap_err() + .contains("path too long")); + } + + #[cfg(unix)] + #[test] + fn registry_reader_rejects_non_utf8_and_unsafe_source_names() { + use std::os::unix::ffi::OsStringExt; + let fixture = Fixture::new(); + let path = fixture + .0 + .join("agents") + .join(std::ffi::OsString::from_vec(vec![0xff])); + match fs::write(&path, b"text") { + Ok(()) => { + assert!(read_source(&fixture.0).unwrap_err().contains("UTF-8")); + fs::remove_file(path).unwrap(); + } + // APFS rejects non-UTF-8 names before the reader can observe them. + Err(error) => assert_eq!(error.raw_os_error(), Some(libc::EILSEQ)), + } + fixture.put("agents/future-agent/assets/unsafe\\name", b"text"); + assert!(read_source(&fixture.0) + .unwrap_err() + .contains("unsafe source path")); + } + + #[cfg(unix)] + #[test] + fn registry_reader_rejects_root_agents_directory_and_file_symlinks() { + use std::os::unix::fs::symlink; + let fixture = Fixture::new(); + let link = fixture.0.join("root-link"); + symlink(&fixture.0, &link).unwrap(); + assert!(read_source(&link).unwrap_err().contains("symlink")); + fs::remove_file(&link).unwrap(); + let agents = fixture.0.join("agents"); + let saved = fixture.0.join("saved-agents"); + fs::rename(&agents, &saved).unwrap(); + symlink(&saved, &agents).unwrap(); + assert!(read_source(&fixture.0).unwrap_err().contains("symlink")); + fs::remove_file(&agents).unwrap(); + fs::rename(&saved, &agents).unwrap(); + for target in [&agents, &agents.join("future-agent/agent.toml")] { + let link = agents.join("link"); + symlink(target, &link).unwrap(); + assert!(read_source(&fixture.0).unwrap_err().contains("symlink")); + fs::remove_file(&link).unwrap(); + } + } + + #[cfg(unix)] + #[test] + fn registry_reader_rejects_nonregular_files_without_opening_them() { + let fixture = Fixture::new(); + let socket = fixture.0.join("agents/socket"); + let _listener = std::os::unix::net::UnixListener::bind(socket).unwrap(); + assert!(read_source(&fixture.0) + .unwrap_err() + .contains("regular file")); + } +} diff --git a/src/agents/id.rs b/src/agents/id.rs new file mode 100644 index 0000000000..2eb4a4af68 --- /dev/null +++ b/src/agents/id.rs @@ -0,0 +1,186 @@ +//! Stable canonical agent identity, independent of registry membership. + +use std::fmt; + +/// A validated canonical ID (`[a-z][a-z0-9-]{0,63}`), stored inline. +/// +/// Parsing an ID does not establish registry membership or grant capabilities. +#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct AgentId { + bytes: [u8; 64], + len: u8, +} + +impl AgentId { + pub fn parse(value: &str) -> Result { + if !Self::valid(value) { + return Err("agent ID must match [a-z][a-z0-9-]{0,63}".to_string()); + } + Ok(Self::from_canonical(value)) + } + + pub fn as_str(&self) -> &str { + // Both constructors validate ASCII and zero-fill unused bytes. + std::str::from_utf8(&self.bytes[..usize::from(self.len)]).expect("validated ASCII agent ID") + } + + const fn valid(value: &str) -> bool { + let bytes = value.as_bytes(); + if bytes.is_empty() || bytes.len() > 64 || !bytes[0].is_ascii_lowercase() { + return false; + } + let mut index = 1; + while index < bytes.len() { + let byte = bytes[index]; + if !byte.is_ascii_lowercase() && !byte.is_ascii_digit() && byte != b'-' { + return false; + } + index += 1; + } + true + } + + const fn from_canonical(value: &str) -> Self { + assert!(Self::valid(value), "invalid canonical agent ID"); + let mut bytes = [0; 64]; + let mut index = 0; + while index < value.len() { + bytes[index] = value.as_bytes()[index]; + index += 1; + } + Self { + bytes, + len: value.len() as u8, + } + } +} + +// Compatibility spellings, not a closed set of supported identities. +#[allow(non_upper_case_globals)] +impl AgentId { + pub const Pi: Self = Self::from_canonical("pi"); + pub const Claude: Self = Self::from_canonical("claude"); + pub const Codex: Self = Self::from_canonical("codex"); + pub const Gemini: Self = Self::from_canonical("gemini"); + pub const Cursor: Self = Self::from_canonical("cursor"); + pub const Devin: Self = Self::from_canonical("devin"); + pub const Antigravity: Self = Self::from_canonical("agy"); + pub const Cline: Self = Self::from_canonical("cline"); + pub const Omp: Self = Self::from_canonical("omp"); + pub const Mastracode: Self = Self::from_canonical("mastracode"); + pub const OpenCode: Self = Self::from_canonical("opencode"); + pub const GithubCopilot: Self = Self::from_canonical("copilot"); + pub const Kimi: Self = Self::from_canonical("kimi"); + pub const Kiro: Self = Self::from_canonical("kiro"); + pub const Droid: Self = Self::from_canonical("droid"); + pub const Amp: Self = Self::from_canonical("amp"); + pub const Grok: Self = Self::from_canonical("grok"); + pub const Hermes: Self = Self::from_canonical("hermes"); + pub const Kilo: Self = Self::from_canonical("kilo"); + pub const Qodercli: Self = Self::from_canonical("qodercli"); + pub const Qwen: Self = Self::from_canonical("qwen"); + pub const Maki: Self = Self::from_canonical("maki"); + pub const Muse: Self = Self::from_canonical("muse"); + + /// Legacy built-in identities, for compatibility and tests only. + /// Never use this list to decide whether an ID is valid or registered. + pub const ALL: [Self; 23] = [ + Self::Pi, + Self::Claude, + Self::Codex, + Self::Gemini, + Self::Cursor, + Self::Devin, + Self::Antigravity, + Self::Cline, + Self::Omp, + Self::Mastracode, + Self::OpenCode, + Self::GithubCopilot, + Self::Kimi, + Self::Kiro, + Self::Droid, + Self::Amp, + Self::Grok, + Self::Hermes, + Self::Kilo, + Self::Qodercli, + Self::Qwen, + Self::Maki, + Self::Muse, + ]; +} + +impl fmt::Debug for AgentId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("AgentId") + .field(&self.as_str()) + .finish() + } +} + +impl fmt::Display for AgentId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::AgentId; + use std::collections::{BTreeSet, HashMap}; + + #[test] + fn validates_canonical_syntax_and_bounds_without_truncating() { + for invalid in [ + "", "Pi", " pi", "pi ", "1agent", "-agent", "a_b", "a.b", "a/b", "a\0b", "é", "aé", + "a\nb", + ] { + assert!(AgentId::parse(invalid).is_err(), "{invalid:?}"); + } + assert_eq!(AgentId::parse("a").unwrap().as_str(), "a"); + assert_eq!(AgentId::parse("a-0").unwrap().as_str(), "a-0"); + let maximum = "a".repeat(64); + assert_eq!(AgentId::parse(&maximum).unwrap().as_str(), maximum); + assert!(AgentId::parse(&"a".repeat(65)).is_err()); + } + + #[test] + fn identity_is_copyable_and_has_value_equality_hash_and_order() { + assert_eq!(std::mem::size_of::(), 65); + let original = AgentId::parse("future-agent").unwrap(); + let copy = original; + assert_eq!(original, copy); + assert_eq!(original, AgentId::parse("future-agent").unwrap()); + assert_ne!(original, AgentId::parse("future-agent2").unwrap()); + let map = HashMap::from([(original, 42)]); + assert_eq!(map.get(&AgentId::parse("future-agent").unwrap()), Some(&42)); + let sorted: BTreeSet<_> = ["aa", "a0", "a", "a-", "b"] + .map(|id| AgentId::parse(id).unwrap()) + .into_iter() + .collect(); + assert_eq!( + sorted.iter().map(AgentId::as_str).collect::>(), + ["a", "a-", "a0", "aa", "b"] + ); + } + + #[test] + fn accepts_new_ids_without_registry_membership() { + let id = AgentId::parse("future-agent-42").unwrap(); + assert!(!AgentId::ALL.contains(&id)); + assert_eq!(id.to_string(), "future-agent-42"); + assert_eq!(format!("{id:?}"), "AgentId(\"future-agent-42\")"); + } + + #[test] + fn legacy_constants_are_canonical_values() { + for id in AgentId::ALL { + assert_eq!(AgentId::parse(id.as_str()).unwrap(), id); + } + assert_eq!(AgentId::Antigravity.as_str(), "agy"); + assert_eq!(AgentId::GithubCopilot.as_str(), "copilot"); + assert_eq!(AgentId::OpenCode.as_str(), "opencode"); + } +} diff --git a/src/agents/integration.rs b/src/agents/integration.rs new file mode 100644 index 0000000000..b285fbfa5f --- /dev/null +++ b/src/agents/integration.rs @@ -0,0 +1,158 @@ +//! Loaded integration metadata bound to a trusted, compiled installer. + +use std::collections::BTreeMap; +use std::io; +use std::path::{Path, PathBuf}; + +use super::source::IntegrationDefinition; +use crate::api::schema::IntegrationTarget; + +#[derive(Debug)] +pub(crate) struct AgentVersionRequirement { + pub label: &'static str, + pub binary: &'static str, + pub args: &'static [&'static str], + pub min_version: &'static str, +} + +type InstallAction = fn(&IntegrationProfile) -> io::Result>; +type Action = fn() -> io::Result>; +type PathResolver = fn() -> io::Result; +type AvailabilityProbe = fn() -> bool; +type ExtraValidator = fn(&Path, u32) -> bool; + +#[derive(Debug, Clone, Copy)] +pub(crate) struct IntegrationAdapter { + install_action: InstallAction, + uninstall_action: Action, + primary_path_resolver: PathResolver, + install_layout_probe: Option, + current_install_extra_validator: Option, + version_requirement: Option<&'static AgentVersionRequirement>, +} + +impl IntegrationAdapter { + pub(crate) const fn new( + install_action: InstallAction, + uninstall_action: Action, + primary_path_resolver: PathResolver, + ) -> Self { + Self { + install_action, + uninstall_action, + primary_path_resolver, + install_layout_probe: None, + current_install_extra_validator: None, + version_requirement: None, + } + } + + pub(crate) const fn with_install_layout_probe(mut self, probe: AvailabilityProbe) -> Self { + self.install_layout_probe = Some(probe); + self + } + + pub(crate) const fn with_current_install_extra_validator( + mut self, + validator: ExtraValidator, + ) -> Self { + self.current_install_extra_validator = Some(validator); + self + } + + pub(crate) const fn with_version_requirement( + mut self, + requirement: &'static AgentVersionRequirement, + ) -> Self { + self.version_requirement = Some(requirement); + self + } + + pub(crate) fn install(self, profile: &IntegrationProfile) -> io::Result> { + (self.install_action)(profile) + } + + pub(crate) fn uninstall(self) -> io::Result> { + (self.uninstall_action)() + } + + pub(crate) fn primary_installed_artifact_path(self) -> io::Result { + (self.primary_path_resolver)() + } + + pub(crate) fn install_layout_available(self) -> bool { + self.install_layout_probe.is_some_and(|probe| probe()) + } + + pub(crate) fn current_install_extra_is_valid(self, path: &Path, expected_version: u32) -> bool { + self.current_install_extra_validator + .is_none_or(|validator| validator(path, expected_version)) + } + + pub(crate) fn agent_version_requirement(self) -> Option<&'static AgentVersionRequirement> { + self.version_requirement + } +} + +#[derive(Debug)] +pub(crate) struct IntegrationProfile { + pub(super) target: IntegrationTarget, + pub(super) definition: IntegrationDefinition, + pub(super) assets: BTreeMap, + pub(super) adapter: IntegrationAdapter, +} + +impl IntegrationProfile { + pub(crate) fn target(&self) -> IntegrationTarget { + self.target + } + pub(crate) fn cli_label(&self) -> &str { + &self.definition.cli_name + } + pub(crate) fn cli_aliases(&self) -> &[String] { + &self.definition.aliases + } + + #[cfg(not(windows))] + pub(crate) fn command_names(&self) -> &[String] { + &self.definition.commands.unix + } + #[cfg(windows)] + pub(crate) fn command_names(&self) -> &[String] { + &self.definition.commands.windows + } + + #[cfg(not(windows))] + pub(crate) fn supported(&self) -> bool { + self.definition.supported.unix + } + #[cfg(windows)] + pub(crate) fn supported(&self) -> bool { + self.definition.supported.windows + } + + #[cfg(not(windows))] + pub(crate) fn expected_version(&self) -> u32 { + self.definition.versions.unix + } + #[cfg(windows)] + pub(crate) fn expected_version(&self) -> u32 { + self.definition.versions.windows + } + + pub(crate) fn asset(&self, install_name: &str) -> io::Result<&str> { + let platform = if cfg!(windows) { "windows" } else { "unix" }; + let definition = self.definition.assets.iter().find(|asset| { + asset.install_name == install_name + && (asset.platform == "all" || asset.platform == platform) + }); + definition + .and_then(|asset| self.assets.get(&asset.path)) + .map(String::as_str) + .ok_or_else(|| io::Error::other(format!("missing integration asset {install_name}"))) + } + + pub(crate) fn adapter(&self) -> IntegrationAdapter { + self.adapter + } +} diff --git a/src/agents/mod.rs b/src/agents/mod.rs new file mode 100644 index 0000000000..bdfe096d62 --- /dev/null +++ b/src/agents/mod.rs @@ -0,0 +1,365 @@ +//! Open agent identities and immutable, session-owned registry snapshots. + +mod bundled; +pub(crate) mod files; +pub(crate) mod id; +pub(crate) mod integration; +pub(crate) mod presentation; +pub(crate) mod process; +pub(crate) mod remote; +mod report; +pub(crate) mod session; +pub(crate) mod source; +pub(crate) mod store; +pub(crate) use store::RegistrySnapshot; + +use std::collections::HashMap; +use std::sync::{Arc, LazyLock}; + +use crate::api::schema::IntegrationTarget; +use crate::detect::Agent; +use integration::IntegrationProfile; +use presentation::SoundProfile; +use process::ProcessProfile; +use report::{ReportAuthority, ReportPolicy}; +use session::SessionProfile; +use source::{LaunchDefinition as LaunchProfile, Package}; + +impl LaunchProfile { + #[cfg(not(windows))] + pub(crate) fn executable(&self) -> &str { + &self.unix + } + #[cfg(windows)] + pub(crate) fn executable(&self) -> &str { + &self.windows + } +} + +#[derive(Debug)] +pub(crate) struct AgentProfile { + legacy_agent: Agent, + definition: Package, + integration: Option>, +} + +impl AgentProfile { + pub(crate) fn legacy_agent(&self) -> Agent { + self.legacy_agent + } + pub(crate) fn canonical_id(&self) -> &str { + &self.definition.identity.id + } + #[cfg(test)] + pub(crate) fn aliases(&self) -> &[String] { + &self.definition.identity.aliases + } + pub(crate) fn sound(&self) -> Option<&SoundProfile> { + self.definition.identity.sound.as_ref() + } + pub(crate) fn process(&self) -> Option<&ProcessProfile> { + self.definition.process.as_ref() + } + pub(crate) fn launch(&self) -> &LaunchProfile { + &self.definition.identity.launch + } + fn report(&self) -> ReportPolicy { + report::policy(self.legacy_agent) + } + pub(crate) fn is_startable(&self) -> bool { + self.definition.identity.startable + } + pub(crate) fn detection(&self) -> Option<&str> { + self.definition.detection.as_deref() + } + pub(crate) fn is_screen_detectable(&self) -> bool { + self.detection().is_some() + } + pub(crate) fn session(&self) -> Option<&SessionProfile> { + self.definition.resume.as_ref() + } + pub(crate) fn integration(&self) -> Option<&Arc> { + self.integration.as_ref() + } +} + +#[derive(Debug, Default)] +pub(crate) struct AgentRegistry { + profiles: Vec, + profile_lookup: HashMap, + process_lookup: HashMap, + versioned_processes: Vec, + integration_order: Vec, +} + +impl AgentRegistry { + pub(crate) fn from_packages(packages: Vec) -> Result { + let mut profiles = Vec::new(); + for mut definition in packages { + let agent = Agent::parse(&definition.identity.id)?; + let definition_assets = std::mem::take(&mut definition.assets); + let integration = definition.integration.take().and_then(|definition| { + crate::integration::builtin::binding(agent).map(|(target, adapter)| { + Arc::new(IntegrationProfile { + target, + definition, + assets: definition_assets, + adapter, + }) + }) + }); + profiles.push(AgentProfile { + legacy_agent: agent, + definition, + integration, + }); + } + profiles.sort_by(|a, b| { + let rank = |id| { + Agent::ALL + .iter() + .position(|known| *known == id) + .unwrap_or(usize::MAX) + }; + rank(a.legacy_agent) + .cmp(&rank(b.legacy_agent)) + .then_with(|| a.canonical_id().cmp(b.canonical_id())) + }); + let mut registry = Self { + profiles, + ..Self::default() + }; + for (index, profile) in registry.profiles.iter().enumerate() { + for name in std::iter::once(&profile.definition.identity.id) + .chain(&profile.definition.identity.aliases) + { + if registry + .profile_lookup + .insert(name.clone(), index) + .is_some() + { + return Err(format!("duplicate agent alias: {name}")); + } + } + if let Some(process) = profile.process() { + for name in &process.names { + if registry + .process_lookup + .insert(name.clone(), index) + .is_some() + { + return Err(format!("duplicate process matcher: {name}")); + } + } + if process.versioned_basename_prefix.is_some() { + registry.versioned_processes.push(index); + } + } + if profile.integration.is_some() { + registry.integration_order.push(index); + } + } + registry.integration_order.sort_by_key(|index| { + registry.profiles[*index] + .integration() + .map(|profile| profile.target() as usize) + }); + Ok(registry) + } + + pub(crate) fn known_profiles(&self) -> impl ExactSizeIterator { + self.profiles.iter() + } + pub(crate) fn profile_by_normalized_process_name(&self, name: &str) -> Option<&AgentProfile> { + self.process_lookup + .get(name) + .map(|index| &self.profiles[*index]) + .or_else(|| self.profile_by_versioned_process_name(name)) + } + pub(crate) fn profile_by_versioned_process_name(&self, name: &str) -> Option<&AgentProfile> { + self.versioned_processes + .iter() + .map(|index| &self.profiles[*index]) + .find(|profile| { + profile + .process() + .is_some_and(|process| process.matches_versioned_basename(name)) + }) + } + pub(crate) fn process_profiles_with_package_layouts( + &self, + ) -> impl Iterator { + self.profiles.iter().filter(|profile| { + profile + .process() + .is_some_and(|process| !process.known_package_layouts().is_empty()) + }) + } + pub(crate) fn process_profiles_with_bundled_node_layout( + &self, + ) -> impl Iterator { + self.profiles.iter().filter(|profile| { + profile + .process() + .is_some_and(|process| process.bundled_node_layout().is_some()) + }) + } + pub(crate) fn screen_detectable_profiles(&self) -> impl Iterator { + self.profiles + .iter() + .filter(|profile| profile.is_screen_detectable()) + } + pub(crate) fn integration_capable_profiles(&self) -> impl Iterator { + self.integration_order + .iter() + .map(|index| &self.profiles[*index]) + } + pub(crate) fn profile_by_integration_target( + &self, + target: IntegrationTarget, + ) -> Option<&AgentProfile> { + self.integration_capable_profiles().find(|profile| { + profile + .integration() + .is_some_and(|integration| integration.target() == target) + }) + } + pub(crate) fn profile_by_integration_cli_name(&self, name: &str) -> Option<&AgentProfile> { + self.integration_capable_profiles().find(|profile| { + profile.integration().is_some_and(|integration| { + integration.cli_label() == name + || integration.cli_aliases().iter().any(|alias| alias == name) + }) + }) + } + pub(crate) fn profile_by_agent(&self, agent: Agent) -> Option<&AgentProfile> { + self.profile_by_id(agent.as_str()) + } + pub(crate) fn sound_profile_by_config_key(&self, key: &str) -> Option<&SoundProfile> { + self.profiles + .iter() + .filter_map(|profile| profile.sound()) + .find(|sound| sound.config_key() == key) + } + #[cfg(test)] + pub(crate) fn profile_for_agent(&self, agent: Agent) -> &AgentProfile { + self.profile_by_agent(agent) + .expect("bundled compatibility profile") + } + pub(crate) fn profile_by_id(&self, id: &str) -> Option<&AgentProfile> { + let profile = self.profile_by_normalized_alias(id)?; + (profile.canonical_id() == id).then_some(profile) + } + pub(crate) fn profile_by_normalized_alias(&self, name: &str) -> Option<&AgentProfile> { + self.profile_lookup + .get(name) + .and_then(|index| self.profiles.get(*index)) + } + pub(crate) fn profile_for_exact_report_pair( + &self, + source: &str, + id: &str, + ) -> Option<&AgentProfile> { + let profile = self.profile_by_id(id)?; + (profile.report().official_source() == Some(source)).then_some(profile) + } + pub(crate) fn session_profile_for_exact_report_pair( + &self, + source: &str, + id: &str, + ) -> Option<(&AgentProfile, &SessionProfile)> { + let profile = self.profile_for_exact_report_pair(source, id)?; + Some((profile, profile.session()?)) + } + fn report_policy_for_exact_pair(&self, source: &str, id: &str) -> Option { + report_policy_for_exact_pair(source, id) + } + pub(crate) fn has_full_lifecycle_report_authority(&self, source: &str, id: &str) -> bool { + self.report_policy_for_exact_pair(source, id) + .is_some_and(|report| report.authority() == ReportAuthority::FullLifecycle) + } + pub(crate) fn is_session_identity_only_integration(&self, source: &str, id: &str) -> bool { + self.report_policy_for_exact_pair(source, id) + .is_some_and(|report| report.authority() == ReportAuthority::SessionIdentityOnly) + } + pub(crate) fn is_reserved_native_state_source(&self, source: &str, id: &str) -> bool { + self.report_policy_for_exact_pair(source, id) + .is_some_and(ReportPolicy::reserves_native_state) + } + pub(crate) fn session_report_allows_replacement( + &self, + source: &str, + id: &str, + event: Option<&str>, + ) -> bool { + self.report_policy_for_exact_pair(source, id) + .is_some_and(|report| report.allows_session_replacement(event)) + } + pub(crate) fn session_replacement_allows_unsequenced_report( + &self, + source: &str, + id: &str, + event: Option<&str>, + ) -> bool { + self.report_policy_for_exact_pair(source, id) + .is_some_and(|report| report.allows_unsequenced_session_replacement(event)) + } + pub(crate) fn initial_lifecycle_report_replaces_session(&self, source: &str, id: &str) -> bool { + self.report_policy_for_exact_pair(source, id) + .is_some_and(ReportPolicy::initial_lifecycle_report_replaces_session) + } +} + +fn load_packages(files: &[(&str, &str)]) -> Result, String> { + let packages = source::load_packages(files)?; + for package in &packages { + crate::integration::builtin::validate_package(package)?; + } + Ok(packages) +} + +pub(crate) fn validate_packages(files: &[(&str, &str)]) -> Result, String> { + let packages = load_packages(files)?; + for package in &packages { + if let Some(detection) = &package.detection { + crate::detect::manifest::validate_package_manifest( + detection, + &package.identity.id, + &package.identity.aliases, + )?; + } + } + Ok(packages) +} + +pub(crate) fn registry() -> Arc { + store::snapshot() +} + +// A fixed baseline is used only to migrate old unpinned saved sessions. It +// never registers identities absent from the active runtime snapshot. +static BUNDLED_REGISTRY: LazyLock = LazyLock::new(|| { + load_packages(bundled::FILES) + .and_then(AgentRegistry::from_packages) + .unwrap_or_else(|error| { + tracing::error!(%error, "invalid bundled resume baseline"); + AgentRegistry::default() + }) +}); + +pub(crate) fn bundled_profile(id: &str) -> Option<&'static AgentProfile> { + BUNDLED_REGISTRY.profile_by_id(id) +} + +pub(crate) fn bundled_report_pair(source: &str, id: &str) -> bool { + report_policy_for_exact_pair(source, id).is_some() +} + +pub(crate) fn report_policy_for_exact_pair(source: &str, id: &str) -> Option { + // Package availability cannot revoke core reporter reservations or policy. + let policy = report::policy(Agent::parse(id).ok()?); + (policy.official_source() == Some(source)).then_some(policy) +} + +#[cfg(test)] +mod tests; diff --git a/src/agents/presentation.rs b/src/agents/presentation.rs new file mode 100644 index 0000000000..2af2f9fb09 --- /dev/null +++ b/src/agents/presentation.rs @@ -0,0 +1,15 @@ +//! Presentation metadata loaded from an agent package. + +pub(crate) use super::source::{ + SoundDefault as SoundDefaultPolicy, SoundDefinition as SoundProfile, +}; + +impl SoundProfile { + pub(crate) fn config_key(&self) -> &str { + &self.key + } + + pub(crate) fn default_policy(&self) -> SoundDefaultPolicy { + self.default + } +} diff --git a/src/agents/process.rs b/src/agents/process.rs new file mode 100644 index 0000000000..6f92399937 --- /dev/null +++ b/src/agents/process.rs @@ -0,0 +1,57 @@ +//! Closed process-recognition primitives loaded from agent packages. + +pub(crate) use super::source::{ + BundledNodeDefinition as BundledNodeLayout, PackageMatch as KnownPackageMatch, + PackagePath as KnownPackageLayout, ProcessDefinition as ProcessProfile, +}; + +impl KnownPackageLayout { + pub(crate) fn components(&self) -> &[String] { + &self.components + } + + pub(crate) fn match_kind(&self) -> KnownPackageMatch { + self.kind + } +} + +impl BundledNodeLayout { + pub(crate) fn runtime_basename(&self) -> &str { + &self.runtime_basename + } + + pub(crate) fn entrypoint_basename(&self) -> &str { + &self.entrypoint_basename + } + + pub(crate) fn package_directory(&self) -> &str { + &self.package_directory + } + + pub(crate) fn versions_directory(&self) -> &str { + &self.versions_directory + } +} + +impl ProcessProfile { + pub(crate) fn known_package_layouts(&self) -> &[KnownPackageLayout] { + &self.package_paths + } + + pub(crate) fn bundled_node_layout(&self) -> Option<&BundledNodeLayout> { + self.bundled_node.as_ref() + } + + pub(crate) fn uses_secondary_runtime_argv_fallback(&self) -> bool { + self.secondary_runtime_argv_fallback + } + + pub(crate) fn matches_versioned_basename(&self, name: &str) -> bool { + self.versioned_basename_prefix + .as_ref() + .is_some_and(|prefix| { + name.strip_prefix(prefix.as_str()) + .is_some_and(|suffix| suffix.starts_with(|c: char| c.is_ascii_digit())) + }) + } +} diff --git a/src/agents/remote.rs b/src/agents/remote.rs new file mode 100644 index 0000000000..cf84a49f4c --- /dev/null +++ b/src/agents/remote.rs @@ -0,0 +1,592 @@ +//! Explicit full-registry delivery. HTTPS authenticates the configured origin; +//! snapshot hashes detect corruption, not compromise of that publishing origin. + +use std::io::Read; +use std::process::Stdio; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use super::files; + +pub(crate) const DEFAULT_ORIGIN: &str = "https://registry.herdr.dev"; +pub(crate) const MAX_POINTER_BYTES: usize = 4096; +pub(crate) const MAX_SNAPSHOT_BYTES: usize = 64 * 1024 * 1024; + +#[derive( + Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, +)] +#[serde(rename_all = "snake_case")] +pub(crate) enum Channel { + #[default] + Stable, + Preview, + Staging, +} + +impl Channel { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Stable => "stable", + Self::Preview => "preview", + Self::Staging => "staging", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub(crate) struct ChannelPointer { + pub(crate) schema: u32, + pub(crate) channel: Channel, + pub(crate) generation: u64, + pub(crate) snapshot_sha256: String, + pub(crate) snapshot_bytes: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub(crate) struct RemoteRevision { + pub(crate) origin: String, + pub(crate) pointer: ChannelPointer, + pub(crate) commit: String, +} + +impl RemoteRevision { + pub(crate) fn validate(&self) -> Result<(), String> { + if normalize_origin(&self.origin)? != self.origin { + return Err("registry provenance origin must be normalized".into()); + } + self.pointer.validate(self.pointer.channel)?; + if !git_id(&self.commit) { + return Err("invalid registry source commit".into()); + } + Ok(()) + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Compatibility { + registry_api: u32, + min_detection_engine: u32, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Source { + repository: String, + commit: String, + agents_tree: String, + dirty: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Snapshot { + schema: u32, + compatibility: Compatibility, + source: Source, + content_sha256: String, + #[serde(deserialize_with = "deserialize_files")] + files: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct SnapshotFile { + path: String, + bytes: u64, + sha256: String, + text: String, +} + +#[derive(Debug)] +pub(crate) struct VerifiedSnapshot { + pub(crate) files: Vec<(String, String)>, + pub(crate) content_sha256: String, + pub(crate) commit: String, +} + +fn deserialize_files<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + struct Visitor; + impl<'de> serde::de::Visitor<'de> for Visitor { + type Value = Vec; + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("a bounded sorted registry inventory") + } + fn visit_seq(self, mut sequence: A) -> Result + where + A: serde::de::SeqAccess<'de>, + { + let mut result: Vec = Vec::new(); + let mut total = 0u64; + while let Some(file) = sequence.next_element::()? { + total += file.text.len() as u64; + if result.len() >= files::MAX_FILES + || file.path.len() > 240 + || file.text.len() as u64 > files::MAX_FILE_BYTES + || total > files::MAX_TOTAL_BYTES + || result.last().is_some_and(|last| last.path >= file.path) + { + return Err(serde::de::Error::custom( + "registry inventory exceeds bounds or is not strictly sorted", + )); + } + result.push(file); + } + Ok(result) + } + } + deserializer.deserialize_seq(Visitor) +} + +pub(crate) fn sha256(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn hex(value: &str, length: usize) -> bool { + value.len() == length + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn git_id(value: &str) -> bool { + hex(value, 40) || hex(value, 64) +} + +impl ChannelPointer { + fn validate(&self, channel: Channel) -> Result<(), String> { + if self.schema != 1 + || self.channel != channel + || self.generation == 0 + || !hex(&self.snapshot_sha256, 64) + || self.snapshot_bytes == 0 + || self.snapshot_bytes > MAX_SNAPSHOT_BYTES as u64 + { + return Err("invalid or unsupported registry channel pointer".into()); + } + Ok(()) + } +} + +pub(crate) fn parse_pointer(bytes: &[u8], channel: Channel) -> Result { + if bytes.len() > MAX_POINTER_BYTES { + return Err("registry channel pointer exceeds limit".into()); + } + let pointer: ChannelPointer = serde_json::from_slice(bytes).map_err(|e| e.to_string())?; + pointer.validate(channel)?; + Ok(pointer) +} + +pub(crate) fn validate_snapshot(bytes: &[u8]) -> Result { + if bytes.len() > MAX_SNAPSHOT_BYTES { + return Err("registry snapshot exceeds limit".into()); + } + let snapshot: Snapshot = serde_json::from_slice(bytes).map_err(|e| e.to_string())?; + if snapshot.schema != 1 + || snapshot.compatibility.registry_api != 1 + || snapshot.compatibility.min_detection_engine == 0 + || snapshot.compatibility.min_detection_engine + > crate::detect::manifest_version::MANIFEST_ENGINE_VERSION + { + return Err( + "registry snapshot requires an unsupported registry API or detection engine".into(), + ); + } + if snapshot.source.repository != "https://github.com/herdrdev/agent-registry" + || !git_id(&snapshot.source.commit) + || !git_id(&snapshot.source.agents_tree) + || snapshot.source.dirty + || !hex(&snapshot.content_sha256, 64) + { + return Err("registry snapshot has invalid or uncommitted provenance".into()); + } + let mut inventory = Sha256::new(); + let mut files = Vec::with_capacity(snapshot.files.len()); + for file in snapshot.files { + if file + .text + .bytes() + .any(|byte| (byte < 0x20 && !b"\t\n\r".contains(&byte)) || byte == 0x7f) + { + return Err(format!( + "registry file contains control bytes: {}", + file.path + )); + } + if file.bytes != file.text.len() as u64 + || !hex(&file.sha256, 64) + || sha256(file.text.as_bytes()) != file.sha256 + { + return Err(format!("registry file size/hash mismatch: {}", file.path)); + } + inventory.update(format!("{} {}\n", file.sha256, file.path).as_bytes()); + files.push((file.path, file.text)); + } + if format!("{:x}", inventory.finalize()) != snapshot.content_sha256 { + return Err("registry content digest mismatch".into()); + } + let borrowed: Vec<_> = files + .iter() + .map(|(p, t)| (p.as_str(), t.as_str())) + .collect(); + super::validate_packages(&borrowed)?; + Ok(VerifiedSnapshot { + files, + content_sha256: snapshot.content_sha256, + commit: snapshot.source.commit, + }) +} + +pub(crate) fn origin() -> Result { + let origin = + std::env::var("HERDR_AGENT_REGISTRY_ORIGIN").unwrap_or_else(|_| DEFAULT_ORIGIN.to_owned()); + normalize_origin(&origin) +} + +fn normalize_origin(origin: &str) -> Result { + // Accept an HTTPS origin, not a URL template, path or credentials. Canonical + // host/port spelling prevents channel high-water marks being bypassed by aliases. + let invalid = || { + "registry origin must be a bounded HTTPS hostname with optional port, without path or credentials".to_string() + }; + let authority = origin + .strip_prefix("https://") + .ok_or_else(invalid)? + .trim_end_matches('/'); + let (host, port) = match authority.split_once(':') { + Some((host, port)) => { + let port = port.parse::().map_err(|_| invalid())?; + if port == 0 { + return Err(invalid()); + } + (host, Some(port)) + } + None => (authority, None), + }; + if host.is_empty() + || host.len() > 253 + || host.split('.').any(|label| { + label.is_empty() + || label.len() > 63 + || label.starts_with('-') + || label.ends_with('-') + || !label + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-') + }) + { + return Err(invalid()); + } + let host = host.to_ascii_lowercase(); + Ok(match port { + None | Some(443) => format!("https://{host}"), + Some(port) => format!("https://{host}:{port}"), + }) +} + +pub(crate) fn download( + origin: &str, + channel: Channel, +) -> Result<(RemoteRevision, VerifiedSnapshot), String> { + download_with(origin, channel, fetch) +} + +fn download_with( + origin: &str, + channel: Channel, + mut fetch: impl FnMut(&str, usize, bool) -> Result, String>, +) -> Result<(RemoteRevision, VerifiedSnapshot), String> { + let base = normalize_origin(origin)?; + let pointer = parse_pointer( + &fetch( + &format!("{base}/v1/channels/{}.json", channel.as_str()), + MAX_POINTER_BYTES, + true, + )?, + channel, + )?; + let bytes = fetch( + &format!("{base}/v1/snapshots/{}.json", pointer.snapshot_sha256), + pointer.snapshot_bytes as usize, + false, + )?; + if bytes.len() as u64 != pointer.snapshot_bytes || sha256(&bytes) != pointer.snapshot_sha256 { + return Err("downloaded registry snapshot does not match the channel size/hash".into()); + } + let snapshot = validate_snapshot(&bytes)?; + let revision = RemoteRevision { + origin: base, + pointer, + commit: snapshot.commit.clone(), + }; + Ok((revision, snapshot)) +} + +fn fetch(url: &str, limit: usize, fresh: bool) -> Result, String> { + let mut command = crate::noninteractive_process::curl_command(); + command.args([ + "--disable", + "--fail", + "--silent", + "--show-error", + "--proto", + "=https", + "--connect-timeout", + "5", + "--max-time", + "30", + "--max-filesize", + &limit.to_string(), + "--header", + "Accept-Encoding: identity", + "--url", + url, + ]); + if fresh { + command.args(["--header", "Cache-Control: no-cache"]); + } + let mut child = command + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| format!("registry download could not start curl: {e}"))?; + let result: Result, String> = (|| { + let stdout = child + .stdout + .as_mut() + .ok_or("registry download has no stdout")?; + read_download(stdout, limit) + })(); + if result.is_err() { + let _ = child.kill(); + } + let status = child + .wait() + .map_err(|e| format!("registry download wait failed: {e}"))?; + let bytes = result?; + if !status.success() { + return Err(format!( + "registry download failed for {url}; active registry is unchanged" + )); + } + Ok(bytes) +} + +fn read_download(reader: impl Read, limit: usize) -> Result, String> { + let mut bytes = Vec::new(); + reader + .take(limit as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|e| e.to_string())?; + if bytes.len() > limit { + return Err("registry download exceeded its byte limit".into()); + } + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + pub(super) fn fixture() -> Vec { + let text = "schema = 1\nid = 'new-agent'\nname = 'New agent'\naliases = []\nstartable = true\n[launch]\nunix = 'new-agent'\nwindows = 'new-agent'\n"; + let path = "agents/new-agent/agent.toml"; + let hash = sha256(text.as_bytes()); + serde_json::to_vec(&json!({ + "schema":1, + "compatibility":{"registry_api":1,"min_detection_engine":3}, + "source":{"repository":"https://github.com/herdrdev/agent-registry","commit":"a".repeat(40),"agents_tree":"b".repeat(40),"dirty":false}, + "content_sha256":sha256(format!("{hash} {path}\n").as_bytes()), + "files":[{"path":path,"bytes":text.len(),"sha256":hash,"text":text}] + })).unwrap() + } + + #[test] + fn publishing_golden_fixture_validates_with_the_real_package_compiler() { + let bytes = include_bytes!("../../scripts/fixtures/agent-registry-snapshot-v1.json"); + let snapshot = validate_snapshot(bytes).unwrap(); + assert_eq!(snapshot.files[0].0, "agents/example/agent.toml"); + assert!(snapshot.files[0].1.contains("café")); + } + + #[test] + fn bounded_download_counts_streamed_bytes_and_preserves_read_errors() { + assert_eq!(read_download(&b"1234"[..], 4).unwrap(), b"1234"); + assert!(read_download(&b"12345"[..], 4) + .unwrap_err() + .contains("byte limit")); + struct Interrupted; + impl Read for Interrupted { + fn read(&mut self, _: &mut [u8]) -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "interrupted response", + )) + } + } + assert!(read_download(Interrupted, 4) + .unwrap_err() + .contains("interrupted response")); + } + + #[test] + fn malicious_inventory_and_download_failure_cannot_pass_validation() { + let mut changed: serde_json::Value = serde_json::from_slice(&fixture()).unwrap(); + let file = &mut changed["files"][0]; + file["path"] = json!("agents/new-agent/../agent.toml"); + let inventory = format!( + "{} {}\n", + file["sha256"].as_str().unwrap(), + file["path"].as_str().unwrap() + ); + changed["content_sha256"] = json!(sha256(inventory.as_bytes())); + assert!(validate_snapshot(&serde_json::to_vec(&changed).unwrap()).is_err()); + let mut duplicate: serde_json::Value = serde_json::from_slice(&fixture()).unwrap(); + let original = duplicate["files"][0].clone(); + duplicate["files"].as_array_mut().unwrap().push(original); + assert!(validate_snapshot(&serde_json::to_vec(&duplicate).unwrap()) + .unwrap_err() + .contains("sorted")); + let failed = download_with(DEFAULT_ORIGIN, Channel::Stable, |_, _, _| { + Err("offline".into()) + }); + assert_eq!(failed.unwrap_err(), "offline"); + let bytes = fixture(); + let pointer = serde_json::to_vec(&ChannelPointer { + schema: 1, + channel: Channel::Stable, + generation: 1, + snapshot_sha256: sha256(&bytes), + snapshot_bytes: bytes.len() as u64, + }) + .unwrap(); + let mut call = 0; + let failed = download_with(DEFAULT_ORIGIN, Channel::Stable, |_, _, _| { + call += 1; + Ok(if call == 1 { + pointer.clone() + } else { + bytes[..bytes.len() - 1].to_vec() + }) + }); + assert!(failed.unwrap_err().contains("size/hash")); + } + + #[test] + fn remote_snapshot_checks_exact_bytes_and_new_identity() { + let bytes = fixture(); + let verified = validate_snapshot(&bytes).unwrap(); + assert_eq!(verified.files[0].0, "agents/new-agent/agent.toml"); + let pointer = ChannelPointer { + schema: 1, + channel: Channel::Staging, + generation: 1, + snapshot_sha256: sha256(&bytes), + snapshot_bytes: bytes.len() as u64, + }; + let pointer_bytes = serde_json::to_vec(&pointer).unwrap(); + let mut calls = 0; + let (revision, downloaded) = + download_with(DEFAULT_ORIGIN, Channel::Staging, |url, limit, fresh| { + calls += 1; + if calls == 1 { + assert_eq!(url, "https://registry.herdr.dev/v1/channels/staging.json"); + assert_eq!(limit, MAX_POINTER_BYTES); + assert!(fresh); + Ok(pointer_bytes.clone()) + } else { + assert_eq!( + url, + format!( + "{DEFAULT_ORIGIN}/v1/snapshots/{}.json", + pointer.snapshot_sha256 + ) + ); + assert_eq!(limit, bytes.len()); + assert!(!fresh, "immutable snapshots may use the CDN cache"); + Ok(bytes.clone()) + } + }) + .unwrap(); + assert_eq!(calls, 2); + assert_eq!(revision.pointer, pointer); + assert_eq!(downloaded.content_sha256, verified.content_sha256); + } + + #[test] + fn equivalent_origins_share_publication_history_identity() { + assert_eq!( + normalize_origin("https://REGISTRY.HERDR.DEV:0443/").unwrap(), + DEFAULT_ORIGIN + ); + assert_eq!( + normalize_origin("https://registry.example:8443/").unwrap(), + "https://registry.example:8443" + ); + for origin in [ + "https://host:0", + "https://host:65536", + "https://host:443:80", + "https://a..example", + "https://-host.example", + ] { + assert!(normalize_origin(origin).is_err()); + } + } + + #[test] + fn rejects_untrusted_urls_and_malformed_snapshot_contracts() { + for origin in [ + "http://registry.herdr.dev", + "https://user@host", + "https://host/path", + "https://host?x", + "file:///test", + "https://host\n", + ] { + assert!(normalize_origin(origin).is_err(), "{origin}"); + } + let original: serde_json::Value = serde_json::from_slice(&fixture()).unwrap(); + for (pointer, replacement) in [ + ("/schema", json!(2)), + ("/compatibility/registry_api", json!(2)), + ("/compatibility/min_detection_engine", json!(999)), + ("/source/dirty", json!(true)), + ("/source/commit", json!("bad")), + ("/files/0/bytes", json!(0)), + ("/files/0/sha256", json!("0".repeat(64))), + ("/content_sha256", json!("0".repeat(64))), + ] { + let mut changed = original.clone(); + *changed.pointer_mut(pointer).unwrap() = replacement; + assert!( + validate_snapshot(&serde_json::to_vec(&changed).unwrap()).is_err(), + "{pointer}" + ); + } + let duplicate = String::from_utf8(fixture()) + .unwrap() + .replacen('{', "{\"schema\":1,", 1); + assert!(validate_snapshot(duplicate.as_bytes()).is_err()); + } + + #[test] + fn pointer_rejects_mismatch_duplicates_and_oversized_envelopes() { + let bytes = br#"{"schema":1,"channel":"stable","generation":1,"snapshot_sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","snapshot_bytes":12}"#; + assert!(parse_pointer(bytes, Channel::Stable).is_ok()); + assert!(parse_pointer(bytes, Channel::Preview).is_err()); + assert!(parse_pointer(&vec![b' '; MAX_POINTER_BYTES + 1], Channel::Stable).is_err()); + let duplicate = + String::from_utf8(bytes.to_vec()) + .unwrap() + .replacen('{', "{\"schema\":1,", 1); + assert!(parse_pointer(duplicate.as_bytes(), Channel::Stable).is_err()); + } +} diff --git a/src/agents/report.rs b/src/agents/report.rs new file mode 100644 index 0000000000..79c7b97685 --- /dev/null +++ b/src/agents/report.rs @@ -0,0 +1,234 @@ +//! Core-only authority for the existing reporter protocols. Registry data cannot grant it. + +use crate::detect::Agent; + +pub(super) fn policy(agent: Agent) -> ReportPolicy { + use ReportAuthority::{FullLifecycle, None as NoAuthority, SessionIdentityOnly}; + use SessionReplacementEvent::{Branch, Clear, Compact, Fork, New, Resume, Select, Startup}; + use SessionReplacementPolicy as Replacement; + + match agent { + Agent::Pi => ReportPolicy::official( + "herdr:pi", + FullLifecycle, + false, + Replacement::events(&[New, Resume, Fork]), + ), + Agent::Claude => ReportPolicy::official( + "herdr:claude", + NoAuthority, + true, + Replacement::events(&[Clear, Resume, Compact]), + ), + Agent::Codex => ReportPolicy::official( + "herdr:codex", + NoAuthority, + true, + Replacement::events(&[Startup, Clear, Resume, Compact]), + ), + Agent::Cursor => { + ReportPolicy::official("herdr:cursor", NoAuthority, true, Replacement::NONE) + } + Agent::Devin => ReportPolicy::official("herdr:devin", NoAuthority, true, Replacement::NONE), + Agent::Antigravity => ReportPolicy::official( + "herdr:antigravity_cli", + SessionIdentityOnly, + false, + Replacement::missing_event(), + ), + Agent::Omp => ReportPolicy::official( + "herdr:omp", + FullLifecycle, + false, + Replacement::events(&[Startup, New, Resume, Fork]), + ), + Agent::Mastracode => ReportPolicy::official( + "herdr:mastracode", + FullLifecycle, + false, + Replacement::events(&[Startup]), + ) + .with_initial_lifecycle_session_replacement(), + Agent::OpenCode => ReportPolicy::official( + "herdr:opencode", + FullLifecycle, + false, + Replacement::events(&[Select]).with_unsequenced_event(Select), + ), + Agent::GithubCopilot => { + ReportPolicy::official("herdr:copilot", NoAuthority, true, Replacement::NONE) + } + Agent::Kimi => { + ReportPolicy::official("herdr:kimi", FullLifecycle, false, Replacement::NONE) + } + Agent::Droid => ReportPolicy::official("herdr:droid", NoAuthority, true, Replacement::NONE), + Agent::Grok => ReportPolicy::official("herdr:grok", NoAuthority, true, Replacement::NONE), + Agent::Hermes => ReportPolicy::official( + "herdr:hermes", + SessionIdentityOnly, + false, + Replacement::events(&[Startup, New, Resume]), + ), + Agent::Kilo => { + ReportPolicy::official("herdr:kilo", FullLifecycle, false, Replacement::NONE) + } + Agent::Qodercli => { + ReportPolicy::official("herdr:qodercli", NoAuthority, true, Replacement::NONE) + } + Agent::Qwen => ReportPolicy::official( + "herdr:qwen", + SessionIdentityOnly, + true, + Replacement::events(&[Startup, Clear, Resume, Compact, Branch]), + ), + _ => ReportPolicy::NONE, + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ReportAuthority { + None, + FullLifecycle, + SessionIdentityOnly, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum SessionReplacementEvent { + Startup, + Clear, + Resume, + Compact, + Branch, + New, + Fork, + Select, +} + +impl SessionReplacementEvent { + fn parse(value: &str) -> Option { + match value { + "startup" => Some(Self::Startup), + "clear" => Some(Self::Clear), + "resume" => Some(Self::Resume), + "compact" => Some(Self::Compact), + "branch" => Some(Self::Branch), + "new" => Some(Self::New), + "fork" => Some(Self::Fork), + "select" => Some(Self::Select), + _ => None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct SessionReplacementPolicy { + events: &'static [SessionReplacementEvent], + allows_missing_event: bool, + unsequenced_event: Option, +} + +impl SessionReplacementPolicy { + pub(super) const NONE: Self = Self { + events: &[], + allows_missing_event: false, + unsequenced_event: None, + }; + + pub(super) const fn events(events: &'static [SessionReplacementEvent]) -> Self { + Self { + events, + allows_missing_event: false, + unsequenced_event: None, + } + } + + pub(super) const fn missing_event() -> Self { + Self { + events: &[], + allows_missing_event: true, + unsequenced_event: None, + } + } + + pub(super) const fn with_unsequenced_event(mut self, event: SessionReplacementEvent) -> Self { + self.unsequenced_event = Some(event); + self + } + + fn allows(self, event: Option<&str>) -> bool { + match event { + Some(event) => SessionReplacementEvent::parse(event) + .is_some_and(|event| self.events.contains(&event)), + None => self.allows_missing_event, + } + } + + fn allows_unsequenced(self, event: Option<&str>) -> bool { + event + .and_then(SessionReplacementEvent::parse) + .is_some_and(|event| self.unsequenced_event == Some(event)) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ReportPolicy { + official_source: Option<&'static str>, + authority: ReportAuthority, + reserved_native_state: bool, + session_replacement: SessionReplacementPolicy, + initial_lifecycle_report_replaces_session: bool, +} + +impl ReportPolicy { + pub(super) const NONE: Self = Self { + official_source: None, + authority: ReportAuthority::None, + reserved_native_state: false, + session_replacement: SessionReplacementPolicy::NONE, + initial_lifecycle_report_replaces_session: false, + }; + + pub(super) const fn official( + source: &'static str, + authority: ReportAuthority, + reserved_native_state: bool, + session_replacement: SessionReplacementPolicy, + ) -> Self { + Self { + official_source: Some(source), + authority, + reserved_native_state, + session_replacement, + initial_lifecycle_report_replaces_session: false, + } + } + + pub(super) const fn with_initial_lifecycle_session_replacement(mut self) -> Self { + self.initial_lifecycle_report_replaces_session = true; + self + } + + pub(crate) const fn official_source(self) -> Option<&'static str> { + self.official_source + } + + pub(crate) const fn authority(self) -> ReportAuthority { + self.authority + } + + pub(crate) const fn reserves_native_state(self) -> bool { + self.reserved_native_state + } + + pub(crate) fn allows_session_replacement(self, event: Option<&str>) -> bool { + self.session_replacement.allows(event) + } + + pub(crate) fn allows_unsequenced_session_replacement(self, event: Option<&str>) -> bool { + self.session_replacement.allows_unsequenced(event) + } + + pub(crate) const fn initial_lifecycle_report_replaces_session(self) -> bool { + self.initial_lifecycle_report_replaces_session + } +} diff --git a/src/agents/session.rs b/src/agents/session.rs new file mode 100644 index 0000000000..0f2103e8fa --- /dev/null +++ b/src/agents/session.rs @@ -0,0 +1,205 @@ +//! Session selection and closed argv construction for loaded resume profiles. + +pub(crate) use super::source::ResumeDefinition as SessionProfile; +use super::source::{ReferenceKind, ResumeOptionsDefinition, ResumeStrategy}; + +pub(crate) const MAX_RESUME_OPTION_ARGS: usize = 128; +pub(crate) const MAX_RESUME_OPTION_BYTES: usize = 16 * 1024; +const MAX_RESUME_OPTION_VALUE_BYTES: usize = 4096; + +pub(crate) fn deserialize_resume_options<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + struct OptionsVisitor; + impl<'de> serde::de::Visitor<'de> for OptionsVisitor { + type Value = Vec; + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("bounded resume arguments") + } + fn visit_seq>( + self, + mut seq: A, + ) -> Result { + let mut args = Vec::new(); + let mut bytes = 0; + while let Some(arg) = seq.next_element::()? { + bytes += arg.len(); + if args.len() >= MAX_RESUME_OPTION_ARGS + || bytes > MAX_RESUME_OPTION_BYTES + || arg.len() > MAX_RESUME_OPTION_VALUE_BYTES + || arg.contains('\0') + { + return Err(serde::de::Error::custom("resume options exceed limits")); + } + args.push(arg); + } + Ok(args) + } + } + deserializer.deserialize_seq(OptionsVisitor) +} + +pub(crate) fn reserved_resume_option(name: &str) -> bool { + matches!( + name, + "-r" | "--resume" + | "--session" + | "--session-id" + | "--thread" + | "--conversation" + | "--continue" + ) +} + +impl ResumeOptionsDefinition { + pub(crate) fn argument_count(&self, args: &[String]) -> usize { + let Some(arg) = args.first() else { return 0 }; + if reserved_resume_option(arg.split_once('=').map_or(arg.as_str(), |(name, _)| name)) { + return 0; + } + if self.flags.contains(arg) { + 1 + } else if let Some((name, value)) = arg.split_once('=') { + usize::from(!value.is_empty() && self.options.iter().any(|option| option == name)) + } else if self.options.contains(arg) + && args.get(1).is_some_and(|value| !value.starts_with('-')) + { + 2 + } else { + 0 + } + } + + pub(crate) fn filter(&self, args: &[String]) -> Vec { + let mut kept = Vec::new(); + let mut bytes = 0; + let mut index = 0; + while let Some(arg) = args.get(index) { + if arg == "--" { + break; + } + let name = arg.split_once('=').map_or(arg.as_str(), |(name, _)| name); + if reserved_resume_option(name) { + index += 1; + if name == arg + && name != "--continue" + && args.get(index).is_some_and(|value| !value.starts_with('-')) + { + index += 1; + } + continue; + } + let count = self.argument_count(&args[index..]); + if count == 0 { + index += 1; + continue; + } + let group = &args[index..index + count]; + let group_bytes: usize = group.iter().map(String::len).sum(); + if kept.len() + count > MAX_RESUME_OPTION_ARGS + || bytes + group_bytes > MAX_RESUME_OPTION_BYTES + || group.iter().any(|value| { + value.len() > MAX_RESUME_OPTION_VALUE_BYTES || value.contains('\0') + }) + { + return Vec::new(); + } + kept.extend_from_slice(group); + bytes += group_bytes; + index += count; + } + kept + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn strings(args: &[&str]) -> Vec { + args.iter().map(|arg| (*arg).to_owned()).collect() + } + + #[test] + fn resume_options_keep_declared_choices_not_prompts_or_session_selectors() { + let policy = ResumeOptionsDefinition { + flags: strings(&["--yolo", "--continue"]), + options: strings(&["--model", "--resume", "-r"]), + }; + assert_eq!( + policy.filter(&strings(&[ + "--yolo", + "--model", + "model name", + "--resume", + "old", + "--resume=old", + "-r", + "old", + "--continue", + "--unknown", + "prompt", + "--model=other", + ])), + strings(&["--yolo", "--model", "model name", "--model=other"]) + ); + assert!(policy + .filter(&strings(&["--model", "--", "--yolo"])) + .is_empty()); + assert_eq!( + policy.filter(&strings(&["--model", ""])), + strings(&["--model", ""]) + ); + } + + #[test] + fn resume_options_bounds_never_truncate_a_pair_or_keep_lossy_values() { + let policy = ResumeOptionsDefinition { + flags: vec![], + options: strings(&["--model"]), + }; + for value in [ + "x".repeat(MAX_RESUME_OPTION_VALUE_BYTES + 1), + "nul\0value".into(), + ] { + assert!(policy.filter(&["--model".into(), value]).is_empty()); + } + let args = strings(&["--model", "ok"].repeat(MAX_RESUME_OPTION_ARGS)); + assert!(policy.filter(&args).is_empty()); + let mut args = vec!["long prompt ".repeat(MAX_RESUME_OPTION_BYTES)]; + args.extend(strings(&["--model", "ok"])); + assert_eq!(policy.filter(&args), strings(&["--model", "ok"])); + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ReportReferencePreference { + IdOnly, + AbsolutePathThenId, +} + +impl SessionProfile { + pub(crate) fn accepts_id(&self) -> bool { + self.accepted_references.contains(&ReferenceKind::Id) + } + + pub(crate) fn accepts_path(&self) -> bool { + self.accepted_references.contains(&ReferenceKind::Path) + } + + pub(crate) fn report_preference(&self) -> ReportReferencePreference { + match self.preferred_reference { + ReferenceKind::Id => ReportReferencePreference::IdOnly, + ReferenceKind::Path => ReportReferencePreference::AbsolutePathThenId, + } + } + + pub(crate) fn argv(&self, executable: &str, value: &str) -> Vec { + match self.strategy { + ResumeStrategy::SeparateFlag | ResumeStrategy::Subcommand => { + vec![executable.into(), self.token.clone(), value.into()] + } + ResumeStrategy::JoinedFlag => vec![executable.into(), format!("{}{value}", self.token)], + } + } +} diff --git a/src/agents/source.rs b/src/agents/source.rs new file mode 100644 index 0000000000..12e343a419 --- /dev/null +++ b/src/agents/source.rs @@ -0,0 +1,1080 @@ +//! Core-independent, bounded parser for the agent source repository. +//! This module deliberately does not bind identities to compiled agents or installers. +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{de::DeserializeOwned, Deserialize}; + +#[derive(Debug, Clone)] +pub(crate) struct Package { + pub(crate) identity: Identity, + pub(crate) process: Option, + pub(crate) resume: Option, + pub(crate) integration: Option, + pub(crate) assets: BTreeMap, + pub(crate) detection: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Identity { + pub(crate) schema: u32, + pub(crate) id: String, + pub(crate) name: String, + pub(crate) aliases: Vec, + pub(crate) startable: bool, + pub(crate) launch: LaunchDefinition, + pub(crate) sound: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct LaunchDefinition { + pub(crate) unix: String, + pub(crate) windows: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct SoundDefinition { + pub(crate) key: String, + pub(crate) default: SoundDefault, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ProcessDefinition { + pub(crate) names: Vec, + #[serde(default)] + pub(crate) secondary_runtime_argv_fallback: bool, + pub(crate) versioned_basename_prefix: Option, + #[serde(default)] + pub(crate) package_paths: Vec, + pub(crate) bundled_node: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct PackagePath { + pub(crate) kind: PackageMatch, + pub(crate) components: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct BundledNodeDefinition { + pub(crate) runtime_basename: String, + pub(crate) entrypoint_basename: String, + pub(crate) package_directory: String, + pub(crate) versions_directory: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ResumeDefinition { + pub(crate) accepted_references: Vec, + pub(crate) preferred_reference: ReferenceKind, + pub(crate) strategy: ResumeStrategy, + pub(crate) token: String, + #[serde(default)] + pub(crate) resume_options: ResumeOptionsDefinition, +} + +#[derive(Debug, Default, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ResumeOptionsDefinition { + #[serde(default)] + pub(crate) flags: Vec, + #[serde(default)] + pub(crate) options: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct IntegrationDefinition { + pub(crate) cli_name: String, + pub(crate) aliases: Vec, + pub(crate) commands: PlatformCommands, + pub(crate) supported: PlatformSupport, + pub(crate) versions: PlatformVersions, + pub(crate) assets: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct PlatformCommands { + pub(crate) unix: Vec, + pub(crate) windows: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct PlatformSupport { + pub(crate) unix: bool, + pub(crate) windows: bool, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct PlatformVersions { + pub(crate) unix: u32, + pub(crate) windows: u32, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct AssetDefinition { + pub(crate) path: String, + pub(crate) platform: String, + pub(crate) role: String, + pub(crate) install_name: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum SoundDefault { + Default, + Off, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum PackageMatch { + NormalizedComponents, + ExactSuffix, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ReferenceKind { + Id, + Path, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ResumeStrategy { + SeparateFlag, + JoinedFlag, + Subcommand, +} + +const MAX_FILES: usize = 4096; +const MAX_PACKAGES: usize = 256; +const MAX_TOML_BYTES: usize = 256 * 1024; +const MAX_ASSET_BYTES: usize = 1024 * 1024; +const MAX_TOTAL_BYTES: usize = 32 * 1024 * 1024; +const MAX_LIST: usize = 64; + +fn ensure(ok: bool, message: impl Into) -> Result<(), String> { + if ok { + Ok(()) + } else { + Err(message.into()) + } +} + +fn canonical_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value.as_bytes()[0].is_ascii_lowercase() + && value + .bytes() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-') +} + +fn lookup_name(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value.trim() == value + && !value.contains(" ") + && value + .bytes() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || b"-_ ".contains(&c)) + && value.bytes().any(|c| c.is_ascii_lowercase()) +} + +/// Safe on both platforms, including Windows device names and trailing-dot rules. +fn basename(value: &str) -> bool { + if value.is_empty() + || value.len() > 128 + || value == "." + || value == ".." + || value.ends_with('.') + || !value + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"-_.@".contains(&c)) + { + return false; + } + let stem = value.split('.').next().unwrap_or("").to_ascii_lowercase(); + !matches!(stem.as_str(), "con" | "prn" | "aux" | "nul" | "clock$") + && !(stem.len() == 4 + && (stem.starts_with("com") || stem.starts_with("lpt")) + && matches!(stem.as_bytes()[3], b'1'..=b'9')) +} + +fn asset_path(path: &str) -> bool { + path.strip_prefix("assets/").is_some_and(basename) +} + +fn parse(text: &str, path: &str) -> Result { + toml::from_str(text).map_err(|error| format!("{path}: {error}")) +} + +fn claim(names: &mut BTreeSet, value: &str, namespace: &str) -> Result<(), String> { + ensure( + lookup_name(value), + format!("invalid {namespace} name: {value}"), + )?; + ensure( + names.insert(value.to_owned()), + format!("duplicate {namespace} name: {value}"), + ) +} + +/// `files` contains UTF-8 text and paths relative to the source root. No filesystem +/// access, enum lookup, detection evaluation, or executable asset evaluation occurs. +pub(crate) fn load_packages(files: &[(&str, &str)]) -> Result, String> { + ensure( + !files.is_empty() && files.len() <= MAX_FILES, + "invalid source file count", + )?; + let mut total = 0usize; + let mut directories: BTreeMap<&str, BTreeMap<&str, &str>> = BTreeMap::new(); + // Validate all paths and input bounds before invoking the TOML parser. + for &(path, text) in files { + ensure(path.len() <= 256, "source path too long")?; + let parts: Vec<_> = path.split('/').collect(); + ensure( + parts.len() >= 3 + && parts[0] == "agents" + && canonical_id(parts[1]) + && basename(parts[1]), + format!("invalid source path: {path}"), + )?; + let relative = path + .strip_prefix(&format!("agents/{}/", parts[1])) + .ok_or_else(|| format!("invalid source path: {path}"))?; + let is_asset = parts.len() == 4 && asset_path(relative); + ensure( + is_asset + || (parts.len() == 3 + && matches!( + parts[2], + "agent.toml" + | "process.toml" + | "resume.toml" + | "integration.toml" + | "detection.toml" + )), + format!("unrecognized or unsafe source path: {path}"), + )?; + ensure( + text.len() + <= if is_asset { + MAX_ASSET_BYTES + } else { + MAX_TOML_BYTES + }, + format!("source file too large: {path}"), + )?; + total = total + .checked_add(text.len()) + .ok_or("source size overflow")?; + ensure(total <= MAX_TOTAL_BYTES, "source exceeds total size limit")?; + ensure(!text.contains('\0'), format!("NUL in source file: {path}"))?; + let directory = directories.entry(parts[1]).or_default(); + ensure( + directory.insert(relative, text).is_none(), + format!("duplicate source path: {path}"), + )?; + ensure( + directories.len() <= MAX_PACKAGES, + "too many source packages", + )?; + } + let mut identities = BTreeSet::new(); + let mut processes = BTreeSet::new(); + let mut sounds = BTreeSet::new(); + let mut integrations = BTreeSet::new(); + let mut prefixes = BTreeSet::new(); + let mut paths = BTreeSet::new(); + let mut bundled_nodes = BTreeSet::new(); + let mut packages = Vec::new(); + for (directory, files) in directories { + let context = |file: &str| format!("agents/{directory}/{file}"); + let agent = files + .get("agent.toml") + .ok_or_else(|| format!("{directory}: missing agent.toml"))?; + let identity: Identity = parse(agent, &context("agent.toml"))?; + ensure(identity.schema == 1, "unsupported agent schema")?; + ensure( + canonical_id(&identity.id) && identity.id == directory, + "agent id must equal package directory", + )?; + ensure( + !identity.name.trim().is_empty() + && identity.name.len() <= 128 + && identity.name.trim() == identity.name + && !identity.name.chars().any(char::is_control), + "invalid agent name", + )?; + ensure(identity.aliases.len() <= MAX_LIST, "too many aliases")?; + claim(&mut identities, &identity.id, "identity")?; + for alias in &identity.aliases { + claim(&mut identities, alias, "identity")?; + } + ensure( + basename(&identity.launch.unix) && basename(&identity.launch.windows), + "unsafe launch executable", + )?; + if let Some(sound) = &identity.sound { + ensure(!sound.key.contains(' '), "invalid sound key")?; + claim(&mut sounds, &sound.key, "sound")?; + } + let process: Option = files + .get("process.toml") + .map(|text| parse(text, &context("process.toml"))) + .transpose()?; + if let Some(process) = &process { + ensure( + !process.names.is_empty() && process.names.len() <= MAX_LIST, + "invalid process name count", + )?; + for name in &process.names { + ensure( + name.len() <= 128 && lookup_name(name.strip_prefix('.').unwrap_or(name)), + format!("invalid process name: {name}"), + )?; + ensure( + processes.insert(name.clone()), + format!("duplicate process name: {name}"), + )?; + } + if let Some(prefix) = &process.versioned_basename_prefix { + ensure( + basename(prefix) + && prefix.ends_with('-') + && prefix == &prefix.to_ascii_lowercase(), + "invalid versioned basename prefix", + )?; + ensure( + prefixes.insert(prefix.clone()), + "duplicate versioned basename prefix", + )?; + } + ensure( + process.package_paths.len() <= MAX_LIST, + "too many package paths", + )?; + for path in &process.package_paths { + ensure( + !path.components.is_empty() + && path.components.len() <= 32 + && path.components.iter().all(|c| basename(c)), + "unsafe package path components", + )?; + ensure( + paths.insert(( + path.kind, + path.components + .iter() + .map(|c| c.to_ascii_lowercase()) + .collect::>(), + )), + "duplicate package path", + )?; + } + if let Some(node) = &process.bundled_node { + ensure( + [ + &node.runtime_basename, + &node.entrypoint_basename, + &node.package_directory, + &node.versions_directory, + ] + .iter() + .all(|s| basename(s)), + "unsafe bundled node layout", + )?; + ensure( + bundled_nodes.insert([ + node.runtime_basename.to_ascii_lowercase(), + node.entrypoint_basename.to_ascii_lowercase(), + node.package_directory.to_ascii_lowercase(), + node.versions_directory.to_ascii_lowercase(), + ]), + "duplicate bundled node layout", + )?; + } + } + let resume: Option = files + .get("resume.toml") + .map(|text| parse(text, &context("resume.toml"))) + .transpose()?; + if let Some(resume) = &resume { + validate_resume(resume)?; + } + let integration: Option = files + .get("integration.toml") + .map(|text| parse(text, &context("integration.toml"))) + .transpose()?; + if let Some(integration) = &integration { + ensure( + integration.aliases.len() <= MAX_LIST, + "too many integration aliases", + )?; + claim(&mut integrations, &integration.cli_name, "integration")?; + for alias in &integration.aliases { + claim(&mut integrations, alias, "integration")?; + } + validate_integration(&identity.id, integration, &files)?; + } else { + ensure( + !files.keys().any(|p| p.starts_with("assets/")), + "assets require integration.toml", + )?; + } + let detection = files + .get("detection.toml") + .map(|text| { + let _: toml::Table = parse(text, &context("detection.toml"))?; + Ok::<_, String>((*text).to_owned()) + }) + .transpose()?; + let assets = files + .iter() + .filter(|(path, _)| path.starts_with("assets/")) + .map(|(path, text)| ((*path).to_owned(), (*text).to_owned())) + .collect(); + packages.push(Package { + identity, + process, + resume, + integration, + assets, + detection, + }); + } + // Prefix recognition requires a following digit; reject exact names and + // longer prefixes that would recognize the same versioned basename. + for prefix in &prefixes { + let matches_prefix = |name: &str| { + name.strip_prefix(prefix.as_str()) + .is_some_and(|tail| tail.as_bytes().first().is_some_and(u8::is_ascii_digit)) + }; + ensure( + !processes.iter().any(|name| matches_prefix(name)), + "versioned basename prefix overlaps process name", + )?; + ensure( + !prefixes.iter().any(|other| matches_prefix(other)), + "overlapping versioned basename prefixes", + )?; + } + Ok(packages) +} + +fn validate_resume(resume: &ResumeDefinition) -> Result<(), String> { + let refs = &resume.accepted_references; + ensure( + !refs.is_empty() + && refs.len() <= 2 + && refs.iter().collect::>().len() == refs.len(), + "invalid accepted resume references", + )?; + ensure( + refs.contains(&resume.preferred_reference), + "preferred resume reference must be accepted", + )?; + let word = |s: &str| { + !s.is_empty() + && s.len() <= 64 + && s.as_bytes()[0].is_ascii_lowercase() + && s.bytes() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-') + }; + let valid = match resume.strategy { + ResumeStrategy::SeparateFlag => resume.token.strip_prefix("--").is_some_and(word), + ResumeStrategy::JoinedFlag => resume + .token + .strip_prefix("--") + .and_then(|s| s.strip_suffix('=')) + .is_some_and(word), + ResumeStrategy::Subcommand => word(&resume.token), + }; + ensure(valid, "unknown resume strategy or unsafe token")?; + let policy = &resume.resume_options; + ensure( + policy.flags.len() + policy.options.len() <= MAX_LIST, + "too many resume options", + )?; + let mut names = BTreeSet::new(); + for name in policy.flags.iter().chain(&policy.options) { + ensure( + name.len() <= 64 + && name + .strip_prefix("--") + .or_else(|| name.strip_prefix('-')) + .is_some_and(word) + && !super::session::reserved_resume_option(name) + && name != resume.token.trim_end_matches('=') + && names.insert(name), + "invalid, duplicate or reserved resume option", + )?; + } + Ok(()) +} + +fn validate_integration( + id: &str, + integration: &IntegrationDefinition, + files: &BTreeMap<&str, &str>, +) -> Result<(), String> { + ensure( + integration.supported.unix || integration.supported.windows, + "integration supports no platforms", + )?; + ensure( + !integration.assets.is_empty() && integration.assets.len() <= MAX_LIST, + "invalid integration asset count", + )?; + for (supported, commands, version) in [ + ( + integration.supported.unix, + &integration.commands.unix, + integration.versions.unix, + ), + ( + integration.supported.windows, + &integration.commands.windows, + integration.versions.windows, + ), + ] { + ensure( + commands.len() <= MAX_LIST + && commands.iter().all(|c| basename(c)) + && commands + .iter() + .map(|c| c.to_ascii_lowercase()) + .collect::>() + .len() + == commands.len(), + "invalid integration commands", + )?; + ensure( + if supported { + !commands.is_empty() && version > 0 + } else { + commands.is_empty() && version == 0 + }, + "integration platform support, commands and versions disagree", + )?; + } + let mut referenced = BTreeSet::new(); + let mut roles = BTreeSet::new(); + let mut installed = BTreeSet::new(); + for asset in &integration.assets { + ensure( + asset_path(&asset.path) && basename(&asset.install_name), + "unsafe integration asset path or install name", + )?; + ensure( + matches!(asset.role.as_str(), "reporter" | "tui" | "manifest"), + "unknown integration asset role", + )?; + ensure( + matches!(asset.platform.as_str(), "all" | "unix" | "windows"), + "unknown integration asset platform", + )?; + let text = files + .get(asset.path.as_str()) + .ok_or_else(|| format!("{id}: missing asset {}", asset.path))?; + referenced.insert(asset.path.as_str()); + let marker_id = if id == "agy" { + "antigravity_cli".to_owned() + } else if asset.role == "tui" { + format!("{id}-tui") + } else { + id.to_owned() + }; + let ids = markers(text, "HERDR_INTEGRATION_ID="); + ensure( + ids.len() <= 1 && ids.iter().all(|value| *value == marker_id), + "integration asset ID marker mismatch", + )?; + let versions = markers(text, "HERDR_INTEGRATION_VERSION="); + ensure(versions.len() <= 1, "duplicate integration version markers")?; + for (platform, supported, version) in [ + ( + "unix", + integration.supported.unix, + integration.versions.unix, + ), + ( + "windows", + integration.supported.windows, + integration.versions.windows, + ), + ] { + if asset.platform != "all" && asset.platform != platform { + continue; + } + ensure(supported, "asset targets unsupported platform")?; + ensure( + roles.insert((platform, asset.role.as_str())), + "duplicate asset role on platform", + )?; + ensure( + installed.insert((platform, asset.install_name.to_ascii_lowercase())), + "duplicate asset install name on platform", + )?; + for value in &versions { + ensure( + value.parse::().ok() == Some(version), + "integration asset version marker mismatch", + )?; + } + } + } + ensure( + files + .keys() + .filter(|path| path.starts_with("assets/")) + .all(|path| referenced.contains(path)), + "unreferenced integration asset", + )?; + for (platform, supported) in [ + ("unix", integration.supported.unix), + ("windows", integration.supported.windows), + ] { + ensure( + !supported || roles.contains(&(platform, "reporter")), + "supported integration platform requires reporter asset", + )?; + } + Ok(()) +} + +pub(super) fn markers<'a>(text: &'a str, key: &str) -> Vec<&'a str> { + text.lines() + .filter_map(|line| { + let line = line.trim(); + let comment = line.strip_prefix('#').or_else(|| line.strip_prefix("//"))?; + comment.trim().strip_prefix(key).map(str::trim) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + const AGENT: &str = "schema = 1\nid = 'future-agent'\nname = 'Future agent'\naliases = ['future agent']\nstartable = true\n[launch]\nunix = 'future-agent'\nwindows = 'future-agent.cmd'\n"; + const INTEGRATION: &str = "cli_name = 'future-agent'\naliases = []\n[commands]\nunix = ['future-agent']\nwindows = ['future-agent']\n[supported]\nunix = true\nwindows = true\n[versions]\nunix = 2\nwindows = 2\n[[assets]]\npath = 'assets/reporter.js'\nplatform = 'all'\nrole = 'reporter'\ninstall_name = 'reporter.js'\n"; + + #[test] + fn unknown_identity_and_missing_optional_capabilities_work() { + let packages = load_packages(&[("agents/future-agent/agent.toml", AGENT)]).unwrap(); + let package = &packages[0]; + assert_eq!(package.identity.id, "future-agent"); + assert!( + package.process.is_none() + && package.resume.is_none() + && package.integration.is_none() + && package.detection.is_none() + ); + } + + #[test] + fn hidden_process_names_do_not_widen_identity_or_sound_names() { + let process = "names = ['future-agent', '.future-agent']"; + let packages = load_packages(&[ + ("agents/future-agent/agent.toml", AGENT), + ("agents/future-agent/process.toml", process), + ]) + .unwrap(); + assert_eq!( + packages[0].process.as_ref().unwrap().names, + ["future-agent", ".future-agent"] + ); + for name in [ + ".", + "..future-agent", + "../future-agent", + &format!(".{}", "a".repeat(128)), + ] { + assert!(load_packages(&[ + ("agents/future-agent/agent.toml", AGENT), + ( + "agents/future-agent/process.toml", + &format!("names = ['{name}']") + ), + ]) + .is_err()); + } + for identity in [ + AGENT.replace("['future agent']", "['.future-agent']"), + format!("{AGENT}\n[sound]\nkey = '.future-agent'\ndefault = 'default'\n"), + ] { + assert!(load_packages(&[("agents/future-agent/agent.toml", &identity)]).is_err()); + } + } + + #[test] + fn rejects_unknown_fields_even_in_nested_structs() { + for extra in ["extra = true\n", "authority = 'full'\n"] { + assert!(load_packages(&[( + "agents/future-agent/agent.toml", + &format!("{AGENT}{extra}") + )]) + .is_err()); + } + assert!(load_packages(&[ + ("agents/future-agent/agent.toml", AGENT), + ( + "agents/future-agent/process.toml", + "names = ['future-agent']\nadapter = 'magic'" + ) + ]) + .is_err()); + } + + #[test] + fn rejects_missing_identity_and_unsafe_paths() { + for path in [ + "agents/future-agent/../agent.toml", + "/agents/future-agent/agent.toml", + "agents\\future-agent\\agent.toml", + "agents/future-agent/other.toml", + "agents/future-agent/assets/../x", + "agents/future-agent/assets/CON.txt", + "agents/con/agent.toml", + ] { + assert!(load_packages(&[(path, AGENT)]).is_err(), "{path}"); + } + assert!(load_packages(&[( + "agents/future-agent/process.toml", + "names = ['future-agent']" + )]) + .is_err()); + assert!(load_packages(&[("agents/other/agent.toml", AGENT)]).is_err()); + for executable in ["../future", "C:\\evil.exe", "NUL", "future.", "x;sh"] { + assert!(load_packages(&[( + "agents/future-agent/agent.toml", + &AGENT.replace("'future-agent.cmd'", &format!("'{executable}'")) + )]) + .is_err()); + } + } + + #[test] + fn namespace_collisions_are_rejected_but_namespaces_are_independent() { + let other = AGENT.replace("future-agent", "other"); + assert!(load_packages(&[ + ("agents/future-agent/agent.toml", AGENT), + ("agents/other/agent.toml", &other) + ]) + .is_err()); + let other = other.replace("future agent", "other alias"); + let files = [ + ("agents/future-agent/agent.toml", AGENT), + ("agents/other/agent.toml", &other), + ("agents/future-agent/process.toml", "names = ['other']"), + ]; + assert!(load_packages(&files).is_ok()); + let mut files = files.to_vec(); + files.push(("agents/other/process.toml", "names = ['other']")); + assert!(load_packages(&files).is_err()); + } + + #[test] + fn resume_uses_closed_reference_and_argv_forms() { + for (strategy, token, valid) in [ + ("separate_flag", "--resume", true), + ("joined_flag", "--resume=", true), + ("subcommand", "resume", true), + ("shell", "resume", false), + ("joined_flag", "--resume", false), + ("separate_flag", "--resume=", false), + ("subcommand", "resume;sh", false), + ] { + let text = format!("accepted_references = ['id']\npreferred_reference = 'id'\nstrategy = '{strategy}'\ntoken = '{token}'"); + assert_eq!( + load_packages(&[ + ("agents/future-agent/agent.toml", AGENT), + ("agents/future-agent/resume.toml", &text) + ]) + .is_ok(), + valid + ); + } + } + + #[test] + fn resume_options_policy_is_bounded_unique_and_cannot_own_session_selection() { + let base = "accepted_references = ['id']\npreferred_reference = 'id'\nstrategy = 'joined_flag'\ntoken = '--restore='\n"; + for (policy, valid) in [ + ("", true), + ( + "[resume_options]\nflags=['--yolo','-f']\noptions=['--model']", + true, + ), + ( + "[resume_options]\nflags=['--yolo']\noptions=['--yolo']", + false, + ), + ("[resume_options]\noptions=['--restore']", false), + ("[resume_options]\nflags=['--continue']", false), + ("[resume_options]\noptions=['-r']", false), + ("[resume_options]\noptions=['--model=value']", false), + ("[resume_options]\nflags=['--']", false), + ("[resume_options]\nunknown=[]", false), + ] { + let text = format!("{base}{policy}"); + assert_eq!( + load_packages(&[ + ("agents/future-agent/agent.toml", AGENT), + ("agents/future-agent/resume.toml", &text), + ]) + .is_ok(), + valid, + "{policy}" + ); + } + let mut resume: ResumeDefinition = toml::from_str(base).unwrap(); + resume.resume_options.flags = (0..=MAX_LIST).map(|i| format!("--flag-{i}")).collect(); + assert!(validate_resume(&resume).is_err()); + } + + #[test] + fn validates_asset_markers_references_and_roles() { + let good = "// HERDR_INTEGRATION_ID=future-agent\n// HERDR_INTEGRATION_VERSION=2\n"; + let test = |definition: &str, asset: &str| { + load_packages(&[ + ("agents/future-agent/agent.toml", AGENT), + ("agents/future-agent/integration.toml", definition), + ("agents/future-agent/assets/reporter.js", asset), + ]) + }; + assert!(test(INTEGRATION, good).is_ok()); + assert!(test(INTEGRATION, &good.replace("VERSION=2", "VERSION=3")).is_err()); + assert!(test(INTEGRATION, &good.replace("ID=future-agent", "ID=other")).is_err()); + assert!(test( + &INTEGRATION.replace("assets/reporter.js", "assets/missing.js"), + good + ) + .is_err()); + assert!(test( + &INTEGRATION.replace("assets/reporter.js", "../reporter.js"), + good + ) + .is_err()); + assert!(test( + &format!( + "{INTEGRATION}{}", + INTEGRATION + .split("[[assets]]") + .nth(1) + .map(|s| format!("[[assets]]{s}")) + .unwrap() + ), + good + ) + .is_err()); + assert!(test(&INTEGRATION.replace("windows = 2", "windows = 3"), good).is_err()); + } + + #[test] + fn rejects_bad_identity_schema_aliases_and_sound_policy() { + for text in [ + AGENT.replace("schema = 1", "schema = 2"), + AGENT.replace("future agent", "Future Agent"), + AGENT.replace("future agent", " future agent"), + AGENT.replace("future agent", "future agent"), + AGENT.replace("['future agent']", "['future agent', 'future agent']"), + format!("{AGENT}\n[sound]\nkey = 'future'\ndefault = 'custom'"), + ] { + assert!(load_packages(&[("agents/future-agent/agent.toml", &text)]).is_err()); + } + } + + #[test] + fn sound_and_integration_lookup_collisions_are_rejected() { + let first = format!("{AGENT}\n[sound]\nkey = 'shared'\ndefault = 'off'"); + let second = first + .replace("future-agent", "other") + .replace("future agent", "other alias"); + assert!(load_packages(&[ + ("agents/future-agent/agent.toml", &first), + ("agents/other/agent.toml", &second) + ]) + .unwrap_err() + .contains("duplicate sound")); + let second = second.replace("key = 'shared'", "key = 'other'"); + let files = [ + ("agents/future-agent/agent.toml", first.as_str()), + ("agents/other/agent.toml", second.as_str()), + ("agents/future-agent/integration.toml", INTEGRATION), + ("agents/future-agent/assets/reporter.js", "// reporter"), + ("agents/other/integration.toml", INTEGRATION), + ("agents/other/assets/reporter.js", "// reporter"), + ]; + assert!(load_packages(&files) + .unwrap_err() + .contains("duplicate integration")); + } + + #[test] + fn process_semantics_and_nested_fields_are_closed() { + for text in [ + "names = []", + "names = ['future', 'future']", + "names = ['Future']", + "names = ['future']\nversioned_basename_prefix = '../future-'", + "names = ['future']\n[[package_paths]]\nkind = 'glob'\ncomponents = ['future']", + "names = ['future']\n[[package_paths]]\nkind = 'exact_suffix'\ncomponents = ['..']", + "names = ['future']\n[[package_paths]]\nkind = 'exact_suffix'\ncomponents = ['future']\nextra = true", + "names = ['future']\n[bundled_node]\nruntime_basename = 'node.exe'\nentrypoint_basename = '../index.js'\npackage_directory = 'future'\nversions_directory = 'versions'", + ] { + assert!(load_packages(&[("agents/future-agent/agent.toml", AGENT), + ("agents/future-agent/process.toml", text)]).is_err(), "{text}"); + } + } + + #[test] + fn process_matchers_cannot_collide_across_packages() { + let other = AGENT + .replace("future-agent", "other") + .replace("future agent", "other alias"); + let check = |first: &str, second: &str| { + let first = format!("names = ['future-agent']\n{first}"); + let second = format!("names = ['other']\n{second}"); + load_packages(&[ + ("agents/future-agent/agent.toml", AGENT), + ("agents/future-agent/process.toml", &first), + ("agents/other/agent.toml", &other), + ("agents/other/process.toml", &second), + ]) + }; + for kind in ["normalized_components", "exact_suffix"] { + let path = format!("[[package_paths]]\nkind = '{kind}'\ncomponents = ['node_modules', 'shared', 'cli.js']"); + assert!(check(&path, &path.replace("shared", "SHARED")) + .unwrap_err() + .contains("duplicate package path")); + assert!(check(&path, &path.replace("shared", "different")).is_ok()); + } + let node = "[bundled_node]\nruntime_basename = 'node.exe'\nentrypoint_basename = 'index.js'\npackage_directory = 'shared'\nversions_directory = 'versions'"; + assert!(check(node, &node.replace("node.exe", "NODE.EXE")) + .unwrap_err() + .contains("duplicate bundled node")); + assert!(check(node, &node.replace("shared", "different")).is_ok()); + let prefix = "versioned_basename_prefix = 'shared-bin-'"; + let overlap = "versioned_basename_prefix = 'shared-bin-1-'"; + for (first, second) in [(prefix, overlap), (overlap, prefix)] { + assert!(check(first, second) + .unwrap_err() + .contains("overlapping versioned basename prefixes")); + } + assert!(check(prefix, "versioned_basename_prefix = 'shared-bin-other-'").is_ok()); + } + + #[test] + fn resume_references_must_be_known_unique_and_include_preference() { + for (references, preferred) in [ + ("[]", "id"), + ("['id', 'id']", "id"), + ("['id']", "path"), + ("['directory']", "directory"), + ] { + let text = format!("accepted_references = {references}\npreferred_reference = '{preferred}'\nstrategy = 'separate_flag'\ntoken = '--resume'"); + assert!(load_packages(&[ + ("agents/future-agent/agent.toml", AGENT), + ("agents/future-agent/resume.toml", &text) + ]) + .is_err()); + } + } + + #[test] + fn detection_is_generic_toml_not_bound_to_core_identity() { + let detection = "historical_identity = 'an-alias'\n[rules]\nfuture_rule = true\n"; + let packages = load_packages(&[ + ("agents/future-agent/agent.toml", AGENT), + ("agents/future-agent/detection.toml", detection), + ]) + .unwrap(); + assert_eq!(packages[0].detection.as_deref(), Some(detection)); + assert!(load_packages(&[ + ("agents/future-agent/agent.toml", AGENT), + ("agents/future-agent/detection.toml", "[broken") + ]) + .is_err()); + } + + #[test] + fn integration_assets_require_declared_supported_roles() { + let test = |definition: &str| { + load_packages(&[ + ("agents/future-agent/agent.toml", AGENT), + ("agents/future-agent/integration.toml", definition), + ("agents/future-agent/assets/reporter.js", "// reporter"), + ]) + }; + for definition in [ + INTEGRATION.replace("role = 'reporter'", "role = 'installer'"), + INTEGRATION.replace("platform = 'all'", "platform = 'linux'"), + INTEGRATION.replace("windows = true", "windows = false"), + INTEGRATION.replace("windows = 2", "windows = 0"), + INTEGRATION.replace("install_name = 'reporter.js'", "install_name = 'CON.txt'"), + format!("{INTEGRATION}adapter = 'custom'\n"), + ] { + assert!(test(&definition).is_err(), "{definition}"); + } + assert!(load_packages(&[ + ("agents/future-agent/agent.toml", AGENT), + ("agents/future-agent/assets/reporter.js", "// orphan") + ]) + .is_err()); + assert!(load_packages(&[ + ("agents/future-agent/agent.toml", AGENT), + ("agents/future-agent/integration.toml", INTEGRATION), + ("agents/future-agent/assets/reporter.js", "// reporter"), + ("agents/future-agent/assets/orphan.js", "// orphan") + ]) + .is_err()); + } + + #[test] + fn historical_agy_and_tui_markers_are_preserved() { + let agy = AGENT.replace("future-agent", "agy"); + assert!(load_packages(&[ + ("agents/agy/agent.toml", &agy), + ("agents/agy/integration.toml", INTEGRATION), + ( + "agents/agy/assets/reporter.js", + "# HERDR_INTEGRATION_ID=antigravity_cli\n# HERDR_INTEGRATION_VERSION=2" + ) + ]) + .is_ok()); + let definition = format!("{INTEGRATION}\n[[assets]]\npath = 'assets/tui.js'\nplatform = 'all'\nrole = 'tui'\ninstall_name = 'tui.js'\n"); + assert!(load_packages(&[ + ("agents/future-agent/agent.toml", AGENT), + ("agents/future-agent/integration.toml", &definition), + ("agents/future-agent/assets/reporter.js", "// reporter"), + ( + "agents/future-agent/assets/tui.js", + "// HERDR_INTEGRATION_ID=future-agent-tui\n// HERDR_INTEGRATION_VERSION=2" + ) + ]) + .is_ok()); + } + + #[test] + fn rejects_oversize_before_parsing_and_duplicate_files() { + let large = " ".repeat(MAX_TOML_BYTES + 1); + assert!(load_packages(&[("agents/future-agent/agent.toml", &large)]) + .unwrap_err() + .contains("too large")); + assert!(load_packages(&[("agents/future-agent/agent.toml", AGENT); 2]).is_err()); + } +} diff --git a/src/agents/store.rs b/src/agents/store.rs new file mode 100644 index 0000000000..a328d1aab0 --- /dev/null +++ b/src/agents/store.rs @@ -0,0 +1,2209 @@ +//! Transactional, session-local registry snapshots. Candidate compilation and +//! durable publication finish before the active Arc is changed. No startup writes. +//! +//! The journal is a last-known-good *package source*, not an archive of mutable +//! detection overrides or remote cache files. Restart recompiles its exact source +//! bytes against the currently valid detection overlays. Initialization alone +//! never establishes a durable LKG; a successful reload publication does. + +use std::fs; +use std::io::Write; +use std::ops::Deref; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, LazyLock, Mutex, RwLock}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use super::{bundled, files, remote, AgentRegistry}; +use crate::detect::{manifest, Agent}; + +type SourceFiles = Vec<(String, String)>; +const MAX_ERROR_BYTES: usize = 2048; +const MAX_SOURCE_PATH_BYTES: usize = 4096; +const MAX_REMOTE_HISTORY: usize = 64; +// JSON can expand one source byte to six bytes. File paths and metadata have +// independent bounds; this cap is checked before deserializing any journal. +const MAX_JOURNAL_BYTES: u64 = files::MAX_TOTAL_BYTES * 6 + 4 * 1024 * 1024; + +#[derive(Debug)] +pub(crate) struct RegistrySnapshot { + pub(crate) registry: Arc, + pub(crate) manifests: manifest::ManifestCache, + pub(crate) generation: u64, + pub(crate) digest: String, + pub(crate) source: Option, + pub(crate) remote: Option, + accepted_remote: Vec, + files: Arc, +} + +impl Deref for RegistrySnapshot { + type Target = AgentRegistry; + + fn deref(&self) -> &Self::Target { + &self.registry + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct RegistryStatus { + pub(crate) generation: u64, + pub(crate) digest: String, + /// Local source directory; None selects the managed bundled/R2 source. + pub(crate) source: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) remote: Option, + pub(crate) last_error: Option, + pub(crate) agents: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct RegistryUpdateCheck { + pub(crate) active_generation: u64, + pub(crate) active_digest: String, + pub(crate) remote: remote::RemoteRevision, + pub(crate) content_sha256: String, + pub(crate) update_available: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct AgentSummary { + pub(crate) id: String, + pub(crate) startable: bool, + pub(crate) process: bool, + pub(crate) detection: bool, + pub(crate) resume: bool, + pub(crate) integration: bool, +} + +pub(crate) struct RegistryStore { + active: RwLock>, + generation: AtomicU64, + reload_lock: Mutex<()>, + download_lock: Mutex<()>, + configured_source: Mutex>, + last_error: Mutex>, + journal_path: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Journal { + schema: u32, + generation: u64, + digest: String, + source: Option, + // Selection survives even when the active bytes must fall back to bundled. + #[serde(default, skip_serializing_if = "Option::is_none")] + selected_source: Option, + #[serde(default)] + remote: Option, + #[serde( + default, + skip_serializing_if = "Vec::is_empty", + deserialize_with = "deserialize_remote_history" + )] + accepted_remote: Vec, + #[serde(deserialize_with = "deserialize_journal_files")] + files: Vec, +} + +impl Journal { + fn into_snapshot(self) -> Result { + // Integrity-valid control metadata does not authorize incompatible packages. + let files = self + .files + .into_iter() + .map(|file| (file.path, file.content)) + .collect(); + let mut snapshot = build_snapshot(files, self.source, self.generation)?; + snapshot.remote = self.remote; + snapshot.accepted_remote = self.accepted_remote; + manifest::apply_registry_provenance( + &mut snapshot.manifests, + snapshot.source.as_deref(), + snapshot.remote.as_ref(), + ); + Ok(snapshot) + } +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct JournalFile { + path: String, + content: String, + sha256: String, +} + +fn deserialize_remote_history<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + struct Visitor; + impl<'de> serde::de::Visitor<'de> for Visitor { + type Value = Vec; + fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("bounded accepted registry publications") + } + fn visit_seq(self, mut sequence: A) -> Result + where + A: serde::de::SeqAccess<'de>, + { + let mut entries = Vec::new(); + while let Some(entry) = sequence.next_element::()? { + if entries.len() >= MAX_REMOTE_HISTORY { + return Err(serde::de::Error::custom( + "too many accepted registry origins/channels", + )); + } + entry.validate().map_err(serde::de::Error::custom)?; + if entries + .iter() + .any(|old: &remote::RemoteRevision| same_channel(old, &entry)) + { + return Err(serde::de::Error::custom( + "duplicate accepted registry origin/channel", + )); + } + entries.push(entry); + } + Ok(entries) + } + } + deserializer.deserialize_seq(Visitor) +} + +fn same_channel(left: &remote::RemoteRevision, right: &remote::RemoteRevision) -> bool { + left.origin == right.origin && left.pointer.channel == right.pointer.channel +} + +// Bound the collection while decoding, not after an attacker-controlled JSON +// array has allocated millions of empty records. +fn deserialize_journal_files<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + struct BoundedFiles; + impl<'de> serde::de::Visitor<'de> for BoundedFiles { + type Value = Vec; + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("a bounded source file array") + } + fn visit_seq(self, mut sequence: A) -> Result + where + A: serde::de::SeqAccess<'de>, + { + let mut files = Vec::new(); + let mut total = 0u64; + while let Some(file) = sequence.next_element::()? { + total += file.content.len() as u64; + if files.len() >= files::MAX_FILES + || file.path.len() > 256 + || file.content.len() as u64 > files::MAX_FILE_BYTES + || total > files::MAX_TOTAL_BYTES + { + return Err(serde::de::Error::custom( + "registry journal source exceeds limits", + )); + } + files.push(file); + } + Ok(files) + } + } + deserializer.deserialize_seq(BoundedFiles) +} + +impl RegistryStore { + /// An isolated store with caller-owned persistence; does not write or consult + /// the global registry. Useful for embedders and deterministic local tests. + #[cfg(test)] + pub(crate) fn new(files: SourceFiles, journal_path: PathBuf) -> Result { + Ok(Self::from_snapshot( + build_snapshot(files, None, 1)?, + None, + journal_path, + None, + )) + } + + fn from_snapshot( + snapshot: RegistrySnapshot, + configured_source: Option, + journal_path: PathBuf, + last_error: Option, + ) -> Self { + Self { + generation: AtomicU64::new(snapshot.generation), + active: RwLock::new(Arc::new(snapshot)), + reload_lock: Mutex::new(()), + download_lock: Mutex::new(()), + configured_source: Mutex::new(configured_source), + last_error: Mutex::new(last_error), + journal_path: Some(journal_path), + } + } + + fn startup(mut configured: Option, journal_path: PathBuf) -> Self { + let mut errors = Vec::new(); + let mut accepted_remote = Vec::new(); + match read_journal(&journal_path) { + Ok(Some(journal)) => { + configured = configured.or_else(|| journal.selected_source.clone()); + accepted_remote = journal.accepted_remote.clone(); + let matches_source = configured.as_ref().is_none_or(|selected| { + journal + .source + .as_ref() + .is_some_and(|saved| source_identity(selected) == source_identity(saved)) + }); + if matches_source { + match journal.into_snapshot() { + Ok(snapshot) => { + return Self::from_snapshot(snapshot, configured, journal_path, None) + } + Err(error) => { + errors.push(format!("last-known-good registry rejected: {error}")) + } + } + } else if configured.as_deref().map(source_identity) + != journal.selected_source.as_deref().map(source_identity) + { + errors.push("saved registry belongs to a different selected source".into()); + } + } + Ok(None) => {} + Err(error) => errors.push(format!("last-known-good registry rejected: {error}")), + } + if let Some(source) = &configured { + match read_candidate(Some(source), 1) { + Ok(mut snapshot) => { + snapshot.accepted_remote = accepted_remote; + let error = startup_error(errors); + let source = snapshot.source.clone(); + return Self::from_snapshot(snapshot, source, journal_path, error); + } + Err(error) => errors.push(format!( + "configured registry rejected; using bundled source: {error}" + )), + } + } + let mut snapshot = read_candidate(None, 1).unwrap_or_else(|error| { + // A bad external source must never disable the trusted fallback. + // Invalid compiled-in data is a build defect, not a reload failure. + panic!("bundled agent registry is invalid: {error}") + }); + snapshot.accepted_remote = accepted_remote; + let error = startup_error(errors); + Self::from_snapshot(snapshot, configured, journal_path, error) + } + + pub(crate) fn snapshot(&self) -> Arc { + self.active + .read() + .unwrap_or_else(|e| e.into_inner()) + .clone() + } + + fn persist( + &self, + snapshot: &RegistrySnapshot, + selected_source: Option<&Path>, + ) -> Result<(), String> { + match &self.journal_path { + Some(path) => persist(path, snapshot, selected_source), + None => Ok(()), // only the global unit-test store is nonpersistent + } + } + + fn publish(&self, candidate: RegistrySnapshot) { + let mut active = self.active.write().unwrap_or_else(|e| e.into_inner()); + let generation = candidate.generation; + *active = Arc::new(candidate); + self.generation.store(generation, Ordering::Release); + } + + pub(crate) fn status(&self) -> RegistryStatus { + let active = self.snapshot(); + RegistryStatus { + generation: active.generation, + digest: active.digest.clone(), + source: active.source.clone(), + remote: active.remote.clone(), + agents: active + .known_profiles() + .map(|profile| AgentSummary { + id: profile.canonical_id().to_owned(), + startable: profile.is_startable(), + process: profile.process().is_some(), + detection: profile.is_screen_detectable(), + resume: profile.session().is_some(), + integration: profile.integration().is_some(), + }) + .collect(), + last_error: self + .last_error + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(), + } + } + + fn outcome(&self, result: Result) -> Result { + let result = result.map_err(bounded_error); + *self.last_error.lock().unwrap_or_else(|e| e.into_inner()) = result.as_ref().err().cloned(); + result + } + + fn reload_guard(&self) -> Result, String> { + match self.reload_lock.try_lock() { + Ok(guard) => Ok(guard), + Err(_) => self.outcome(Err("agent registry reload busy".into())), + } + } + + pub(crate) fn reload(&self, source: Option<&Path>) -> Result { + let _guard = self.reload_guard()?; + let result = (|| { + let retained = self + .configured_source + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(); + let source = source.or(retained.as_deref()); + let active = self.snapshot(); + let mut candidate = if source.is_none() && active.remote.is_some() { + let mut candidate = + build_snapshot((*active.files).clone(), None, active.generation)?; + candidate.remote = active.remote.clone(); + candidate + } else { + read_candidate(source, active.generation)? + }; + validate_integration_revisions(&active, &candidate)?; + candidate.accepted_remote = active.accepted_remote.clone(); + if candidate.digest != active.digest { + candidate.generation = next_generation(active.generation)?; + } else { + // Byte-identical reloads must not silently refresh detection under + // an unchanged generation. Detection updates have their own path. + candidate.registry = active.registry.clone(); + candidate.manifests = active.manifests.clone(); + } + manifest::apply_registry_provenance( + &mut candidate.manifests, + candidate.source.as_deref(), + candidate.remote.as_ref(), + ); + self.persist(&candidate, candidate.source.as_deref())?; + *self + .configured_source + .lock() + .unwrap_or_else(|e| e.into_inner()) = candidate.source.clone(); + self.publish(candidate); + Ok(()) + })(); + self.outcome(result)?; + Ok(self.status()) + } + + pub(crate) fn reset(&self) -> Result { + let _guard = self.reload_guard()?; + self.outcome((|| { + let active = self.snapshot(); + let mut candidate = read_candidate(None, next_generation(active.generation)?)?; + candidate.accepted_remote = active.accepted_remote.clone(); + self.persist(&candidate, None)?; + *self + .configured_source + .lock() + .unwrap_or_else(|e| e.into_inner()) = None; + self.publish(candidate); + Ok(()) + })())?; + Ok(self.status()) + } + + fn prepare_remote( + &self, + channel: Option, + ) -> Result<(Arc, RegistrySnapshot), String> { + let _download = self + .download_lock + .try_lock() + .map_err(|_| "registry download already in progress")?; + let active = self.snapshot(); + self.require_managed_source(&active)?; + let channel = channel.unwrap_or_else(|| { + active + .remote + .as_ref() + .map(|revision| revision.pointer.channel) + .unwrap_or_default() + }); + let (revision, verified) = remote::download(&remote::origin()?, channel)?; + let candidate = remote_candidate(&active, revision, verified)?; + Ok((active, candidate)) + } + + fn require_managed_source(&self, snapshot: &RegistrySnapshot) -> Result<(), String> { + if snapshot.source.is_some() + || self + .configured_source + .lock() + .unwrap_or_else(|e| e.into_inner()) + .is_some() + { + return Err("a local registry is selected; use registry reset before updating from R2 (local files are not modified)".into()); + } + Ok(()) + } + + fn automatic_update_channel(&self) -> Option { + let active = self.snapshot(); + self.require_managed_source(&active).ok()?; + Some( + active + .remote + .as_ref() + .map(|revision| revision.pointer.channel) + .unwrap_or_default(), + ) + } + + pub(crate) fn check_remote( + &self, + channel: remote::Channel, + ) -> Result { + self.outcome((|| { + let (active, candidate) = self.prepare_remote(Some(channel))?; + // A check is advisory and must never publish or write a journal. + let remote = candidate + .remote + .clone() + .ok_or("missing remote registry provenance")?; + Ok(RegistryUpdateCheck { + active_generation: active.generation, + active_digest: active.digest.clone(), + update_available: candidate.digest != active.digest, + content_sha256: candidate.digest, + remote, + }) + })()) + } + + fn update_remote(&self, channel: Option) -> Result { + let result = (|| { + let (expected, candidate) = self.prepare_remote(channel)?; + self.activate_remote(&expected, candidate) + })(); + self.outcome(result)?; + Ok(self.status()) + } + + fn activate_remote( + &self, + expected: &RegistrySnapshot, + mut candidate: RegistrySnapshot, + ) -> Result<(), String> { + let _guard = self.reload_guard()?; + let active = self.snapshot(); + self.require_managed_source(&active)?; + if active.generation != expected.generation + || active.source != expected.source + || active.remote != expected.remote + || active.accepted_remote != expected.accepted_remote + { + return Err("registry changed during download; retry the update".into()); + } + validate_integration_revisions(&active, &candidate)?; + if active.digest == candidate.digest { + candidate.registry = active.registry.clone(); + candidate.manifests = active.manifests.clone(); + } + manifest::apply_registry_provenance( + &mut candidate.manifests, + None, + candidate.remote.as_ref(), + ); + self.persist(&candidate, None)?; + self.publish(candidate); + Ok(()) + } + + fn replace_detection( + &self, + builder: impl FnOnce(&RegistrySnapshot) -> manifest::ManifestCache, + ) -> Result<(), String> { + let _guard = self.reload_guard()?; + self.outcome((|| { + let active = self.snapshot(); + let mut candidate = RegistrySnapshot { + registry: active.registry.clone(), + manifests: builder(&active), + generation: next_generation(active.generation)?, + digest: active.digest.clone(), + source: active.source.clone(), + remote: active.remote.clone(), + accepted_remote: active.accepted_remote.clone(), + files: active.files.clone(), + }; + manifest::apply_registry_provenance( + &mut candidate.manifests, + candidate.source.as_deref(), + candidate.remote.as_ref(), + ); + let selected = self + .configured_source + .lock() + .unwrap_or_else(|e| e.into_inner()); + self.persist(&candidate, selected.as_deref())?; + self.publish(candidate); + Ok(()) + })()) + } +} + +fn remote_candidate( + active: &RegistrySnapshot, + revision: remote::RemoteRevision, + verified: remote::VerifiedSnapshot, +) -> Result { + revision.validate()?; + let mut accepted_remote = active.accepted_remote.clone(); + if let Some(previous) = accepted_remote + .iter_mut() + .find(|old| same_channel(old, &revision)) + { + if revision.pointer.generation < previous.pointer.generation { + return Err( + "registry publication is older than the accepted channel generation".into(), + ); + } + if revision.pointer.generation == previous.pointer.generation + && revision.pointer != previous.pointer + { + return Err( + "registry channel generation was reused for different snapshot bytes".into(), + ); + } + *previous = revision.clone(); + } else { + if accepted_remote.len() >= MAX_REMOTE_HISTORY { + return Err("accepted registry origin/channel history is full".into()); + } + accepted_remote.push(revision.clone()); + } + let generation = if verified.content_sha256 == active.digest { + active.generation + } else { + next_generation(active.generation)? + }; + let mut candidate = build_snapshot(verified.files, None, generation)?; + validate_integration_revisions(active, &candidate)?; + if candidate.digest != verified.content_sha256 || revision.commit != verified.commit { + return Err("registry snapshot provenance/content changed before activation".into()); + } + candidate.remote = Some(revision); + candidate.accepted_remote = accepted_remote; + manifest::apply_registry_provenance(&mut candidate.manifests, None, candidate.remote.as_ref()); + Ok(candidate) +} + +fn next_generation(generation: u64) -> Result { + generation + .checked_add(1) + .ok_or_else(|| "registry generation exhausted".into()) +} + +fn bounded_error(mut error: String) -> String { + if error.len() > MAX_ERROR_BYTES { + let mut end = MAX_ERROR_BYTES - 3; + while !error.is_char_boundary(end) { + end -= 1; + } + error.truncate(end); + error.push_str("..."); + } + error +} + +fn startup_error(errors: Vec) -> Option { + if errors.is_empty() { + return None; + } + let error = bounded_error(errors.join("; ")); + eprintln!("agent registry: {error}"); + tracing::error!(%error, "agent registry startup fallback"); + Some(error) +} + +fn source_identity(source: &Path) -> PathBuf { + let absolute = if source.is_absolute() { + source.to_owned() + } else { + std::env::current_dir().unwrap_or_default().join(source) + }; + for ancestor in absolute.ancestors() { + if let Ok(canonical) = fs::canonicalize(ancestor) { + if let Ok(suffix) = absolute.strip_prefix(ancestor) { + // A deleted source must retain its canonical parent spelling, including + // Windows' verbatim prefix, to match the saved last-good source. + return canonical.join(suffix); + } + } + } + absolute +} + +fn check_source_path(source: &Path) -> Result<(), String> { + match source.to_str() { + Some(value) if !value.is_empty() && value.len() <= MAX_SOURCE_PATH_BYTES => Ok(()), + _ => Err("registry source path must be bounded nonempty UTF-8".into()), + } +} + +fn read_candidate(source: Option<&Path>, generation: u64) -> Result { + let (files, source) = match source { + Some(source) => { + check_source_path(source)?; + let files = files::read_source(source)?; + // Read before canonicalization so a symlink root cannot bypass the reader. + let path = fs::canonicalize(source).map_err(|e| e.to_string())?; + check_source_path(&path)?; + (files, Some(path)) + } + None => ( + bundled::FILES + .iter() + .map(|(p, t)| ((*p).into(), (*t).into())) + .collect(), + None, + ), + }; + build_snapshot(files, source, generation) +} + +fn build_snapshot( + mut files: SourceFiles, + source: Option, + generation: u64, +) -> Result { + files.sort_by(|left, right| left.0.cmp(&right.0)); + let borrowed: Vec<_> = files + .iter() + .map(|(p, t)| (p.as_str(), t.as_str())) + .collect(); + let packages = super::validate_packages(&borrowed)?; + validate_integration_baseline(&packages)?; + let registry = Arc::new(AgentRegistry::from_packages(packages)?); + let mut manifests = manifest::build_manifest_cache(®istry); + manifest::apply_registry_provenance(&mut manifests, source.as_deref(), None); + Ok(RegistrySnapshot { + registry, + manifests, + generation, + digest: source_digest(&files), + source, + remote: None, + accepted_remote: Vec::new(), + files: Arc::new(files), + }) +} + +// The compiled installer layout is validated separately. Its bundled version is +// the compatibility floor; newer versioned payloads use that same installer. +pub(crate) fn validate_integration_baseline( + packages: &[super::source::Package], +) -> Result<(), String> { + for package in packages { + let Some(integration) = &package.integration else { + continue; + }; + let id = &package.identity.id; + if crate::integration::builtin::binding(Agent::parse(id)?).is_none() { + continue; + } + for asset in &integration.assets { + if asset.role == "manifest" { + continue; + } + let text = package + .assets + .get(&asset.path) + .ok_or_else(|| format!("{id}: missing integration asset {}", asset.path))?; + if super::source::markers(text, "HERDR_INTEGRATION_ID=").len() != 1 + || super::source::markers(text, "HERDR_INTEGRATION_VERSION=").len() != 1 + { + return Err(format!( + "{id}: integration asset {} requires identity and version markers", + asset.path + )); + } + } + let metadata_path = format!("agents/{id}/integration.toml"); + let metadata = bundled::FILES + .iter() + .find(|(path, _)| *path == metadata_path) + .map(|(_, text)| *text) + .ok_or_else(|| format!("missing bundled integration definition for {id}"))?; + let baseline: super::source::IntegrationDefinition = toml::from_str(metadata) + .map_err(|error| format!("invalid bundled integration {id}: {error}"))?; + for (platform, version, minimum) in [ + ("unix", integration.versions.unix, baseline.versions.unix), + ( + "windows", + integration.versions.windows, + baseline.versions.windows, + ), + ] { + if version < minimum { + return Err(format!("{id}: integration version {version} on {platform} is older than this installer's minimum {minimum}")); + } + if version == minimum { + validate_same_version_assets(id, platform, integration, |path| { + let full_path = format!("agents/{id}/{path}"); + let baseline = bundled::FILES + .iter() + .find(|(name, _)| *name == full_path) + .map(|(_, text)| *text); + package.assets.get(path).map(String::as_str) == baseline + })?; + } + } + } + Ok(()) +} + +fn validate_same_version_assets( + id: &str, + platform: &str, + definition: &super::source::IntegrationDefinition, + unchanged: impl Fn(&str) -> bool, +) -> Result<(), String> { + for asset in &definition.assets { + if (asset.platform == "all" || asset.platform == platform) && !unchanged(&asset.path) { + return Err(format!( + "{id}: integration asset {} changed on {platform} without changing its version", + asset.path + )); + } + } + Ok(()) +} + +fn validate_integration_revisions( + previous: &AgentRegistry, + candidate: &AgentRegistry, +) -> Result<(), String> { + for profile in candidate.integration_capable_profiles() { + let Some(next) = profile.integration() else { + continue; + }; + let Some(old) = previous + .profile_by_id(profile.canonical_id()) + .and_then(|profile| profile.integration()) + else { + continue; + }; + for (platform, version, previous_version) in [ + ( + "unix", + next.definition.versions.unix, + old.definition.versions.unix, + ), + ( + "windows", + next.definition.versions.windows, + old.definition.versions.windows, + ), + ] { + if version == previous_version { + validate_same_version_assets( + profile.canonical_id(), + platform, + &next.definition, + |path| next.assets.get(path) == old.assets.get(path), + )?; + } + } + } + Ok(()) +} + +fn hash(content: &str) -> String { + format!("{:x}", Sha256::digest(content.as_bytes())) +} + +// Same sorted exact-byte content pin as scripts/agent_registry_vendor.py. +fn source_digest(files: &SourceFiles) -> String { + source_digest_entries( + files + .iter() + .map(|(path, content)| (path.as_str(), content.as_str())), + ) +} + +fn source_digest_entries<'a>(files: impl Iterator) -> String { + let mut digest = Sha256::new(); + for (path, content) in files { + digest.update(format!("{} {path}\n", hash(content)).as_bytes()); + } + format!("{:x}", digest.finalize()) +} + +fn read_journal(path: &Path) -> Result, String> { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.to_string()), + }; + if !metadata.is_file() + || metadata.file_type().is_symlink() + || metadata.len() > MAX_JOURNAL_BYTES + { + return Err("registry journal must be a bounded regular file".into()); + } + let text = files::read_capped(files::open_regular(path)?, MAX_JOURNAL_BYTES)?; + let mut journal: Journal = serde_json::from_str(&text).map_err(|e| e.to_string())?; + if journal.schema != 1 || journal.generation == 0 || journal.files.len() > files::MAX_FILES { + return Err("invalid registry journal schema, generation or file count".into()); + } + if journal.selected_source.is_none() { + journal.selected_source = journal.source.clone(); + } + if let Some(selected) = &journal.selected_source { + check_source_path(selected)?; + } + if let Some(source) = &journal.source { + check_source_path(source)?; + if journal + .selected_source + .as_ref() + .is_none_or(|selected| source_identity(selected) != source_identity(source)) + { + return Err("registry journal local source does not match selection".into()); + } + } + let mut total = 0u64; + for file in &journal.files { + total += file.content.len() as u64; + if file.path.len() > 256 + || file.content.len() as u64 > files::MAX_FILE_BYTES + || total > files::MAX_TOTAL_BYTES + { + return Err("registry journal source exceeds limits".into()); + } + if file.sha256 != hash(&file.content) { + return Err("registry journal file hash mismatch".into()); + } + } + journal.files.sort_by(|a, b| a.path.cmp(&b.path)); + if journal.digest + != source_digest_entries( + journal + .files + .iter() + .map(|file| (file.path.as_str(), file.content.as_str())), + ) + { + return Err("registry journal digest mismatch".into()); + } + if let Some(remote) = &journal.remote { + remote.validate()?; + if journal.selected_source.is_some() { + return Err("registry journal cannot select local and remote sources together".into()); + } + if journal.accepted_remote.is_empty() { + journal.accepted_remote.push(remote.clone()); + } else if !journal + .accepted_remote + .iter() + .any(|accepted| accepted == remote) + { + return Err("active registry publication does not match accepted history".into()); + } + } + Ok(Some(journal)) +} + +fn persist( + path: &Path, + snapshot: &RegistrySnapshot, + selected_source: Option<&Path>, +) -> Result<(), String> { + let journal = Journal { + schema: 1, + generation: snapshot.generation, + digest: snapshot.digest.clone(), + source: snapshot.source.clone(), + selected_source: selected_source.map(Path::to_path_buf), + remote: snapshot.remote.clone(), + accepted_remote: snapshot.accepted_remote.clone(), + files: snapshot + .files + .iter() + .map(|(path, content)| JournalFile { + path: path.clone(), + content: content.clone(), + sha256: hash(content), + }) + .collect(), + }; + let bytes = serde_json::to_vec(&journal).map_err(|e| e.to_string())?; + if bytes.len() as u64 > MAX_JOURNAL_BYTES { + return Err("registry journal exceeds limit".into()); + } + atomic_write(path, &bytes) +} + +fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), String> { + let parent = path.parent().ok_or("registry journal has no parent")?; + fs::create_dir_all(parent).map_err(|e| format!("create registry journal directory: {e}"))?; + match fs::symlink_metadata(path) { + Ok(metadata) if !metadata.is_file() || metadata.file_type().is_symlink() => { + return Err("registry journal is not a regular file".into()) + } + Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e.to_string()), + _ => {} + } + static NEXT: AtomicU64 = AtomicU64::new(0); + let temp = parent.join(format!( + ".active.{}.{}.tmp", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + let mut file = crate::platform::create_private_file(&temp) + .map_err(|e| format!("create registry journal: {e}"))?; + let result = file.write_all(bytes).and_then(|_| file.sync_all()); + drop(file); + if let Err(error) = result.and_then(|_| fs::rename(&temp, path)) { + let _ = fs::remove_file(&temp); + return Err(format!("persist registry journal: {error}")); + } + // Rename is the commit point. A subsequent directory-sync failure cannot be + // reported as rollback: the committed journal and active snapshot must agree. + if let Err(error) = crate::platform::sync_directory_after_replace(parent) { + tracing::warn!(%error, "registry committed but journal directory sync failed"); + } + Ok(()) +} + +#[cfg(not(test))] +static STORE: LazyLock = LazyLock::new(|| { + RegistryStore::startup( + std::env::var_os("HERDR_AGENT_REGISTRY_SOURCE").map(PathBuf::from), + crate::session::data_dir().join("agent-registry/active.json"), + ) +}); + +// Existing detection unit tests change XDG directories around global refreshes. +// They must neither read nor overwrite a real session journal. Filesystem and +// restart tests below always construct isolated, genuinely persistent stores. +#[cfg(test)] +static STORE: LazyLock = LazyLock::new(|| { + let mut store = RegistryStore::from_snapshot( + read_candidate(None, 1).expect("valid bundled registry"), + None, + PathBuf::new(), + None, + ); + store.journal_path = None; + store +}); + +#[cfg(test)] +pub(crate) fn snapshot_for_test( + files: SourceFiles, + generation: u64, +) -> Result, String> { + build_snapshot(files, None, generation).map(Arc::new) +} + +pub(crate) fn snapshot() -> Arc { + STORE.snapshot() +} +pub(crate) fn generation() -> u64 { + STORE.generation.load(Ordering::Acquire) +} +pub(crate) fn status() -> RegistryStatus { + STORE.status() +} +pub(crate) fn reload(source: Option<&Path>) -> Result { + STORE.reload(source) +} +pub(crate) fn reset() -> Result { + STORE.reset() +} +pub(crate) fn check_remote(channel: remote::Channel) -> Result { + STORE.check_remote(channel) +} +pub(crate) fn update_remote(channel: remote::Channel) -> Result { + STORE.update_remote(Some(channel)) +} +pub(crate) fn auto_update_remote() -> Result, String> { + if STORE.automatic_update_channel().is_none() { + return Ok(None); + } + // Resolve the channel from the download's snapshot, not this preflight. + STORE.update_remote(None).map(Some) +} +pub(crate) fn replace_detection( + builder: impl FnOnce(&RegistrySnapshot) -> manifest::ManifestCache, +) -> Result<(), String> { + STORE.replace_detection(builder) +} +pub(crate) fn refresh_detection(agents: Option<&[Agent]>) -> Vec { + if let Err(error) = replace_detection(|snapshot| match agents { + Some(agents) => manifest::build_manifest_cache_for_agents( + &snapshot.registry, + &snapshot.manifests, + agents, + ), + None => manifest::build_manifest_cache(&snapshot.registry), + }) { + tracing::warn!(%error, "agent detection refresh rejected; retaining active snapshot"); + } + manifest::summaries(&snapshot().manifests) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + const AGENT: &str = "schema = 1\nid = 'future-agent'\nname = 'Future agent'\naliases = []\nstartable = true\n[launch]\nunix = 'future-agent'\nwindows = 'future-agent.cmd'\n"; + const PATH: &str = "agents/future-agent/agent.toml"; + + struct Fixture(PathBuf); + impl Fixture { + fn new() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + #[cfg(unix)] + let base = PathBuf::from("/var/tmp"); + #[cfg(not(unix))] + let base = std::env::temp_dir(); + let path = base.join(format!( + "herdr-registry-store-{}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(), + NEXT.fetch_add(1, Ordering::Relaxed), + )); + fs::create_dir(&path).unwrap(); + Self(path) + } + fn source(&self, name: &str, text: &str) -> PathBuf { + let root = self.0.join(name); + let path = root.join(PATH); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, text).unwrap(); + root + } + fn journal(&self) -> PathBuf { + self.0.join("journal/active.json") + } + fn store(&self) -> RegistryStore { + RegistryStore::new(vec![(PATH.into(), AGENT.into())], self.journal()).unwrap() + } + } + impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + fn remote_for(active: &RegistrySnapshot, publication: u64, text: &str) -> RegistrySnapshot { + remote_for_channel(active, publication, text, remote::Channel::Staging) + } + + fn remote_for_channel( + active: &RegistrySnapshot, + publication: u64, + text: &str, + channel: remote::Channel, + ) -> RegistrySnapshot { + let files = vec![(PATH.to_string(), text.to_string())]; + let commit = "b".repeat(40); + remote_candidate( + active, + remote::RemoteRevision { + origin: remote::DEFAULT_ORIGIN.into(), + pointer: remote::ChannelPointer { + schema: 1, + channel, + generation: publication, + snapshot_sha256: format!("{publication:064x}"), + snapshot_bytes: 100, + }, + commit: commit.clone(), + }, + remote::VerifiedSnapshot { + content_sha256: source_digest(&files), + files, + commit, + }, + ) + .unwrap() + } + + #[test] + fn remote_activation_persists_offline_identity_and_offline_reload_keeps_remote_source() { + let fixture = Fixture::new(); + let store = fixture.store(); + let initial = store.snapshot(); + let text = AGENT.replace("aliases = []", "aliases = ['downloaded']"); + let candidate = remote_for(&initial, 10, &text); + store.activate_remote(&initial, candidate).unwrap(); + let active = store.snapshot(); + assert!(active.profile_by_normalized_alias("downloaded").is_some()); + assert!(initial.profile_by_normalized_alias("downloaded").is_none()); + assert_eq!(active.remote.as_ref().unwrap().pointer.generation, 10); + let restarted = RegistryStore::startup(None, fixture.journal()); + assert_eq!(restarted.snapshot().remote, active.remote); + assert_eq!(restarted.snapshot().digest, active.digest); + let reloaded = restarted.reload(None).unwrap(); + assert_eq!(reloaded.remote, active.remote); + assert_eq!(reloaded.generation, active.generation); + assert_eq!(reloaded.digest, active.digest); + } + + #[test] + fn concurrent_local_reload_cannot_be_overwritten_by_remote_download() { + let fixture = Fixture::new(); + let store = fixture.store(); + let expected = store.snapshot(); + let candidate = remote_for(&expected, 10, AGENT); + let local = fixture.source("local", AGENT); + store.reload(Some(&local)).unwrap(); + let before = fs::read(fixture.journal()).unwrap(); + assert!(store + .activate_remote(&expected, candidate) + .unwrap_err() + .contains("local registry")); + assert_eq!(fs::read(fixture.journal()).unwrap(), before); + assert_eq!( + store.status().source, + Some(fs::canonicalize(&local).unwrap()) + ); + assert!(store.require_managed_source(&store.snapshot()).is_err()); + let reset = store.reset().unwrap(); + assert!(reset.source.is_none()); + assert!(reset.remote.is_none()); + assert!(local.join(PATH).exists()); + assert!(store.require_managed_source(&store.snapshot()).is_ok()); + } + + #[test] + fn remote_publication_is_monotonic_but_new_sequence_can_restore_old_content() { + let fixture = Fixture::new(); + let store = fixture.store(); + let initial = store.snapshot(); + store + .activate_remote(&initial, remote_for(&initial, 10, AGENT)) + .unwrap(); + let active = store.snapshot(); + assert_eq!( + active.generation, initial.generation, + "identical content is a runtime no-op" + ); + let newer = remote_for( + &active, + 11, + &AGENT.replace("aliases = []", "aliases = ['next']"), + ); + let mut stale = newer.remote.clone().unwrap(); + stale.pointer.generation = 9; + let verified = remote::VerifiedSnapshot { + content_sha256: newer.digest.clone(), + files: (*newer.files).clone(), + commit: stale.commit.clone(), + }; + assert!(remote_candidate(&active, stale, verified) + .unwrap_err() + .contains("older")); + let mut reused = newer.remote.clone().unwrap(); + reused.pointer.generation = 10; + let verified = remote::VerifiedSnapshot { + content_sha256: newer.digest.clone(), + files: (*newer.files).clone(), + commit: reused.commit.clone(), + }; + assert!(remote_candidate(&active, reused, verified) + .unwrap_err() + .contains("reused")); + store.activate_remote(&active, newer).unwrap(); + let current = store.snapshot(); + let rollback = remote_for(¤t, 12, AGENT); + store.activate_remote(¤t, rollback).unwrap(); + assert_eq!(store.snapshot().digest, initial.digest); + assert!(store.snapshot().generation > current.generation); + } + + #[test] + fn channel_high_water_marks_survive_switch_restart_and_bundled_reset() { + let fixture = Fixture::new(); + let store = fixture.store(); + let initial = store.snapshot(); + store + .activate_remote( + &initial, + remote_for_channel(&initial, 10, AGENT, remote::Channel::Stable), + ) + .unwrap(); + let stable = store.snapshot(); + let accepted = stable.remote.clone().unwrap(); + store + .activate_remote( + &stable, + remote_for_channel(&stable, 1, AGENT, remote::Channel::Preview), + ) + .unwrap(); + let restarted = RegistryStore::startup(None, fixture.journal()); + assert_eq!(restarted.snapshot().accepted_remote.len(), 2); + for reset in [false, true] { + if reset { + restarted.reset().unwrap(); + } + let active = restarted.snapshot(); + let before = fs::read(fixture.journal()).unwrap(); + for (generation, hash) in [(9, format!("{:064x}", 9)), (10, "f".repeat(64))] { + let mut stale = accepted.clone(); + stale.pointer.generation = generation; + stale.pointer.snapshot_sha256 = hash; + let files = vec![(PATH.into(), AGENT.into())]; + let candidate = remote_candidate( + &active, + stale, + remote::VerifiedSnapshot { + content_sha256: source_digest(&files), + files, + commit: accepted.commit.clone(), + }, + ); + assert!( + candidate.is_err(), + "stale/reused stable publication must not pass after preview or reset" + ); + assert_eq!(fs::read(fixture.journal()).unwrap(), before); + assert!(Arc::ptr_eq(&active, &restarted.snapshot())); + } + } + let active = restarted.snapshot(); + let rollback = remote_for_channel(&active, 11, AGENT, remote::Channel::Stable); + restarted.activate_remote(&active, rollback).unwrap(); + let recovered = RegistryStore::startup(None, fixture.journal()); + assert_eq!( + recovered + .snapshot() + .remote + .as_ref() + .unwrap() + .pointer + .generation, + 11 + ); + assert_eq!(recovered.snapshot().accepted_remote.len(), 2); + let local = fixture.source("explicit-local", AGENT); + let selected_local = RegistryStore::startup(Some(local), fixture.journal()); + assert_eq!( + selected_local.snapshot().accepted_remote, + recovered.snapshot().accepted_remote + ); + selected_local.reset().unwrap(); + assert_eq!( + RegistryStore::startup(None, fixture.journal()) + .snapshot() + .accepted_remote, + recovered.snapshot().accepted_remote + ); + } + + #[test] + fn concurrent_channel_switch_back_cannot_erase_newer_publication_history() { + let fixture = Fixture::new(); + let store = fixture.store(); + let initial = store.snapshot(); + store + .activate_remote( + &initial, + remote_for_channel(&initial, 10, AGENT, remote::Channel::Stable), + ) + .unwrap(); + let expected = store.snapshot(); + let delayed = remote_for_channel(&expected, 1, AGENT, remote::Channel::Preview); + store + .activate_remote( + &expected, + remote_for_channel(&expected, 10, AGENT, remote::Channel::Preview), + ) + .unwrap(); + let preview = store.snapshot(); + store + .activate_remote( + &preview, + remote_for_channel(&preview, 10, AGENT, remote::Channel::Stable), + ) + .unwrap(); + let before = store.snapshot(); + assert_eq!(before.generation, expected.generation); + assert_eq!(before.remote, expected.remote); + let journal = fs::read(fixture.journal()).unwrap(); + assert!(store + .activate_remote(&expected, delayed) + .unwrap_err() + .contains("changed during download")); + assert_eq!(fs::read(fixture.journal()).unwrap(), journal); + assert!(Arc::ptr_eq(&before, &store.snapshot())); + let restored = RegistryStore::startup(None, fixture.journal()); + assert_eq!( + restored + .snapshot() + .accepted_remote + .iter() + .find(|entry| entry.pointer.channel == remote::Channel::Preview) + .unwrap() + .pointer + .generation, + 10 + ); + } + + #[test] + fn journal_rejects_duplicate_or_unbounded_channel_history() { + let fixture = Fixture::new(); + let store = fixture.store(); + let active = store.snapshot(); + store + .activate_remote(&active, remote_for(&active, 1, AGENT)) + .unwrap(); + let mut journal: serde_json::Value = + serde_json::from_slice(&fs::read(fixture.journal()).unwrap()).unwrap(); + let entry = journal["accepted_remote"][0].clone(); + journal["accepted_remote"] = serde_json::json!([entry.clone(), entry.clone()]); + assert!(serde_json::from_value::(journal.clone()).is_err()); + journal["accepted_remote"] = serde_json::Value::Array( + (0..=MAX_REMOTE_HISTORY) + .map(|i| { + let mut entry = entry.clone(); + entry["origin"] = serde_json::json!(format!("https://registry-{i}.example")); + entry + }) + .collect(), + ); + assert!(serde_json::from_value::(journal).is_err()); + } + + #[test] + fn generation_refresh_during_download_requires_retry_and_failure_keeps_journal() { + let fixture = Fixture::new(); + let store = fixture.store(); + let initial = store.snapshot(); + let candidate = remote_for(&initial, 10, AGENT); + store + .replace_detection(|snapshot| snapshot.manifests.clone()) + .unwrap(); + let journal = fs::read(fixture.journal()).unwrap(); + assert!(store + .activate_remote(&initial, candidate) + .unwrap_err() + .contains("changed during download")); + assert_eq!(fs::read(fixture.journal()).unwrap(), journal); + } + + #[test] + fn remote_persistence_failure_never_publishes_candidate() { + let fixture = Fixture::new(); + let store = fixture.store(); + let initial = store.snapshot(); + fs::write(fixture.0.join("journal"), "not a directory").unwrap(); + assert!(store + .activate_remote(&initial, remote_for(&initial, 10, AGENT)) + .is_err()); + assert!(Arc::ptr_eq(&store.snapshot(), &initial)); + } + + #[test] + fn automatic_and_manual_updates_share_the_nonblocking_download_guard() { + let fixture = Fixture::new(); + let store = fixture.store(); + let active = store.snapshot(); + let _download = store.download_lock.lock().unwrap(); + for channel in [None, Some(remote::Channel::Stable)] { + assert!(store + .update_remote(channel) + .unwrap_err() + .contains("already in progress")); + assert!(Arc::ptr_eq(&active, &store.snapshot())); + assert!(!fixture.journal().exists()); + } + } + + #[test] + fn automatic_updates_follow_the_accepted_channel_and_skip_local_sources() { + let fixture = Fixture::new(); + let store = fixture.store(); + assert_eq!( + store.automatic_update_channel(), + Some(remote::Channel::Stable) + ); + let active = store.snapshot(); + store.publish(remote_for_channel( + &active, + 1, + AGENT, + remote::Channel::Preview, + )); + assert_eq!( + store.automatic_update_channel(), + Some(remote::Channel::Preview) + ); + let source = fixture.source("local", AGENT); + store.reload(Some(&source)).unwrap(); + let before = store.snapshot(); + let journal = fs::read(fixture.journal()).unwrap(); + assert_eq!(store.automatic_update_channel(), None); + assert!(Arc::ptr_eq(&before, &store.snapshot())); + assert_eq!(fs::read(fixture.journal()).unwrap(), journal); + assert!(store.status().last_error.is_none()); + + let selected = RegistryStore::startup(Some(fixture.0.join("missing")), fixture.journal()); + assert!(selected.snapshot().source.is_none()); + assert_eq!(selected.automatic_update_channel(), None); + } + + #[test] + fn configured_local_source_never_recovers_an_unrelated_journal() { + let fixture = Fixture::new(); + let store = fixture.store(); + let selected_a = fixture.source("a", AGENT); + store.reload(Some(&selected_a)).unwrap(); + let selected_b = fixture.source( + "b", + &AGENT.replace("aliases = []", "aliases = ['selected-b']"), + ); + let restarted = RegistryStore::startup(Some(selected_b.clone()), fixture.journal()); + assert_eq!( + restarted.status().source, + Some(fs::canonicalize(selected_b).unwrap()) + ); + assert!(restarted + .snapshot() + .profile_by_normalized_alias("selected-b") + .is_some()); + assert!(restarted + .status() + .last_error + .unwrap() + .contains("different selected source")); + } + + #[test] + fn source_reload_is_complete_persisted_and_old_arc_remains_valid() { + let fixture = Fixture::new(); + let store = fixture.store(); + let old = store.snapshot(); + assert!( + !fixture.journal().exists(), + "initialization must never write" + ); + let root = fixture.source( + "source", + &AGENT.replace("aliases = []", "aliases = ['future-alias']"), + ); + let status = store.reload(Some(&root)).unwrap(); + assert_eq!(status.generation, 2); + assert_eq!(store.generation.load(Ordering::Acquire), 2); + assert_eq!(old.generation, 1); + assert!(old.profile_by_normalized_alias("future-alias").is_none()); + assert!(store + .snapshot() + .profile_by_normalized_alias("future-alias") + .is_some()); + assert!(store.snapshot().profile_by_id("claude").is_none()); + assert!(!Arc::ptr_eq(&old, &store.snapshot())); + let persisted = read_journal(&fixture.journal()).unwrap().unwrap(); + assert_eq!(persisted.digest, status.digest); + assert_eq!(persisted.generation, 2); + assert_eq!( + persisted.files[0].content, + AGENT.replace("aliases = []", "aliases = ['future-alias']") + ); + } + + #[test] + fn identical_bytes_keep_generation_but_update_and_retain_provenance() { + let fixture = Fixture::new(); + let store = fixture.store(); + let first = fixture.source("first", AGENT); + let second = fixture.source("second", AGENT); + store.reload(Some(&first)).unwrap(); + let original = store.snapshot(); + let status = store.reload(Some(&second)).unwrap(); + assert_eq!(status.generation, 1); + assert_eq!(status.source, Some(fs::canonicalize(&second).unwrap())); + assert!(Arc::ptr_eq(&original.registry, &store.snapshot().registry)); + fs::write( + second.join(PATH), + AGENT.replace("Future agent", "New display name"), + ) + .unwrap(); + assert_eq!(store.reload(None).unwrap().generation, 2); + } + + #[test] + fn invalid_read_and_semantic_candidates_preserve_exact_active_arc_and_journal() { + let fixture = Fixture::new(); + let store = fixture.store(); + let source = fixture.source("source", AGENT); + store.reload(Some(&source)).unwrap(); + let old = store.snapshot(); + let journal = fs::read(fixture.journal()).unwrap(); + assert!(store.reload(Some(&fixture.0.join("missing"))).is_err()); + assert!(Arc::ptr_eq(&old, &store.snapshot())); + fs::write(source.join(PATH), "schema = 999").unwrap(); + assert!(store.reload(None).is_err()); + assert!(Arc::ptr_eq(&old, &store.snapshot())); + assert_eq!(fs::read(fixture.journal()).unwrap(), journal); + assert!(store.status().last_error.is_some()); + fs::write(source.join(PATH), AGENT).unwrap(); + assert!(store.reload(None).unwrap().last_error.is_none()); + } + + #[test] + fn invalid_detection_candidate_cannot_publish() { + let fixture = Fixture::new(); + let store = fixture.store(); + let source = fixture.source("source", AGENT); + fs::write(source.join("agents/future-agent/detection.toml"), + "id = 'future-agent'\nversion = '2026.01.01.1'\nmin_engine_version = 1\nupdated_at = '2026-01-01T00:00:00Z'\n[[rules]]\nid = 'idle'\nstate = 'idle'\nregex = ['[']\n").unwrap(); + let old = store.snapshot(); + assert!(store.reload(Some(&source)).is_err()); + assert!(Arc::ptr_eq(&old, &store.snapshot())); + assert!(!fixture.journal().exists()); + } + + #[test] + fn journal_write_failure_preserves_active_and_retained_source() { + let fixture = Fixture::new(); + let store = fixture.store(); + let source = fixture.source("source", &AGENT.replace("Future agent", "Changed")); + let old = store.snapshot(); + fs::write(fixture.journal().parent().unwrap(), b"not a directory").unwrap(); + assert!(store.reload(Some(&source)).is_err()); + assert!(Arc::ptr_eq(&old, &store.snapshot())); + assert!(store.configured_source.lock().unwrap().is_none()); + assert_eq!(store.generation.load(Ordering::Acquire), 1); + } + + #[test] + fn missing_relative_source_keeps_canonical_parent_identity() { + let fixture = Fixture::new(); + let name = fixture.0.file_name().unwrap(); + let relative = PathBuf::from(name).join("missing-source"); + let canonical = fs::canonicalize(std::env::current_dir().unwrap()).unwrap(); + assert!(!relative.exists()); + assert_eq!(source_identity(&relative), canonical.join(&relative)); + assert_ne!( + source_identity(&relative), + source_identity(&relative.with_file_name("different-source")) + ); + } + + #[test] + fn restart_prefers_valid_journal_even_when_source_is_deleted_or_invalid() { + let fixture = Fixture::new(); + let store = fixture.store(); + let source = fixture.source("source", &AGENT.replace("Future agent", "Saved")); + let saved = store.reload(Some(&source)).unwrap(); + let bytes = fs::read(fixture.journal()).unwrap(); + fs::remove_dir_all(&source).unwrap(); + assert_eq!( + source_identity(&source), + source_identity(saved.source.as_ref().unwrap()) + ); + let restored = RegistryStore::startup(Some(source.clone()), fixture.journal()); + assert_eq!(restored.status().digest, saved.digest); + assert_eq!(restored.status().generation, saved.generation); + assert_eq!( + restored.snapshot().files[0].1, + AGENT.replace("Future agent", "Saved") + ); + assert!(restored.snapshot().profile_by_id("claude").is_none()); + assert_eq!(fs::read(fixture.journal()).unwrap(), bytes); + fixture.source("source", "schema = 999"); + let restored = RegistryStore::startup(Some(source), fixture.journal()); + assert_eq!(restored.status().digest, saved.digest); + assert_eq!(fs::read(fixture.journal()).unwrap(), bytes); + } + + #[test] + fn startup_source_is_complete_and_read_only_without_a_journal() { + let fixture = Fixture::new(); + let source = fixture.source("source", AGENT); + let store = RegistryStore::startup(Some(source), fixture.journal()); + assert!(store.snapshot().profile_by_id("future-agent").is_some()); + assert!(store.snapshot().profile_by_id("claude").is_none()); + assert!(!fixture.journal().exists()); + } + + #[test] + fn invalid_startup_source_falls_back_to_bundled_and_records_error_without_writes() { + let fixture = Fixture::new(); + let store = RegistryStore::startup(Some(fixture.0.join("missing")), fixture.journal()); + assert!(store.snapshot().profile_by_id("claude").is_some()); + assert!(store.status().source.is_none()); + assert!(store.status().last_error.unwrap().contains("using bundled")); + assert!(!fixture.journal().exists()); + } + + #[test] + fn journal_requires_hashes_and_semantics_not_just_valid_json() { + let fixture = Fixture::new(); + let store = fixture.store(); + let source = fixture.source("source", AGENT); + store.reload(Some(&source)).unwrap(); + let bytes = fs::read(fixture.journal()).unwrap(); + let mut journal: Journal = serde_json::from_slice(&bytes).unwrap(); + journal.files[0].content = "schema = 999".into(); + fs::write(fixture.journal(), serde_json::to_vec(&journal).unwrap()).unwrap(); + assert!(read_journal(&fixture.journal()) + .unwrap_err() + .contains("hash")); + journal.files[0].sha256 = hash(&journal.files[0].content); + fs::write(fixture.journal(), serde_json::to_vec(&journal).unwrap()).unwrap(); + assert!(read_journal(&fixture.journal()) + .unwrap_err() + .contains("digest")); + journal.digest = source_digest(&vec![(PATH.into(), journal.files[0].content.clone())]); + fs::write(fixture.journal(), serde_json::to_vec(&journal).unwrap()).unwrap(); + assert!(read_journal(&fixture.journal()) + .unwrap() + .unwrap() + .into_snapshot() + .is_err()); + } + + #[test] + fn journal_selection_requires_valid_matching_local_provenance() { + let fixture = Fixture::new(); + let store = fixture.store(); + let source = fixture.source("source", AGENT); + store.reload(Some(&source)).unwrap(); + let bytes = fs::read(fixture.journal()).unwrap(); + for selected in [PathBuf::from("relative"), fixture.0.join("different")] { + let mut journal: Journal = serde_json::from_slice(&bytes).unwrap(); + journal.selected_source = Some(selected); + fs::write(fixture.journal(), serde_json::to_vec(&journal).unwrap()).unwrap(); + assert!(read_journal(&fixture.journal()).is_err()); + } + let mut journal: Journal = serde_json::from_slice(&bytes).unwrap(); + journal.source = None; + let revision = remote_for(&store.snapshot(), 1, AGENT).remote.unwrap(); + journal.remote = Some(revision.clone()); + journal.accepted_remote = vec![revision]; + fs::write(fixture.journal(), serde_json::to_vec(&journal).unwrap()).unwrap(); + assert!(read_journal(&fixture.journal()) + .unwrap_err() + .contains("local and remote")); + } + + #[test] + fn corrupt_journal_is_not_overwritten_during_fallback() { + let fixture = Fixture::new(); + fs::create_dir_all(fixture.journal().parent().unwrap()).unwrap(); + fs::write(fixture.journal(), b"corrupt").unwrap(); + let source = fixture.source("source", AGENT); + let store = RegistryStore::startup(Some(source), fixture.journal()); + assert!(store + .status() + .last_error + .unwrap() + .contains("last-known-good")); + assert_eq!(fs::read(fixture.journal()).unwrap(), b"corrupt"); + } + + #[test] + fn reload_busy_does_not_queue_and_detection_replacement_invalidates_generation() { + let fixture = Fixture::new(); + let store = fixture.store(); + let old = store.snapshot(); + let guard = store.reload_lock.lock().unwrap(); + assert!(store.reload(None).unwrap_err().contains("busy")); + assert!(store + .replace_detection(|s| s.manifests.clone()) + .unwrap_err() + .contains("busy")); + assert!(Arc::ptr_eq(&old, &store.snapshot())); + drop(guard); + store.replace_detection(|s| s.manifests.clone()).unwrap(); + let current = store.snapshot(); + assert_eq!(current.generation, old.generation + 1); + assert_eq!(store.generation.load(Ordering::Acquire), current.generation); + assert_eq!(current.digest, old.digest); + assert!(Arc::ptr_eq(¤t.registry, &old.registry)); + assert_eq!( + read_journal(&fixture.journal()) + .unwrap() + .unwrap() + .generation, + current.generation + ); + } + + #[test] + fn detection_persistence_failure_also_preserves_active_arc() { + let fixture = Fixture::new(); + let store = fixture.store(); + let old = store.snapshot(); + fs::create_dir_all(fixture.journal()).unwrap(); + assert!(store.replace_detection(|s| s.manifests.clone()).is_err()); + assert!(Arc::ptr_eq(&old, &store.snapshot())); + } + + #[test] + fn errors_are_utf8_bounded_and_generation_never_wraps() { + let error = bounded_error("é".repeat(5000)); + assert!(error.len() <= MAX_ERROR_BYTES); + assert!(error.ends_with("...")); + assert!(next_generation(u64::MAX).is_err()); + } + + #[test] + fn journal_inventory_and_encoded_size_are_bounded() { + let fixture = Fixture::new(); + fs::create_dir_all(fixture.journal().parent().unwrap()).unwrap(); + let file = fs::File::create(fixture.journal()).unwrap(); + file.set_len(MAX_JOURNAL_BYTES + 1).unwrap(); + assert!(read_journal(&fixture.journal()) + .unwrap_err() + .contains("bounded")); + drop(file); + let record = JournalFile { + path: PATH.into(), + content: String::new(), + sha256: hash(""), + }; + let record = serde_json::to_string(&record).unwrap(); + let text = format!( + r#"{{"schema":1,"generation":1,"digest":"","source":null,"files":[{}]}}"#, + vec![record; files::MAX_FILES + 1].join(",") + ); + fs::write(fixture.journal(), text).unwrap(); + assert!(read_journal(&fixture.journal()) + .unwrap_err() + .contains("limits")); + } + + #[cfg(unix)] + #[test] + fn journal_symlinks_and_nonregular_files_are_rejected() { + let fixture = Fixture::new(); + fs::create_dir_all(fixture.journal().parent().unwrap()).unwrap(); + let target = fixture.0.join("target"); + fs::write(&target, b"untouched").unwrap(); + std::os::unix::fs::symlink(&target, fixture.journal()).unwrap(); + assert!(read_journal(&fixture.journal()).is_err()); + let store = fixture.store(); + assert!(store.replace_detection(|s| s.manifests.clone()).is_err()); + assert_eq!(fs::read(target).unwrap(), b"untouched"); + fs::remove_file(fixture.journal()).unwrap(); + let _socket = std::os::unix::net::UnixListener::bind(fixture.journal()).unwrap(); + assert!(read_journal(&fixture.journal()).is_err()); + } + + fn compiled_integration_source(fixture: &Fixture) -> (RegistryStore, PathBuf) { + let files: SourceFiles = bundled::FILES + .iter() + .filter(|(path, _)| path.starts_with("agents/claude/")) + .map(|(path, text)| ((*path).into(), (*text).into())) + .collect(); + let source = fixture.0.join("compiled-source"); + for (path, text) in &files { + let path = source.join(path); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, text).unwrap(); + } + let store = RegistryStore::new(files, fixture.journal()).unwrap(); + store.reload(Some(&source)).unwrap(); // establish durable LKG first + (store, source) + } + + fn save_obsolete_integration_journal(fixture: &Fixture, managed: bool) -> Vec { + let mut journal: Journal = + serde_json::from_slice(&fs::read(fixture.journal()).unwrap()).unwrap(); + let asset = journal + .files + .iter_mut() + .find(|file| file.path == "agents/claude/assets/herdr-agent-state.ps1") + .unwrap(); + // Model bytes accepted by an older binary but incompatible with this one. + asset + .content + .push_str("\n# previous compiled integration\n"); + asset.sha256 = hash(&asset.content); + journal.digest = source_digest( + &journal + .files + .iter() + .map(|file| (file.path.clone(), file.content.clone())) + .collect(), + ); + let revision = remote_for(&fixture.store().snapshot(), 20, AGENT) + .remote + .unwrap(); + journal.accepted_remote = vec![revision.clone()]; + if managed { + journal.source = None; + journal.remote = Some(revision); + } + let mut value = serde_json::to_value(journal).unwrap(); + // Exercise migration from the original journal without a separate selection field. + value.as_object_mut().unwrap().remove("selected_source"); + let bytes = serde_json::to_vec(&value).unwrap(); + fs::write(fixture.journal(), &bytes).unwrap(); + bytes + } + + #[test] + fn incompatible_journal_preserves_local_selection_through_fallback_and_refresh() { + let fixture = Fixture::new(); + let (old, source) = compiled_integration_source(&fixture); + let selected = old.status().source.unwrap(); + let files = old.snapshot().files.clone(); + let bytes = save_obsolete_integration_journal(&fixture, false); + fs::remove_dir_all(&source).unwrap(); + let recovered = RegistryStore::startup(None, fixture.journal()); + assert!(recovered + .status() + .last_error + .unwrap() + .contains("without changing its version")); + assert!( + recovered.snapshot().source.is_none(), + "active fallback is bundled, not local" + ); + assert_eq!( + *recovered.configured_source.lock().unwrap(), + Some(selected.clone()) + ); + assert_eq!(recovered.automatic_update_channel(), None); + assert_eq!( + recovered.snapshot().accepted_remote[0].pointer.generation, + 20 + ); + assert_eq!( + fs::read(fixture.journal()).unwrap(), + bytes, + "startup is read-only" + ); + + recovered + .replace_detection(|snapshot| snapshot.manifests.clone()) + .unwrap(); + let restarted = RegistryStore::startup(None, fixture.journal()); + assert_eq!(*restarted.configured_source.lock().unwrap(), Some(selected)); + assert_eq!(restarted.automatic_update_channel(), None); + assert_eq!( + restarted.snapshot().accepted_remote[0].pointer.generation, + 20 + ); + + for (path, text) in files.iter() { + let path = source.join(path); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, text).unwrap(); + } + let repaired = RegistryStore::startup(None, fixture.journal()); + assert_eq!( + repaired.status().source, + Some(fs::canonicalize(&source).unwrap()) + ); + assert_eq!(repaired.snapshot().known_profiles().count(), 1); + repaired.reset().unwrap(); + let reset = RegistryStore::startup(None, fixture.journal()); + assert!(reset.automatic_update_channel().is_some()); + assert_eq!(reset.snapshot().accepted_remote[0].pointer.generation, 20); + } + + #[test] + fn incompatible_journal_preserves_remote_history_until_compatible_roll_forward() { + let fixture = Fixture::new(); + compiled_integration_source(&fixture); + let bytes = save_obsolete_integration_journal(&fixture, true); + let recovered = RegistryStore::startup(None, fixture.journal()); + assert!(recovered + .status() + .last_error + .unwrap() + .contains("without changing its version")); + assert!( + recovered.snapshot().remote.is_none(), + "do not label bundled bytes as remote" + ); + assert_eq!(recovered.snapshot().accepted_remote.len(), 1); + assert_eq!(fs::read(fixture.journal()).unwrap(), bytes); + recovered + .replace_detection(|snapshot| snapshot.manifests.clone()) + .unwrap(); + let restarted = RegistryStore::startup(None, fixture.journal()); + let active = restarted.snapshot(); + let accepted = active.accepted_remote[0].clone(); + for generation in [19, 20] { + let mut revision = accepted.clone(); + revision.pointer.generation = generation; + revision.pointer.snapshot_sha256 = "f".repeat(64); + let files = vec![(PATH.into(), AGENT.into())]; + assert!( + remote_candidate( + &active, + revision, + remote::VerifiedSnapshot { + content_sha256: source_digest(&files), + files, + commit: accepted.commit.clone(), + } + ) + .is_err(), + "reject downgrade and equal-generation replacement after upgrade" + ); + } + let next = remote_for(&active, 21, AGENT); + restarted.activate_remote(&active, next).unwrap(); + assert_eq!( + RegistryStore::startup(None, fixture.journal()) + .snapshot() + .remote + .as_ref() + .unwrap() + .pointer + .generation, + 21 + ); + } + + #[test] + fn versioned_integration_update_activates_and_survives_restart_without_installing() { + let fixture = Fixture::new(); + let (store, source) = compiled_integration_source(&fixture); + let old = store.snapshot(); + let journal = fs::read(fixture.journal()).unwrap(); + let path = source.join("agents/claude/integration.toml"); + let metadata = fs::read_to_string(&path).unwrap(); + let definition: super::super::source::IntegrationDefinition = + toml::from_str(&metadata).unwrap(); + let previous = definition.versions.unix; + assert_eq!(previous, definition.versions.windows); + let next = previous + 1; + fs::write( + path, + metadata + .replace(&format!("unix = {previous}"), &format!("unix = {next}")) + .replace( + &format!("windows = {previous}"), + &format!("windows = {next}"), + ), + ) + .unwrap(); + for asset in definition.assets { + let path = source.join("agents/claude").join(asset.path); + let text = fs::read_to_string(&path).unwrap(); + fs::write( + path, + text.replace( + &format!("HERDR_INTEGRATION_VERSION={previous}"), + &format!("HERDR_INTEGRATION_VERSION={next}"), + ), + ) + .unwrap(); + } + let files = files::read_source(&source).unwrap(); + let borrowed: Vec<_> = files + .iter() + .map(|(p, t)| (p.as_str(), t.as_str())) + .collect(); + assert!( + super::super::validate_packages(&borrowed).is_ok(), + "offline extraction must accept new assets/versions" + ); + let status = store.reload(None).unwrap(); + assert!(status.generation > old.generation); + assert_ne!(fs::read(fixture.journal()).unwrap(), journal); + let restarted = RegistryStore::startup(None, fixture.journal()); + let current = restarted.snapshot(); + let profile = current + .profile_by_id("claude") + .unwrap() + .integration() + .unwrap(); + assert_eq!(profile.expected_version(), next); + let asset = profile + .asset(crate::integration::builtin::claude::HOOK_INSTALL_NAME) + .unwrap(); + assert_eq!( + crate::integration::parse_integration_version(asset), + Some(next) + ); + assert_eq!( + old.profile_by_id("claude") + .unwrap() + .integration() + .unwrap() + .expected_version(), + previous + ); + let untouched = fs::read(fixture.journal()).unwrap(); + let asset_path = source.join("agents/claude/assets/herdr-agent-state.ps1"); + fs::write( + &asset_path, + format!( + "{}\n# reused hot version", + fs::read_to_string(&asset_path).unwrap() + ), + ) + .unwrap(); + assert!(store + .reload(None) + .unwrap_err() + .contains("without changing its version")); + assert_eq!(fs::read(fixture.journal()).unwrap(), untouched); + } + + #[test] + fn compiled_integration_asset_change_is_rejected_even_with_unchanged_version() { + let fixture = Fixture::new(); + let (store, source) = compiled_integration_source(&fixture); + let old = store.snapshot(); + let journal = fs::read(fixture.journal()).unwrap(); + // Check the non-host platform too: the safety boundary is portable. + let path = source.join("agents/claude/assets/herdr-agent-state.ps1"); + let mut text = fs::read_to_string(&path).unwrap(); + text.push_str("\n# changed implementation without version bump\n"); + fs::write(path, text).unwrap(); + let files = files::read_source(&source).unwrap(); + let borrowed: Vec<_> = files + .iter() + .map(|(p, t)| (p.as_str(), t.as_str())) + .collect(); + assert!(super::super::validate_packages(&borrowed).is_ok()); + let error = store.reload(None).unwrap_err(); + assert!( + error.contains("integration asset") && error.contains("without changing its version") + ); + assert!(Arc::ptr_eq(&old, &store.snapshot())); + assert_eq!(fs::read(fixture.journal()).unwrap(), journal); + } + + #[test] + fn newer_integration_assets_still_require_identity_and_version_markers() { + let mut packages = super::super::validate_packages(bundled::FILES).unwrap(); + packages.retain(|package| package.identity.id == "pi"); + let definition = packages[0].integration.as_mut().unwrap(); + let previous = definition.versions.unix; + definition.versions.unix += 1; + definition.versions.windows += 1; + for text in packages[0].assets.values_mut() { + *text = text.replace( + &format!("HERDR_INTEGRATION_VERSION={previous}"), + &format!("HERDR_INTEGRATION_VERSION={}", previous + 1), + ); + } + assert!(validate_integration_baseline(&packages).is_ok()); + for marker in ["HERDR_INTEGRATION_ID=", "HERDR_INTEGRATION_VERSION="] { + let mut missing = packages.clone(); + let text = missing[0] + .assets + .get_mut("assets/herdr-agent-state.ts") + .unwrap(); + *text = text + .lines() + .filter(|line| !line.contains(marker)) + .collect::>() + .join("\n"); + assert!(validate_integration_baseline(&missing) + .unwrap_err() + .contains("requires identity and version markers")); + } + } + + #[test] + fn unchanged_compiled_assets_allow_data_driven_integration_labels_and_commands() { + let fixture = Fixture::new(); + let (store, source) = compiled_integration_source(&fixture); + let path = source.join("agents/claude/integration.toml"); + let metadata = fs::read_to_string(&path) + .unwrap() + .replace("\"claude\"", "\"claude-local\""); + fs::write(path, metadata).unwrap(); + let status = store.reload(None).unwrap(); + assert_eq!(status.generation, 2); + let active = store.snapshot(); + let integration = active + .profile_by_id("claude") + .unwrap() + .integration() + .unwrap(); + assert_eq!(integration.cli_label(), "claude-local"); + assert_eq!(integration.command_names(), ["claude-local"]); + } + + #[test] + fn unknown_integration_metadata_remains_valid_but_has_no_compiled_installer() { + let fixture = Fixture::new(); + let integration = "cli_name = 'future-agent'\naliases = []\n[commands]\nunix = ['future-agent']\nwindows = ['future-agent']\n[supported]\nunix = true\nwindows = true\n[versions]\nunix = 2\nwindows = 2\n[[assets]]\npath = 'assets/reporter.js'\nplatform = 'all'\nrole = 'reporter'\ninstall_name = 'reporter.js'\n"; + let store = RegistryStore::new( + vec![ + (PATH.into(), AGENT.into()), + ( + "agents/future-agent/integration.toml".into(), + integration.into(), + ), + ( + "agents/future-agent/assets/reporter.js".into(), + "// HERDR_INTEGRATION_ID=future-agent\n// HERDR_INTEGRATION_VERSION=2\n".into(), + ), + ], + fixture.journal(), + ) + .unwrap(); + assert!(store + .snapshot() + .profile_by_id("future-agent") + .unwrap() + .integration() + .is_none()); + } + + #[test] + fn status_capabilities_describe_only_the_captured_active_registry() { + let fixture = Fixture::new(); + let store = fixture.store(); + let status = store.status(); + assert_eq!(status.agents.len(), 1); + let agent = &status.agents[0]; + assert_eq!(agent.id, "future-agent"); + assert!(agent.startable); + assert!(!agent.process && !agent.detection && !agent.resume && !agent.integration); + let decoded: RegistryStatus = + serde_json::from_slice(&serde_json::to_vec(&status).unwrap()).unwrap(); + assert_eq!(decoded.digest, status.digest); + } + + #[test] + fn digest_matches_offline_vendor_pin() { + let files = bundled::FILES + .iter() + .map(|(p, t)| ((*p).into(), (*t).into())) + .collect(); + let lock: serde_json::Value = + serde_json::from_str(include_str!("../../vendor/agent-registry/lock.json")).unwrap(); + assert_eq!(source_digest(&files), lock["sha256"].as_str().unwrap()); + } +} diff --git a/src/agents/tests.rs b/src/agents/tests.rs new file mode 100644 index 0000000000..c40de8b037 --- /dev/null +++ b/src/agents/tests.rs @@ -0,0 +1,1118 @@ +use super::*; + +#[test] +fn bundled_packages_pass_the_same_full_validation_as_local_source() { + let packages = validate_packages(bundled::FILES).expect("valid bundled registry"); + assert_eq!(packages.len(), Agent::ALL.len()); + let loaded = AgentRegistry::from_packages(packages).expect("valid identities"); + assert_eq!(loaded.known_profiles().len(), Agent::ALL.len()); +} + +#[test] +fn package_removal_preserves_core_report_policy_without_activating_capabilities() { + let absent = AgentRegistry::default(); + let bundled = registry(); + for profile in bundled.known_profiles() { + let id = profile.canonical_id(); + let Some(source) = profile.report().official_source() else { + continue; + }; + assert!(absent.profile_by_id(id).is_none()); + assert!(absent + .session_profile_for_exact_report_pair(source, id) + .is_none()); + assert_eq!( + absent.report_policy_for_exact_pair(source, id), + bundled.report_policy_for_exact_pair(source, id), + ); + assert!(absent + .report_policy_for_exact_pair("herdr:launch", id) + .is_none()); + assert!(absent + .report_policy_for_exact_pair(source, "novel-agent") + .is_none()); + } + assert!(absent.is_reserved_native_state_source("herdr:codex", "codex")); + assert!(absent.has_full_lifecycle_report_authority("herdr:opencode", "opencode")); + assert!(!absent.has_full_lifecycle_report_authority("herdr:opencode", "open-code")); +} + +#[test] +fn novel_source_identity_can_launch_without_gaining_report_or_installer_authority() { + let packages = validate_packages(&[( + "agents/example/agent.toml", + r#" +schema = 1 +id = "example" +name = "Example" +aliases = [] +startable = true +[launch] +unix = "example" +windows = "example" +"#, + )]) + .expect("new package can be parsed without a Rust enum variant"); + assert_eq!(packages[0].identity.id, "example"); + let registry = AgentRegistry::from_packages(packages).expect("valid novel identity"); + let id = Agent::parse("example").expect("canonical identity"); + assert_eq!( + registry + .profile_by_id("example") + .map(|profile| profile.legacy_agent()), + Some(id) + ); + assert!(registry + .profile_by_normalized_process_name("example") + .is_none()); + assert_eq!( + registry + .known_profiles() + .filter(|profile| profile.is_startable()) + .map(|profile| profile.legacy_agent()) + .collect::>(), + vec![id] + ); + assert!(registry.integration_capable_profiles().next().is_none()); + assert!(!registry.has_full_lifecycle_report_authority("herdr:example", "example")); + assert!(registry.profile_by_agent(Agent::Pi).is_none()); +} + +#[test] +fn owned_registry_preserves_identity_indexes_independent_of_input_order() { + let mut packages = validate_packages(bundled::FILES).expect("valid bundled registry"); + packages.reverse(); + let registry = AgentRegistry::from_packages(packages).expect("valid identities"); + for agent in Agent::ALL { + let profile = registry.profile_by_agent(agent).expect("bound identity"); + assert_eq!(profile.canonical_id(), agent.as_str()); + assert!(std::ptr::eq( + profile, + registry.profile_by_id(agent.as_str()).expect("id lookup") + )); + } +} + +const EXPECTED_IDENTITIES: [(Agent, &str, &[&str], &str, &str); 23] = [ + (Agent::Pi, "pi", &[], "pi", "pi"), + ( + Agent::Claude, + "claude", + &["claude-code"], + "claude", + "claude", + ), + (Agent::Codex, "codex", &[], "codex", "codex"), + (Agent::Gemini, "gemini", &[], "gemini", "gemini"), + ( + Agent::Cursor, + "cursor", + &["cursor-agent"], + "cursor-agent", + "cursor-agent.cmd", + ), + ( + Agent::Devin, + "devin", + &["devin-cli", "devin cli"], + "devin", + "devin", + ), + ( + Agent::Antigravity, + "agy", + &["antigravity", "antigravity-cli"], + "agy", + "agy", + ), + (Agent::Cline, "cline", &[], "cline", "cline"), + (Agent::Omp, "omp", &[], "omp", "omp"), + ( + Agent::Mastracode, + "mastracode", + &["mastra-code", "mastra code"], + "mastracode", + "mastracode", + ), + ( + Agent::OpenCode, + "opencode", + &["opencode2", "open-code"], + "opencode", + "opencode", + ), + ( + Agent::GithubCopilot, + "copilot", + &["github-copilot", "ghcs"], + "copilot", + "copilot", + ), + ( + Agent::Kimi, + "kimi", + &["kimi-code", "kimi code"], + "kimi", + "kimi", + ), + (Agent::Kiro, "kiro", &["kiro-cli"], "kiro-cli", "kiro-cli"), + (Agent::Droid, "droid", &[], "droid", "droid"), + (Agent::Amp, "amp", &["amp-local"], "amp", "amp"), + (Agent::Grok, "grok", &["grok-build"], "grok", "grok"), + ( + Agent::Hermes, + "hermes", + &["hermes-agent"], + "hermes", + "hermes", + ), + ( + Agent::Kilo, + "kilo", + &["kilo-code", "kilo code"], + "kilo", + "kilo", + ), + ( + Agent::Qodercli, + "qodercli", + &["qoderclicn", "qoder", "qodercn"], + "qodercli", + "qodercli", + ), + ( + Agent::Qwen, + "qwen", + &["qwen-code", "qwen code"], + "qwen", + "qwen", + ), + (Agent::Maki, "maki", &[], "maki", "maki"), + ( + Agent::Muse, + "muse", + &["muse-code", "muse-cli"], + "muse", + "muse", + ), +]; + +type ExpectedReportPolicy = ( + Agent, + Option<&'static str>, + ReportAuthority, + bool, + &'static [&'static str], + bool, + Option<&'static str>, + bool, +); + +const EXPECTED_REPORT_POLICIES: [ExpectedReportPolicy; 23] = [ + ( + Agent::Pi, + Some("herdr:pi"), + ReportAuthority::FullLifecycle, + false, + &["new", "resume", "fork"], + false, + None, + false, + ), + ( + Agent::Claude, + Some("herdr:claude"), + ReportAuthority::None, + true, + &["clear", "resume", "compact"], + false, + None, + false, + ), + ( + Agent::Codex, + Some("herdr:codex"), + ReportAuthority::None, + true, + &["startup", "clear", "resume", "compact"], + false, + None, + false, + ), + ( + Agent::Gemini, + None, + ReportAuthority::None, + false, + &[], + false, + None, + false, + ), + ( + Agent::Cursor, + Some("herdr:cursor"), + ReportAuthority::None, + true, + &[], + false, + None, + false, + ), + ( + Agent::Devin, + Some("herdr:devin"), + ReportAuthority::None, + true, + &[], + false, + None, + false, + ), + ( + Agent::Antigravity, + Some("herdr:antigravity_cli"), + ReportAuthority::SessionIdentityOnly, + false, + &[], + true, + None, + false, + ), + ( + Agent::Cline, + None, + ReportAuthority::None, + false, + &[], + false, + None, + false, + ), + ( + Agent::Omp, + Some("herdr:omp"), + ReportAuthority::FullLifecycle, + false, + &["startup", "new", "resume", "fork"], + false, + None, + false, + ), + ( + Agent::Mastracode, + Some("herdr:mastracode"), + ReportAuthority::FullLifecycle, + false, + &["startup"], + false, + None, + true, + ), + ( + Agent::OpenCode, + Some("herdr:opencode"), + ReportAuthority::FullLifecycle, + false, + &["select"], + false, + Some("select"), + false, + ), + ( + Agent::GithubCopilot, + Some("herdr:copilot"), + ReportAuthority::None, + true, + &[], + false, + None, + false, + ), + ( + Agent::Kimi, + Some("herdr:kimi"), + ReportAuthority::FullLifecycle, + false, + &[], + false, + None, + false, + ), + ( + Agent::Kiro, + None, + ReportAuthority::None, + false, + &[], + false, + None, + false, + ), + ( + Agent::Droid, + Some("herdr:droid"), + ReportAuthority::None, + true, + &[], + false, + None, + false, + ), + ( + Agent::Amp, + None, + ReportAuthority::None, + false, + &[], + false, + None, + false, + ), + ( + Agent::Grok, + Some("herdr:grok"), + ReportAuthority::None, + true, + &[], + false, + None, + false, + ), + ( + Agent::Hermes, + Some("herdr:hermes"), + ReportAuthority::SessionIdentityOnly, + false, + &["startup", "new", "resume"], + false, + None, + false, + ), + ( + Agent::Kilo, + Some("herdr:kilo"), + ReportAuthority::FullLifecycle, + false, + &[], + false, + None, + false, + ), + ( + Agent::Qodercli, + Some("herdr:qodercli"), + ReportAuthority::None, + true, + &[], + false, + None, + false, + ), + ( + Agent::Qwen, + Some("herdr:qwen"), + ReportAuthority::SessionIdentityOnly, + true, + &["startup", "clear", "resume", "compact", "branch"], + false, + None, + false, + ), + ( + Agent::Maki, + None, + ReportAuthority::None, + false, + &[], + false, + None, + false, + ), + ( + Agent::Muse, + None, + ReportAuthority::None, + false, + &[], + false, + None, + false, + ), +]; + +const EXPECTED_SCREEN_DETECTABLE: [Agent; 21] = [ + Agent::Pi, + Agent::Claude, + Agent::Codex, + Agent::Gemini, + Agent::Cursor, + Agent::Devin, + Agent::Antigravity, + Agent::Cline, + Agent::OpenCode, + Agent::GithubCopilot, + Agent::Kimi, + Agent::Kiro, + Agent::Droid, + Agent::Amp, + Agent::Grok, + Agent::Hermes, + Agent::Kilo, + Agent::Qodercli, + Agent::Qwen, + Agent::Maki, + Agent::Muse, +]; + +const EXPECTED_RESUMABLE: [Agent; 17] = [ + Agent::Pi, + Agent::Claude, + Agent::Codex, + Agent::Cursor, + Agent::Devin, + Agent::Antigravity, + Agent::Omp, + Agent::Mastracode, + Agent::OpenCode, + Agent::GithubCopilot, + Agent::Kimi, + Agent::Droid, + Agent::Grok, + Agent::Hermes, + Agent::Kilo, + Agent::Qodercli, + Agent::Qwen, +]; + +const EXPECTED_INTEGRATION_CAPABLE: [Agent; 17] = [ + Agent::Pi, + Agent::Omp, + Agent::Claude, + Agent::Codex, + Agent::GithubCopilot, + Agent::Devin, + Agent::Droid, + Agent::Kimi, + Agent::OpenCode, + Agent::Kilo, + Agent::Hermes, + Agent::Qodercli, + Agent::Qwen, + Agent::Cursor, + Agent::Mastracode, + Agent::Antigravity, + Agent::Grok, +]; + +type ExpectedIntegrationProfile = ( + IntegrationTarget, + Agent, + &'static str, + &'static [&'static str], + &'static [&'static str], + &'static [&'static str], + u32, + u32, +); + +const EXPECTED_INTEGRATION_PROFILES: [ExpectedIntegrationProfile; 17] = [ + ( + IntegrationTarget::Pi, + Agent::Pi, + "pi", + &[], + &["pi"], + &["pi"], + 9, + 9, + ), + ( + IntegrationTarget::Omp, + Agent::Omp, + "omp", + &[], + &["omp"], + &["omp"], + 9, + 9, + ), + ( + IntegrationTarget::Claude, + Agent::Claude, + "claude", + &[], + &["claude"], + &["claude"], + 9, + 9, + ), + ( + IntegrationTarget::Codex, + Agent::Codex, + "codex", + &[], + &["codex"], + &["codex"], + 8, + 8, + ), + ( + IntegrationTarget::Copilot, + Agent::GithubCopilot, + "copilot", + &[], + &["copilot"], + &["copilot"], + 3, + 3, + ), + ( + IntegrationTarget::Devin, + Agent::Devin, + "devin", + &[], + &["devin"], + &["devin"], + 2, + 2, + ), + ( + IntegrationTarget::Droid, + Agent::Droid, + "droid", + &[], + &["droid"], + &["droid"], + 3, + 3, + ), + ( + IntegrationTarget::Kimi, + Agent::Kimi, + "kimi", + &[], + &["kimi"], + &["kimi"], + 7, + 7, + ), + ( + IntegrationTarget::Opencode, + Agent::OpenCode, + "opencode", + &[], + &["opencode"], + &["opencode"], + 12, + 12, + ), + ( + IntegrationTarget::Kilo, + Agent::Kilo, + "kilo", + &[], + &["kilo", "kilo-code"], + &["kilo", "kilo-code"], + 4, + 4, + ), + ( + IntegrationTarget::Hermes, + Agent::Hermes, + "hermes", + &[], + &["hermes"], + &["hermes"], + 5, + 5, + ), + ( + IntegrationTarget::Qodercli, + Agent::Qodercli, + "qodercli", + &[], + &["qodercli"], + &["qodercli", "qoder", "qoderclicn", "qodercn"], + 3, + 3, + ), + ( + IntegrationTarget::Qwen, + Agent::Qwen, + "qwen", + &[], + &["qwen"], + &["qwen"], + 1, + 1, + ), + ( + IntegrationTarget::Cursor, + Agent::Cursor, + "cursor", + &[], + &["cursor-agent"], + &["cursor-agent"], + 1, + 1, + ), + ( + IntegrationTarget::Mastracode, + Agent::Mastracode, + "mastracode", + &[], + &["mastracode"], + &["mastracode"], + 2, + 2, + ), + ( + IntegrationTarget::AntigravityCli, + Agent::Antigravity, + "antigravity-cli", + &["antigravity_cli"], + &["agy"], + &["agy"], + 3, + 3, + ), + ( + IntegrationTarget::Grok, + Agent::Grok, + "grok", + &[], + &["grok"], + &["grok"], + 1, + 1, + ), +]; + +fn profile_agents<'a>(profiles: impl Iterator) -> Vec { + profiles.map(|profile| profile.legacy_agent()).collect() +} + +#[test] +fn profiles_preserve_exact_identity_order_aliases_and_executables() { + let registry = registry(); + assert_eq!(registry.known_profiles().len(), EXPECTED_IDENTITIES.len()); + + for (profile, (agent, id, aliases, unix_executable, windows_executable)) in + registry.known_profiles().zip(EXPECTED_IDENTITIES) + { + assert_eq!(profile.legacy_agent(), agent); + assert_eq!(profile.canonical_id(), id); + assert_eq!(profile.aliases(), aliases); + assert_eq!(profile.launch().unix, unix_executable); + assert_eq!(profile.launch().windows, windows_executable); + + #[cfg(not(windows))] + assert_eq!(profile.launch().executable(), unix_executable); + #[cfg(windows)] + assert_eq!(profile.launch().executable(), windows_executable); + } +} + +#[test] +fn canonical_ids_and_lookup_names_are_unique() { + let registry = registry(); + + for (index, profile) in registry.known_profiles().enumerate() { + assert!(!profile.canonical_id().is_empty()); + assert!(registry + .profile_by_agent(profile.legacy_agent()) + .is_some_and(|candidate| std::ptr::eq(candidate, profile))); + assert!(registry + .profile_by_id(profile.canonical_id()) + .is_some_and(|candidate| std::ptr::eq(candidate, profile))); + assert!(registry + .profile_by_normalized_alias(profile.canonical_id()) + .is_some_and(|candidate| std::ptr::eq(candidate, profile))); + + for other in registry.known_profiles().skip(index + 1) { + assert_ne!(profile.canonical_id(), other.canonical_id()); + assert_ne!(profile.legacy_agent(), other.legacy_agent()); + } + + for (alias_index, alias) in profile.aliases().iter().enumerate() { + assert!(!alias.is_empty()); + assert!(registry + .profile_by_normalized_alias(alias) + .is_some_and(|candidate| std::ptr::eq(candidate, profile))); + assert!(registry.profile_by_id(alias).is_none()); + assert!(registry + .known_profiles() + .all(|other| other.canonical_id() != *alias)); + + for other_alias in profile.aliases().iter().skip(alias_index + 1) { + assert_ne!(alias, other_alias); + } + for other in registry.known_profiles().skip(index + 1) { + assert!(!other.aliases().contains(alias)); + } + } + } + + assert!(registry.profile_by_id("Claude").is_none()); + assert!(registry.profile_by_normalized_alias(" claude ").is_none()); + assert!(registry.profile_by_normalized_alias("CLAUDE").is_none()); + assert!(registry.profile_by_normalized_alias("unknown").is_none()); +} + +#[test] +fn report_policy_matrix_preserves_exact_official_pairs_and_replacement_rules() { + const EVENTS: [Option<&str>; 10] = [ + None, + Some("startup"), + Some("clear"), + Some("resume"), + Some("compact"), + Some("branch"), + Some("new"), + Some("fork"), + Some("select"), + Some("other"), + ]; + + let registry = registry(); + for ( + agent, + source, + authority, + reserved_native_state, + replacement_events, + allows_missing_event, + unsequenced_event, + initial_lifecycle_replacement, + ) in EXPECTED_REPORT_POLICIES + { + let profile = registry.profile_for_agent(agent); + let report = profile.report(); + assert_eq!(report.official_source(), source, "{agent:?}"); + assert_eq!(report.authority(), authority, "{agent:?}"); + assert_eq!( + report.reserves_native_state(), + reserved_native_state, + "{agent:?}" + ); + assert_eq!( + report.initial_lifecycle_report_replaces_session(), + initial_lifecycle_replacement, + "{agent:?}" + ); + + if let Some(source) = source { + let id = profile.canonical_id(); + assert_eq!( + registry.has_full_lifecycle_report_authority(source, id), + authority == ReportAuthority::FullLifecycle, + "{agent:?}" + ); + assert_eq!( + registry.is_session_identity_only_integration(source, id), + authority == ReportAuthority::SessionIdentityOnly, + "{agent:?}" + ); + assert_eq!( + registry.is_reserved_native_state_source(source, id), + reserved_native_state, + "{agent:?}" + ); + assert_eq!( + registry.initial_lifecycle_report_replaces_session(source, id), + initial_lifecycle_replacement, + "{agent:?}" + ); + for event in EVENTS { + let expected_replacement = match event { + Some(event) => replacement_events.contains(&event), + None => allows_missing_event, + }; + assert_eq!( + registry.session_report_allows_replacement(source, id, event), + expected_replacement, + "{agent:?} {event:?}" + ); + assert_eq!( + registry.session_replacement_allows_unsequenced_report(source, id, event), + event.is_some_and(|event| unsequenced_event == Some(event)), + "{agent:?} {event:?}" + ); + } + + assert!(!registry.has_full_lifecycle_report_authority("herdr:custom", id)); + assert!(!registry.is_session_identity_only_integration("herdr:custom", id)); + assert!(!registry.is_reserved_native_state_source("herdr:custom", id)); + assert!(!registry.session_report_allows_replacement( + "herdr:custom", + id, + Some("startup") + )); + for alias in profile.aliases() { + assert!(!registry.has_full_lifecycle_report_authority(source, alias)); + assert!(!registry.is_session_identity_only_integration(source, alias)); + assert!(!registry.is_reserved_native_state_source(source, alias)); + assert!(!registry.session_report_allows_replacement( + source, + alias, + Some("startup") + )); + } + } + } + + assert!(!registry.has_full_lifecycle_report_authority("herdr:pi", "custom-agent")); + assert!(!registry.session_report_allows_replacement( + "herdr:opencode", + "open-code", + Some("select") + )); +} + +#[test] +fn process_matchers_have_unique_profile_ownership() { + const EXPECTED_SPECIAL_MATCHERS: [(Agent, usize, bool, bool); 5] = [ + (Agent::Pi, 2, false, false), + (Agent::Cursor, 0, true, false), + (Agent::Cline, 0, false, true), + (Agent::Mastracode, 1, false, false), + (Agent::Qwen, 1, false, true), + ]; + + let registry = registry(); + let mut actual_special_matchers = Vec::new(); + + for profile in registry + .known_profiles() + .filter(|profile| profile.process().is_some()) + { + let process = profile.process().expect("process capability should exist"); + for process_name in std::iter::once(profile.canonical_id()) + .chain(profile.aliases().iter().map(String::as_str)) + { + assert!(registry + .profile_by_normalized_process_name(process_name) + .is_some_and(|owner| std::ptr::eq(owner, profile))); + } + + let package_layout_count = process.known_package_layouts().len(); + for layout in process.known_package_layouts() { + assert!(!layout.components().is_empty()); + assert!(layout + .components() + .iter() + .all(|component| !component.is_empty())); + assert_eq!( + registry + .process_profiles_with_package_layouts() + .flat_map(|candidate| { + candidate + .process() + .into_iter() + .flat_map(|candidate_process| candidate_process.known_package_layouts()) + }) + .filter(|candidate| candidate.components() == layout.components()) + .count(), + 1 + ); + } + let has_bundled_node_layout = process.bundled_node_layout().is_some(); + let uses_secondary_fallback = process.uses_secondary_runtime_argv_fallback(); + if package_layout_count > 0 || has_bundled_node_layout || uses_secondary_fallback { + actual_special_matchers.push(( + profile.legacy_agent(), + package_layout_count, + has_bundled_node_layout, + uses_secondary_fallback, + )); + } + } + + assert_eq!(actual_special_matchers, EXPECTED_SPECIAL_MATCHERS); + assert_eq!( + profile_agents(registry.process_profiles_with_package_layouts()), + [Agent::Pi, Agent::Mastracode, Agent::Qwen] + ); + assert_eq!( + profile_agents(registry.process_profiles_with_bundled_node_layout()), + [Agent::Cursor] + ); +} + +#[test] +fn capability_views_preserve_exact_independent_membership() { + let registry = registry(); + let expected_known: Vec = EXPECTED_IDENTITIES + .iter() + .map(|(agent, ..)| *agent) + .collect(); + + assert_eq!(profile_agents(registry.known_profiles()), expected_known); + assert_eq!(profile_agents(registry.known_profiles()), Agent::ALL); + for agent in Agent::ALL { + assert_eq!(registry.profile_for_agent(agent).legacy_agent(), agent); + } + assert_eq!( + profile_agents( + registry + .known_profiles() + .filter(|profile| profile.is_startable()) + ), + expected_known + ); + assert_eq!( + profile_agents( + registry + .known_profiles() + .filter(|profile| profile.process().is_some()) + ), + expected_known + ); + assert_eq!( + profile_agents(registry.screen_detectable_profiles()), + EXPECTED_SCREEN_DETECTABLE + ); + assert_eq!( + profile_agents( + registry + .known_profiles() + .filter(|profile| profile.session().is_some()) + ), + EXPECTED_RESUMABLE + ); + for profile in registry.known_profiles() { + let expected_resumable = EXPECTED_RESUMABLE.contains(&profile.legacy_agent()); + assert_eq!(profile.session().is_some(), expected_resumable); + } + assert_eq!( + profile_agents(registry.integration_capable_profiles()), + EXPECTED_INTEGRATION_CAPABLE + ); +} + +#[test] +fn integration_profiles_preserve_exact_metadata_order_and_round_trips() { + let registry = registry(); + let expected_targets = EXPECTED_INTEGRATION_PROFILES.map(|(target, ..)| target); + assert_eq!(IntegrationTarget::ALL, expected_targets); + for (discriminant, target) in IntegrationTarget::ALL.into_iter().enumerate() { + assert_eq!(target as usize, discriminant); + } + assert_eq!( + profile_agents(registry.integration_capable_profiles()), + EXPECTED_INTEGRATION_CAPABLE + ); + + for ( + target, + agent, + cli_label, + cli_aliases, + unix_commands, + windows_commands, + unix_version, + windows_version, + ) in EXPECTED_INTEGRATION_PROFILES + { + let profile = registry.profile_for_agent(agent); + let integration = profile.integration().expect("integration profile"); + assert_eq!(integration.target(), target); + assert_eq!(integration.cli_label(), cli_label); + assert_eq!(integration.cli_aliases(), cli_aliases); + assert_eq!(integration.definition.commands.unix, unix_commands); + assert_eq!(integration.definition.commands.windows, windows_commands); + assert!(integration.definition.supported.unix); + assert!(integration.definition.supported.windows); + assert_eq!(integration.definition.versions.unix, unix_version); + assert_eq!(integration.definition.versions.windows, windows_version); + + #[cfg(not(windows))] + { + assert_eq!(integration.command_names(), unix_commands); + assert!(integration.supported()); + assert_eq!(integration.expected_version(), unix_version); + } + #[cfg(windows)] + { + assert_eq!(integration.command_names(), windows_commands); + assert!(integration.supported()); + assert_eq!(integration.expected_version(), windows_version); + } + + assert!(registry + .profile_by_integration_target(target) + .is_some_and(|candidate| std::ptr::eq(candidate, profile))); + assert!(registry + .profile_by_integration_cli_name(cli_label) + .is_some_and(|candidate| std::ptr::eq(candidate, profile))); + for alias in cli_aliases { + assert!(registry + .profile_by_integration_cli_name(alias) + .is_some_and(|candidate| std::ptr::eq(candidate, profile))); + assert_eq!( + registry + .integration_capable_profiles() + .filter(|candidate| { + candidate.integration().is_some_and(|candidate| { + candidate.cli_label() == *alias + || candidate.cli_aliases().iter().any(|name| name == alias) + }) + }) + .count(), + 1 + ); + } + assert_eq!( + registry + .integration_capable_profiles() + .filter(|candidate| { + candidate.integration().is_some_and(|candidate| { + candidate.cli_label() == cli_label + || candidate.cli_aliases().iter().any(|name| name == cli_label) + }) + }) + .count(), + 1 + ); + assert_eq!( + registry + .known_profiles() + .filter(|candidate| { + candidate + .integration() + .is_some_and(|candidate| candidate.target() == target) + }) + .count(), + 1 + ); + } +} + +#[test] +fn integration_cli_lookup_is_exact_and_rejects_agent_only_names() { + let registry = registry(); + for rejected in [ + "Antigravity-cli", + "antigravity cli", + " antigravity-cli ", + "agy", + "antigravity", + "kilo-code", + "unknown", + "", + ] { + assert!( + registry.profile_by_integration_cli_name(rejected).is_none(), + "unexpected integration CLI alias: {rejected}" + ); + } + + assert_eq!( + registry + .profile_by_integration_cli_name("antigravity_cli") + .and_then(|profile| profile.integration()) + .map(|integration| integration.target()), + Some(IntegrationTarget::AntigravityCli) + ); +} diff --git a/src/api/mod.rs b/src/api/mod.rs index d28a82aef7..667676bae4 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -24,6 +24,7 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool { &request.method, Method::ServerReloadConfig(_) | Method::ServerReloadAgentManifests(_) + | Method::RegistryPresentationRefresh(_) | Method::NotificationShow(_) | Method::ProductAnnouncementDismiss(_) | Method::ReleaseNotesDismiss(_) @@ -95,6 +96,75 @@ pub struct ApiRequestMessage { pub type ApiRequestSender = mpsc::UnboundedSender; +/// At most one maintenance wakeup may be queued while publications race ahead +/// of the App loop. The handler reads the latest generation, not an old payload. +#[derive(Default)] +pub(crate) struct RegistryPublicationWakeup(std::sync::atomic::AtomicBool); + +pub(crate) static REGISTRY_PUBLICATION_WAKEUP: RegistryPublicationWakeup = + RegistryPublicationWakeup(std::sync::atomic::AtomicBool::new(false)); + +impl RegistryPublicationWakeup { + pub(crate) fn notify(&self, before: u64, after: u64, api_tx: &ApiRequestSender) { + use std::sync::atomic::Ordering; + if before == after || self.0.swap(true, Ordering::AcqRel) { + return; + } + let (respond_to, _) = std::sync::mpsc::channel(); + if api_tx + .send(ApiRequestMessage { + request: Request { + id: "internal:registry:published".into(), + method: Method::RegistryPresentationRefresh(schema::EmptyParams::default()), + }, + respond_to, + response_write_complete: None, + stream_active: None, + }) + .is_err() + { + self.begin_refresh(); + } + } + + pub(crate) fn begin_refresh(&self) { + // Clear before sampling generation: a publication during the refresh + // must enqueue another wakeup rather than becoming a lost update. + self.0.store(false, std::sync::atomic::Ordering::Release); + } +} + pub fn socket_path() -> PathBuf { crate::session::active_api_socket_path() } + +#[cfg(test)] +mod registry_publication_tests { + use super::*; + + #[test] + fn publication_wakeups_coalesce_and_unchanged_reloads_do_not_queue() { + let wakeup = RegistryPublicationWakeup::default(); + let (tx, mut rx) = mpsc::unbounded_channel(); + wakeup.notify(1, 1, &tx); + assert!(rx.try_recv().is_err()); + wakeup.notify(1, 2, &tx); + wakeup.notify(2, 3, &tx); + let message = rx.try_recv().unwrap(); + assert!(matches!( + message.request.method, + Method::RegistryPresentationRefresh(_) + )); + assert!(request_changes_ui(&message.request)); + assert!(serde_json::to_value(&message.request).is_err()); + assert!(serde_json::from_value::(serde_json::json!({ + "id": "external", "method": "RegistryPresentationRefresh", "params": {} + })) + .is_err()); + assert!(rx.try_recv().is_err()); + wakeup.begin_refresh(); + wakeup.notify(3, 4, &tx); + assert!(rx.try_recv().is_ok()); + assert!(rx.try_recv().is_err()); + } +} diff --git a/src/api/schema.rs b/src/api/schema.rs index ba9f0c3a71..92b3f8f63d 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -7,6 +7,7 @@ pub mod events; pub mod integrations; pub mod panes; pub mod plugins; +pub mod registry; pub mod response; pub mod server; pub mod session; @@ -21,6 +22,7 @@ pub use events::*; pub use integrations::*; pub use panes::*; pub use plugins::*; +pub use registry::*; pub use response::*; pub use server::*; pub use session::*; @@ -57,6 +59,20 @@ pub enum Method { ServerAgentManifests(EmptyParams), #[serde(rename = "server.reload_agent_manifests")] ServerReloadAgentManifests(EmptyParams), + #[serde(rename = "registry.status")] + RegistryStatus(EmptyParams), + #[serde(rename = "registry.reload")] + RegistryReload(RegistryReloadParams), + #[serde(rename = "registry.check")] + RegistryCheck(RegistryUpdateParams), + #[serde(rename = "registry.update")] + RegistryUpdate(RegistryUpdateParams), + #[serde(rename = "registry.reset")] + RegistryReset(EmptyParams), + // Coalesced in-process publication wakeup; not a callable wire method. + #[serde(skip)] + #[schemars(skip)] + RegistryPresentationRefresh(EmptyParams), #[serde(rename = "notification.show")] NotificationShow(NotificationShowParams), #[serde(rename = "product_announcement.dismiss")] diff --git a/src/api/schema/integrations.rs b/src/api/schema/integrations.rs index 0fce2913e7..0729ae0b10 100644 --- a/src/api/schema/integrations.rs +++ b/src/api/schema/integrations.rs @@ -49,6 +49,7 @@ pub enum IntegrationTarget { Grok, } +#[cfg(test)] impl IntegrationTarget { pub(crate) const ALL: [Self; 17] = [ Self::Pi, diff --git a/src/api/schema/registry.rs b/src/api/schema/registry.rs new file mode 100644 index 0000000000..0aff2f99ed --- /dev/null +++ b/src/api/schema/registry.rs @@ -0,0 +1,41 @@ +use serde::{Deserialize, Serialize}; +use std::path::Path; + +/// Reload the selected session's registry. Omission reuses its configured source. +/// This does not install integrations or grant report authority. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RegistryReloadParams { + /// Absolute local source directory (at most 4096 UTF-8 bytes), not a URL. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, +} + +/// Explicit remote check/update. Never starts an automatic update schedule. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RegistryUpdateParams { + #[serde(default)] + pub(crate) channel: crate::agents::remote::Channel, +} + +impl RegistryReloadParams { + pub(crate) fn source_path(&self) -> Result, String> { + self.source + .as_deref() + .map(|source| { + if source.is_empty() || source.len() > 4096 || source.contains('\0') { + return Err( + "registry source must be a nonempty local path of at most 4096 bytes" + .into(), + ); + } + let path = Path::new(source); + if !path.is_absolute() { + return Err("registry source must be an absolute local directory path".into()); + } + Ok(path) + }) + .transpose() + } +} diff --git a/src/api/schema/response.rs b/src/api/schema/response.rs index e6d4de4db2..5c77c598a0 100644 --- a/src/api/schema/response.rs +++ b/src/api/schema/response.rs @@ -94,6 +94,12 @@ pub enum ResponseResult { TabList { tabs: Vec, }, + AgentRegistry { + registry: crate::agents::store::RegistryStatus, + }, + RegistryUpdateCheck { + registry_update: crate::agents::store::RegistryUpdateCheck, + }, AgentInfo { agent: AgentInfo, }, diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs index b566e8f608..f364a65981 100644 --- a/src/api/schema/tests.rs +++ b/src/api/schema/tests.rs @@ -1433,3 +1433,124 @@ fn popup_close_request_round_trips() { assert_eq!(json["method"], "popup.close"); assert_eq!(json["params"], serde_json::json!({})); } + +#[test] +fn registry_requests_and_typed_status_round_trip() { + let source = std::env::current_dir() + .unwrap() + .to_str() + .unwrap() + .to_owned(); + for method in [ + Method::RegistryStatus(EmptyParams::default()), + Method::RegistryCheck(RegistryUpdateParams::default()), + Method::RegistryUpdate(RegistryUpdateParams { + channel: crate::agents::remote::Channel::Staging, + }), + Method::RegistryReset(EmptyParams::default()), + Method::RegistryReload(RegistryReloadParams::default()), + Method::RegistryReload(RegistryReloadParams { + source: Some(source), + }), + ] { + let request = Request { + id: "registry".into(), + method, + }; + let json = serde_json::to_value(&request).unwrap(); + assert!(matches!( + json["method"].as_str(), + Some( + "registry.status" + | "registry.reload" + | "registry.check" + | "registry.update" + | "registry.reset" + ) + )); + assert_eq!(serde_json::from_value::(json).unwrap(), request); + assert!(!crate::api::request_changes_ui(&request)); + } + let json = serde_json::json!({ + "id": "registry", + "result": { + "type": "agent_registry", + "registry": { + "generation": 7, + "digest": "abc123", + "source": null, + "last_error": null, + "agents": [{ + "id": "future-agent", "startable": true, "process": true, + "detection": false, "resume": false, "integration": false + }] + } + } + }); + let response: SuccessResponse = serde_json::from_value(json.clone()).unwrap(); + assert!( + matches!(&response.result, ResponseResult::AgentRegistry { registry } if registry.generation == 7) + ); + assert_eq!(serde_json::to_value(response).unwrap(), json); +} + +#[test] +fn registry_remote_methods_accept_only_explicit_known_channels_and_no_source_urls() { + for method in ["registry.check", "registry.update"] { + for params in [ + serde_json::json!({"channel":"untrusted"}), + serde_json::json!({"channel":"stable", "url":"https://other.example"}), + serde_json::json!({"channel":"stable", "install":true}), + ] { + assert!(serde_json::from_value::(serde_json::json!({ + "id":"registry", "method":method, "params":params + })) + .is_err()); + } + assert!(serde_json::from_value::(serde_json::json!({ + "id":"registry", "method":method, "params":{} + })) + .is_ok()); + } +} + +#[test] +fn registry_reload_source_is_a_bounded_absolute_local_path() { + assert!(RegistryReloadParams::default() + .source_path() + .unwrap() + .is_none()); + let source = std::env::current_dir() + .unwrap() + .to_str() + .unwrap() + .to_owned(); + let valid = RegistryReloadParams { + source: Some(source.clone()), + }; + assert_eq!( + valid.source_path().unwrap(), + Some(std::path::Path::new(&source)) + ); + for source in [ + String::new(), + "relative/path".into(), + "https://example.com/registry".into(), + "/bad\0path".into(), + format!("/{}", "x".repeat(4096)), + ] { + assert!(RegistryReloadParams { + source: Some(source) + } + .source_path() + .is_err()); + } + assert!(serde_json::from_value::(serde_json::json!({ + "id": "bad", "method": "registry.reload", "params": { "source": 123 } + })) + .is_err()); + assert!(serde_json::from_value::(serde_json::json!({ + "id": "bad", "method": "registry.reload", "params": { "install": true } + })) + .is_err()); +} diff --git a/src/api/server.rs b/src/api/server.rs index b8ef7c3860..330d8269a9 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -379,6 +379,61 @@ fn handle_request( ); } + // Connection workers own registry I/O and compilation. Never queue these + // operations on App's event loop; runtime consumers observe generation. + let registry = match &request.method { + Method::RegistryStatus(_) => Some(Ok(ResponseResult::AgentRegistry { + registry: crate::agents::store::status(), + })), + Method::RegistryCheck(params) => Some( + crate::agents::store::check_remote(params.channel) + .map(|registry_update| ResponseResult::RegistryUpdateCheck { registry_update }), + ), + Method::RegistryReload(_) | Method::RegistryUpdate(_) | Method::RegistryReset(_) => { + let before = crate::agents::store::generation(); + let result = match &request.method { + Method::RegistryReload(params) => match params.source_path() { + Ok(source) => crate::agents::store::reload(source), + Err(message) => { + return error_response_json(request.id, "invalid_params", message) + } + }, + Method::RegistryUpdate(params) => { + crate::agents::store::update_remote(params.channel) + } + _ => crate::agents::store::reset(), + }; + if let Ok(status) = &result { + crate::api::REGISTRY_PUBLICATION_WAKEUP.notify(before, status.generation, api_tx); + } + Some(result.map(|registry| ResponseResult::AgentRegistry { registry })) + } + _ => None, + }; + if let Some(registry) = registry { + return match registry { + Ok(result) => serde_json::to_string(&SuccessResponse { + id: request.id.clone(), + result, + }) + .unwrap_or_else(|error| { + error_response_json(request.id, "internal_error", error.to_string()) + }), + Err(message) => error_response_json( + request.id, + if matches!( + request.method, + Method::RegistryCheck(_) | Method::RegistryUpdate(_) + ) { + "registry_update_failed" + } else { + "registry_reload_failed" + }, + message, + ), + }; + } + dispatch_to_app(request, api_tx, None, response_write_complete, None, None) } @@ -390,6 +445,12 @@ pub(crate) fn api_method_name(method: &Method) -> &'static str { Method::ServerReloadConfig(_) => "server.reload_config", Method::ServerAgentManifests(_) => "server.agent_manifests", Method::ServerReloadAgentManifests(_) => "server.reload_agent_manifests", + Method::RegistryStatus(_) => "registry.status", + Method::RegistryReload(_) => "registry.reload", + Method::RegistryCheck(_) => "registry.check", + Method::RegistryUpdate(_) => "registry.update", + Method::RegistryReset(_) => "registry.reset", + Method::RegistryPresentationRefresh(_) => "internal.registry.presentation_refresh", Method::NotificationShow(_) => "notification.show", Method::ProductAnnouncementDismiss(_) => "product_announcement.dismiss", Method::ReleaseNotesDismiss(_) => "release_notes.dismiss", @@ -1159,6 +1220,90 @@ mod tests { assert!(matches!(parsed.result, ResponseResult::Pong { .. })); } + #[test] + fn registry_status_is_read_only_and_bypasses_app_channel() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let before = crate::agents::store::snapshot(); + for _ in 0..2 { + let response = handle_request( + Request { + id: "registry_status".into(), + method: Method::RegistryStatus(crate::api::schema::EmptyParams::default()), + }, + &tx, + None, + None, + None, + ); + let response: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert!( + matches!(response.result, ResponseResult::AgentRegistry { registry } + if registry.generation == before.generation && registry.digest == before.digest) + ); + } + assert!(Arc::ptr_eq(&before, &crate::agents::store::snapshot())); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn registry_invalid_reload_is_rejected_without_app_dispatch_or_mutation() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let before = crate::agents::store::snapshot(); + let response = handle_request( + Request { + id: "invalid_reload".into(), + method: Method::RegistryReload(crate::api::schema::RegistryReloadParams { + source: Some("https://example.com/registry".into()), + }), + }, + &tx, + None, + None, + None, + ); + let response: ErrorResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(response.id, "invalid_reload"); + assert_eq!(response.error.code, "invalid_params"); + assert!(Arc::ptr_eq(&before, &crate::agents::store::snapshot())); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn registry_remote_errors_are_server_local_without_app_dispatch_or_activation() { + let _guard = crate::config::test_config_env_lock().lock().unwrap(); + let previous = std::env::var_os("HERDR_AGENT_REGISTRY_ORIGIN"); + std::env::set_var("HERDR_AGENT_REGISTRY_ORIGIN", "http://invalid.example"); + let (tx, mut rx) = mpsc::unbounded_channel(); + let before = crate::agents::store::snapshot(); + let responses = [ + Method::RegistryCheck(crate::api::schema::RegistryUpdateParams::default()), + Method::RegistryUpdate(crate::api::schema::RegistryUpdateParams::default()), + ] + .map(|method| { + handle_request( + Request { + id: "remote".into(), + method, + }, + &tx, + None, + None, + None, + ) + }); + match previous { + Some(value) => std::env::set_var("HERDR_AGENT_REGISTRY_ORIGIN", value), + None => std::env::remove_var("HERDR_AGENT_REGISTRY_ORIGIN"), + } + for response in responses { + let response: ErrorResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(response.error.code, "registry_update_failed"); + assert!(response.error.message.contains("HTTPS")); + } + assert!(Arc::ptr_eq(&before, &crate::agents::store::snapshot())); + assert!(rx.try_recv().is_err()); + } + #[test] fn server_stop_control_bypasses_app_channel() { let (tx, mut rx) = mpsc::unbounded_channel(); diff --git a/src/api/server/pane_graphics_stream.rs b/src/api/server/pane_graphics_stream.rs index c0dac5ab23..a285c669dc 100644 --- a/src/api/server/pane_graphics_stream.rs +++ b/src/api/server/pane_graphics_stream.rs @@ -10,7 +10,7 @@ use crate::api::schema::{ ResponseResult, SuccessResponse, }; use crate::api::ApiRequestSender; -use crate::ipc::{is_connection_closed_error, LocalStream}; +use crate::ipc::{is_connection_closed_error, LocalStream, LocalStreamReadCount}; use super::{ api_response_outcome, dispatch_stream_frame, dispatch_stream_open, @@ -394,8 +394,11 @@ fn read_line( "timed out reading stream frame header", )?; match wait.read(stream, &mut byte) { - Ok(0) => return Ok(None), - Ok(_) => { + Ok(LocalStreamReadCount::Pending) => { + wait.after_retry(idle_deadline, total_deadline); + } + Ok(LocalStreamReadCount::Closed) => return Ok(None), + Ok(LocalStreamReadCount::Data(_)) => { wait.on_progress(); let now = Instant::now(); let total_deadline_at = @@ -456,14 +459,17 @@ fn read_exact( let remaining = len - data.len(); let read_len = remaining.min(chunk.len()); match wait.read(stream, &mut chunk[..read_len]) { - Ok(0) if data.is_empty() => return Ok(None), - Ok(0) => { + Ok(LocalStreamReadCount::Pending) => { + wait.after_retry(Some(idle_deadline), Some(total_deadline)); + } + Ok(LocalStreamReadCount::Closed) if data.is_empty() => return Ok(None), + Ok(LocalStreamReadCount::Closed) => { return Err(io::Error::new( io::ErrorKind::UnexpectedEof, "stream ended mid-frame", )); } - Ok(n) => { + Ok(LocalStreamReadCount::Data(n)) => { wait.on_progress(); let now = Instant::now(); if now >= total_deadline { @@ -494,14 +500,14 @@ enum ReadWait { } impl ReadWait { - fn read(&self, stream: &mut LocalStream, buffer: &mut [u8]) -> io::Result { - if matches!(self, Self::SocketTimeout) { - return stream.read(buffer); - } - match crate::ipc::poll_local_stream_read_count(stream, buffer)? { - crate::ipc::LocalStreamReadCount::Data(count) => Ok(count), - crate::ipc::LocalStreamReadCount::Pending => Err(io::ErrorKind::WouldBlock.into()), - crate::ipc::LocalStreamReadCount::Closed => Ok(0), + fn read(&self, stream: &mut LocalStream, buf: &mut [u8]) -> io::Result { + match self { + Self::Poll(_) => crate::ipc::poll_local_stream_read_count(stream, buf), + Self::SocketTimeout => match stream.read(buf) { + Ok(0) => Ok(LocalStreamReadCount::Closed), + Ok(read) => Ok(LocalStreamReadCount::Data(read)), + Err(error) => Err(error), + }, } } @@ -923,6 +929,59 @@ mod tests { assert!(server_thread.join().unwrap().is_ok()); } + #[test] + fn polling_reads_distinguish_pending_data_and_closed_peers() { + let (mut client, mut server, _path) = local_stream_pair("graphics-poll-states"); + crate::ipc::set_local_stream_polling(&mut server, true).unwrap(); + let wait = ReadWait::Poll(PollBackoff::new()); + let mut byte = [0]; + assert!(matches!( + wait.read(&mut server, &mut byte).unwrap(), + LocalStreamReadCount::Pending + )); + client.write_all(b"x").unwrap(); + assert!(matches!( + wait.read(&mut server, &mut byte).unwrap(), + LocalStreamReadCount::Data(1) + )); + assert_eq!(byte, *b"x"); + assert!(matches!( + wait.read(&mut server, &mut byte).unwrap(), + LocalStreamReadCount::Pending + )); + drop(client); + // Written Windows pipes close through interprocess's background flush pool. + let deadline = Instant::now() + Duration::from_secs(2); + loop { + match wait.read(&mut server, &mut byte).unwrap() { + LocalStreamReadCount::Closed => break, + LocalStreamReadCount::Pending => { + assert!(Instant::now() < deadline, "peer did not close"); + std::thread::sleep(Duration::from_millis(1)); + } + LocalStreamReadCount::Data(_) => panic!("unexpected data after draining peer"), + } + } + } + + #[test] + fn actual_mid_body_disconnect_remains_unexpected_eof() { + let (mut client, mut server, _path) = local_stream_pair("graphics-partial-body-close"); + client.write_all(b"ab").unwrap(); + let closer = std::thread::spawn(move || drop(client)); + let error = read_exact( + &mut server, + 4, + &Arc::new(AtomicBool::new(true)), + &Arc::new(AtomicBool::new(true)), + Duration::from_secs(1), + Duration::from_secs(1), + ) + .unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::UnexpectedEof); + closer.join().unwrap(); + } + #[test] fn idle_graphics_stream_waits_for_header_without_timing_out() { let (_client, mut server, _path) = local_stream_pair("graphics-idle-header"); diff --git a/src/app/actions.rs b/src/app/actions.rs index d5d13d5a6b..e0692d8121 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -1589,6 +1589,30 @@ impl AppState { pub fn handle_app_event(&mut self, event: AppEvent) -> Vec { match event { + AppEvent::AgentResumeProcessBound { pane_id, binding } => { + let mut recipe_changed = false; + for ws in &self.workspaces { + if let Some(id) = ws.terminal_id(pane_id) { + if let Some(terminal) = self.terminals.get_mut(id) { + let previous = terminal.pinned_agent_resume_recipe.clone(); + let options_changed = terminal.bind_agent_resume_process(*binding); + recipe_changed = + options_changed || previous != terminal.pinned_agent_resume_recipe; + } + break; + } + } + if recipe_changed { + self.mark_session_dirty(); + } + Vec::new() + } + event @ AppEvent::AgentDetection { .. } => { + match event.into_current_detection(crate::agents::store::generation()) { + Some(observation) => self.handle_app_event(observation), + None => Vec::new(), + } + } AppEvent::PaneDied { pane_id, .. } => { self.handle_pane_died(pane_id); Vec::new() @@ -1616,38 +1640,6 @@ impl AppState { } Vec::new() } - AppEvent::AgentDetectionManifestsUpdated { - updated, status, .. - } => { - self.agent_manifest_update_status = status; - self.refresh_agent_manifest_summaries(); - if !updated.is_empty() - && matches!( - self.toast_config.delivery, - crate::config::ToastDelivery::Herdr - ) - { - let agent_list = updated - .iter() - .map(|item| { - format!( - "{} {}", - crate::detect::agent_label(item.agent), - item.version - ) - }) - .collect::>() - .join(", "); - self.toast = Some(ToastNotification { - kind: ToastKind::UpdateInstalled, - title: "Agent detection rules updated".to_string(), - context: agent_list, - position: None, - target: None, - }); - } - Vec::new() - } AppEvent::AgentProcessDetected { pane_id, agent, @@ -1662,6 +1654,7 @@ impl AppState { pane_id, agent, state, + visible_idle, visible_blocker, visible_working, process_exited, @@ -1672,7 +1665,7 @@ impl AppState { agent, state, visible_blocker, - false, + visible_idle, visible_working, process_exited, observed_at, @@ -2923,6 +2916,7 @@ mod tests { pane_id, agent: Some(Agent::Pi), state: AgentState::Working, + visible_idle: false, visible_blocker: false, visible_working: false, process_exited: false, @@ -2940,6 +2934,49 @@ mod tests { assert_eq!(terminal.detected_agent, Some(Agent::Pi)); } + #[test] + fn state_changed_propagates_visible_idle_into_strict_managed_readiness() { + let mut state = app_with_workspaces(&["strict"]); + let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); + let terminal_id = state.workspaces[0] + .panes + .get(&pane_id) + .unwrap() + .attached_terminal_id + .clone(); + let injected_at = std::time::Instant::now(); + state + .terminals + .get_mut(&terminal_id) + .unwrap() + .begin_managed_agent_with_readiness( + Some("reviewer".into()), + Agent::OpenCode, + true, + injected_at, + std::time::Duration::from_secs(3), + std::time::Duration::from_secs(30), + ); + state.handle_app_event(AppEvent::AgentProcessDetected { + pane_id, + agent: Agent::OpenCode, + observed_at: injected_at, + }); + assert!(!state.terminals[&terminal_id].managed_agent_interactive_ready()); + state.handle_app_event(AppEvent::StateChanged { + pane_id, + agent: Some(Agent::OpenCode), + state: AgentState::Idle, + visible_idle: true, + visible_blocker: false, + visible_working: false, + process_exited: false, + observed_at: injected_at + std::time::Duration::from_millis(1), + }); + assert!(state.terminals[&terminal_id].managed_agent_interactive_ready()); + state.assert_invariants_for_test(); + } + #[test] fn state_changed_idle_in_background_marks_unseen() { let mut state = app_with_workspaces(&["active", "background"]); @@ -2961,6 +2998,7 @@ mod tests { pane_id: bg_pane_id, agent: Some(Agent::Pi), state: AgentState::Idle, + visible_idle: false, visible_blocker: false, visible_working: false, process_exited: false, @@ -2994,6 +3032,7 @@ mod tests { pane_id, agent: Some(Agent::Pi), state: AgentState::Idle, + visible_idle: false, visible_blocker: false, visible_working: false, process_exited: false, @@ -3016,6 +3055,7 @@ mod tests { pane_id: bg_pane_id, agent: Some(Agent::Pi), state: AgentState::Idle, + visible_idle: false, visible_blocker: false, visible_working: false, process_exited: false, @@ -3037,6 +3077,7 @@ mod tests { pane_id: bg_pane_id, agent: Some(Agent::Pi), state: AgentState::Unknown, + visible_idle: false, visible_blocker: false, visible_working: false, process_exited: false, @@ -3046,6 +3087,7 @@ mod tests { pane_id: bg_pane_id, agent: Some(Agent::Pi), state: AgentState::Idle, + visible_idle: false, visible_blocker: false, visible_working: false, process_exited: false, @@ -3073,6 +3115,7 @@ mod tests { pane_id, agent: Some(Agent::Pi), state: AgentState::Idle, + visible_idle: false, visible_blocker: false, visible_working: false, process_exited: false, @@ -3092,6 +3135,7 @@ mod tests { pane_id, agent: Some(Agent::Pi), state: agent_state, + visible_idle: false, visible_blocker: agent_state == AgentState::Blocked, visible_working: agent_state == AgentState::Working, process_exited: false, @@ -3103,6 +3147,7 @@ mod tests { pane_id, agent: Some(Agent::Pi), state: AgentState::Idle, + visible_idle: false, visible_blocker: false, visible_working: false, process_exited: false, @@ -3127,6 +3172,7 @@ mod tests { pane_id, agent: Some(Agent::Codex), state: AgentState::Working, + visible_idle: false, visible_blocker: false, visible_working: true, process_exited: false, @@ -3137,6 +3183,7 @@ mod tests { pane_id, agent: Some(Agent::Codex), state: AgentState::Idle, + visible_idle: false, visible_blocker: false, visible_working: false, process_exited: true, @@ -3182,6 +3229,7 @@ mod tests { pane_id: bg_pane_id, agent: Some(Agent::Pi), state: AgentState::Blocked, + visible_idle: false, visible_blocker: false, visible_working: false, process_exited: false, @@ -3206,6 +3254,7 @@ mod tests { pane_id: bg_pane_id, agent: Some(Agent::Pi), state: AgentState::Blocked, + visible_idle: false, visible_blocker: false, visible_working: false, process_exited: false, @@ -3238,6 +3287,7 @@ mod tests { pane_id: bg_pane_id, agent: Some(Agent::Pi), state: AgentState::Blocked, + visible_idle: false, visible_blocker: false, visible_working: false, process_exited: false, @@ -3249,6 +3299,7 @@ mod tests { pane_id: bg_pane_id, agent: Some(Agent::Pi), state: AgentState::Working, + visible_idle: false, visible_blocker: false, visible_working: true, process_exited: false, @@ -3272,6 +3323,7 @@ mod tests { pane_id: bg_pane_id, agent: Some(Agent::Pi), state: AgentState::Blocked, + visible_idle: false, visible_blocker: false, visible_working: false, process_exited: false, @@ -3297,6 +3349,7 @@ mod tests { pane_id, agent: Some(Agent::Pi), state: AgentState::Blocked, + visible_idle: false, visible_blocker: false, visible_working: false, process_exited: false, @@ -3324,6 +3377,7 @@ mod tests { pane_id: bg_pane_id, agent: Some(Agent::Pi), state: AgentState::Blocked, + visible_idle: false, visible_blocker: false, visible_working: false, process_exited: false, @@ -3380,6 +3434,7 @@ mod tests { pane_id: bg_pane_id, agent: Some(Agent::Codex), state: AgentState::Idle, + visible_idle: false, visible_blocker: false, visible_working: false, process_exited: false, @@ -3398,6 +3453,7 @@ mod tests { pane_id: bg_pane_id, agent: Some(Agent::Codex), state: AgentState::Blocked, + visible_idle: false, visible_blocker: true, visible_working: false, process_exited: false, @@ -3428,6 +3484,7 @@ mod tests { pane_id, agent: Some(Agent::Claude), state: AgentState::Working, + visible_idle: false, visible_blocker: false, visible_working: false, process_exited: false, @@ -3451,6 +3508,7 @@ mod tests { pane_id, agent: Some(Agent::Claude), state: AgentState::Idle, + visible_idle: false, visible_blocker: false, visible_working: false, process_exited: false, @@ -3477,6 +3535,7 @@ mod tests { pane_id, agent: Some(Agent::Pi), state: AgentState::Working, + visible_idle: false, visible_blocker: false, visible_working: true, process_exited: false, @@ -3537,6 +3596,7 @@ mod tests { pane_id, agent: Some(Agent::Devin), state: AgentState::Idle, + visible_idle: false, visible_blocker: false, visible_working: false, process_exited: false, @@ -3669,6 +3729,7 @@ mod tests { pane_id: bg_pane_id, agent: Some(Agent::Droid), state: AgentState::Idle, + visible_idle: false, visible_blocker: false, visible_working: false, process_exited: false, @@ -3698,6 +3759,7 @@ mod tests { pane_id: bg_pane_id, agent: Some(Agent::Pi), state: AgentState::Blocked, + visible_idle: false, visible_blocker: false, visible_working: false, process_exited: false, @@ -3724,6 +3786,7 @@ mod tests { pane_id: bg_pane_id, agent: Some(Agent::Pi), state: AgentState::Blocked, + visible_idle: false, visible_blocker: false, visible_working: false, process_exited: false, @@ -3747,6 +3810,7 @@ mod tests { pane_id, agent: Some(Agent::Pi), state: AgentState::Blocked, + visible_idle: false, visible_blocker: false, visible_working: false, process_exited: false, @@ -3768,6 +3832,7 @@ mod tests { pane_id, agent: Some(Agent::Pi), state: AgentState::Blocked, + visible_idle: false, visible_blocker: false, visible_working: false, process_exited: false, @@ -3829,36 +3894,6 @@ mod tests { ); } - #[test] - fn agent_detection_manifest_update_event_updates_status_and_toast() { - let mut state = AppState::test_new(); - state.toast_config.delivery = crate::config::ToastDelivery::Herdr; - let status = crate::detect::manifest_update::ManifestUpdateStatus { - last_result: Some("checked".to_string()), - ..Default::default() - }; - - let updates = state.handle_app_event(AppEvent::AgentDetectionManifestsUpdated { - updated: vec![crate::detect::manifest_update::ManifestUpdateCommit { - agent: Agent::Codex, - version: crate::detect::manifest_update::ManifestVersion::parse("2026.06.10.1") - .unwrap(), - }], - activated: Vec::new(), - status, - }); - - assert!(updates.is_empty()); - assert_eq!( - state.agent_manifest_update_status.last_result.as_deref(), - Some("checked") - ); - let toast = state.toast.as_ref().expect("manifest update toast"); - assert_eq!(toast.kind, ToastKind::UpdateInstalled); - assert_eq!(toast.title, "Agent detection rules updated"); - assert_eq!(toast.context, "codex 2026.06.10.1"); - } - #[test] fn toggle_zoom_works() { let mut state = app_with_workspaces(&["test"]); diff --git a/src/app/agent_resume.rs b/src/app/agent_resume.rs index 2cbd93ab38..d431570c21 100644 --- a/src/app/agent_resume.rs +++ b/src/app/agent_resume.rs @@ -217,7 +217,10 @@ impl App { return false; } - let Some(resume_command) = shell_command_from_argv(&plan.argv) else { + let launch_argv = plan.replay_argv(&crate::agents::registry()); + let Some(resume_command) = + crate::platform::interactive_shell_command(&launch_argv, &self.state.default_shell) + else { tracing::warn!( pane = pane_id.raw(), terminal = %terminal_id, @@ -230,6 +233,8 @@ impl App { .find_pane(pane_id) .and_then(|(ws_idx, _)| self.pane_launch_env(ws_idx, pane_id, Vec::new())) else { + self.pending_agent_resume_deadline = + Some(Instant::now() + super::PENDING_AGENT_RESUME_THEME_WAIT); return false; }; @@ -254,31 +259,61 @@ impl App { terminal = %terminal_id, agent = %plan.agent, err = %err, - "failed to start shell for deferred agent resume" + "failed to start shell for deferred agent resume; keeping resume queued" ); - if let Some(terminal) = self.state.terminals.get_mut(&terminal_id) { - terminal.clear_agent_runtime_identity_after_respawn(); - } + self.pending_agent_resume_deadline = + Some(Instant::now() + super::PENDING_AGENT_RESUME_THEME_WAIT); return false; } }; let mut input = resume_command; input.push('\r'); + let injected_at = Instant::now(); if let Err(err) = runtime.try_send_bytes(Bytes::from(input)) { tracing::warn!( pane = pane_id.raw(), terminal = %terminal_id, agent = %plan.agent, err = %err, - "failed to send deferred agent resume command to shell" + "failed to send deferred agent resume command to shell; keeping resume queued" ); runtime.shutdown(); + self.pending_agent_resume_deadline = + Some(Instant::now() + super::PENDING_AGENT_RESUME_THEME_WAIT); return false; } self.terminal_runtimes.insert(terminal_id.clone(), runtime); if let Some(terminal) = self.state.terminals.get_mut(&terminal_id) { + if let Ok(agent) = crate::detect::Agent::parse(&plan.agent) { + if terminal.managed_agent_kind() == Some(agent) { + terminal.mark_queued_agent_injected( + injected_at, + super::agents::AGENT_START_SETTLE_DELAY, + super::agents::DEFAULT_AGENT_START_TIMEOUT, + ); + } else { + terminal.begin_managed_agent_with_readiness( + None, + agent, + plan.strict_input_readiness, + injected_at, + super::agents::AGENT_START_SETTLE_DELAY, + super::agents::DEFAULT_AGENT_START_TIMEOUT, + ); + } + let recipe = terminal.pinned_agent_resume_recipe.clone().or_else(|| { + crate::agents::bundled_profile(&plan.agent) + .and_then(crate::agent_resume::PinnedAgentResumeRecipe::capture) + }); + terminal.admit_agent_resume_recipe( + agent, + recipe, + terminal.persisted_agent_session.clone(), + injected_at, + ); + } terminal.pending_agent_resume_plan = None; terminal.respawn_shell_on_exit = false; } @@ -321,35 +356,9 @@ fn stable_terminal_inner_rect(pane_inner: Rect) -> Rect { ) } -fn shell_command_from_argv(argv: &[String]) -> Option { - let mut parts = argv.iter(); - let first = shell_quote(parts.next()?); - let mut command = first; - for part in parts { - command.push(' '); - command.push_str(&shell_quote(part)); - } - Some(command) -} - -fn shell_quote(value: &str) -> String { - if value.is_empty() { - return "''".to_string(); - } - if value.bytes().all(|byte| { - byte.is_ascii_alphanumeric() - || matches!( - byte, - b'_' | b'-' | b'.' | b'/' | b':' | b'@' | b'%' | b'+' | b'=' - ) - }) { - return value.to_string(); - } - format!("'{}'", value.replace('\'', "'\\''")) -} - #[cfg(test)] mod tests { + #[cfg(unix)] use super::*; #[cfg(unix)] @@ -401,7 +410,9 @@ mod tests { terminal.pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { agent: "codex".into(), argv: marker_resume_test_argv(), + resume_options: Vec::new(), dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), + strict_input_readiness: false, }); assert!(!app.start_pending_agent_resumes(false)); @@ -458,6 +469,63 @@ mod tests { } } + #[cfg(unix)] + #[tokio::test] + async fn failed_deferred_spawn_keeps_cold_restore_queued_without_readiness_deadline() { + let mut app = test_app(); + let workspace = crate::workspace::Workspace::test_new("restored"); + let pane_id = workspace.tabs[0].root_pane; + let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap(); + app.state.view.pane_infos = workspace.tabs[0] + .layout + .panes(ratatui::layout::Rect::new(0, 0, 100, 30)); + app.state.view.terminal_area = ratatui::layout::Rect::new(0, 0, 100, 30); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.ensure_test_terminals(); + app.state.host_terminal_theme = crate::terminal_theme::TerminalTheme { + foreground: Some(crate::terminal_theme::RgbColor { + r: 220, + g: 220, + b: 220, + }), + background: Some(crate::terminal_theme::RgbColor { + r: 20, + g: 20, + b: 20, + }), + ..Default::default() + }; + app.state.default_shell = "/definitely/missing/herdr-shell".into(); + let terminal = app + .state + .terminals + .get_mut(&terminal_id) + .expect("test terminal should exist"); + terminal.pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { + resume_options: Vec::new(), + agent: "codex".into(), + argv: long_running_test_argv(), + dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), + strict_input_readiness: false, + }); + terminal.queue_managed_agent(Some("reviewer".into()), crate::detect::Agent::Codex, false); + + let before = Instant::now(); + app.pending_agent_resume_deadline = Some(before); + assert!(!app.start_pending_agent_resumes(false)); + assert!(app.terminal_runtimes.get(&terminal_id).is_none()); + let terminal = app.state.terminals.get(&terminal_id).unwrap(); + assert!(terminal.pending_agent_resume_plan.is_some()); + assert!(terminal.managed_agent_launch_pending()); + assert!(!terminal.managed_agent_interactive_ready()); + assert_eq!(terminal.next_managed_agent_deadline(), None); + assert_eq!(terminal.agent_name.as_deref(), Some("reviewer")); + assert!(app + .pending_agent_resume_deadline + .is_some_and(|deadline| deadline > before)); + } + #[cfg(unix)] #[tokio::test] async fn pending_agent_resume_can_launch_after_theme_wait_expires() { @@ -480,6 +548,8 @@ mod tests { agent: "codex".into(), argv: long_running_test_argv(), dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), + strict_input_readiness: false, + resume_options: Vec::new(), }); app.sync_pending_agent_resume_deadline(std::time::Instant::now()); @@ -530,7 +600,9 @@ mod tests { .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { agent: "codex".into(), argv: long_running_test_argv(), + resume_options: Vec::new(), dedupe_key: format!("herdr:codex\0codex\0Id\0{terminal_id}"), + strict_input_readiness: false, }); } app.pending_agent_resume_deadline = @@ -594,7 +666,9 @@ mod tests { .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { agent: "codex".into(), argv: long_running_test_argv(), + resume_options: Vec::new(), dedupe_key: "herdr:codex\0codex\0Id\0inactive-tab-session".into(), + strict_input_readiness: false, }); assert!(app.start_pending_agent_resumes(false)); @@ -655,7 +729,9 @@ mod tests { .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { agent: "codex".into(), argv: long_running_test_argv(), + resume_options: Vec::new(), dedupe_key: "herdr:codex\0codex\0Id\0zoom-hidden-session".into(), + strict_input_readiness: false, }); assert!(app.start_pending_agent_resumes(false)); @@ -714,6 +790,8 @@ mod tests { agent: "codex".into(), argv: long_running_test_argv(), dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), + strict_input_readiness: false, + resume_options: Vec::new(), }); app.sync_pending_agent_resume_deadline(std::time::Instant::now()); @@ -775,6 +853,8 @@ mod tests { agent: "codex".into(), argv: long_running_test_argv(), dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), + strict_input_readiness: false, + resume_options: Vec::new(), }); assert!(app.start_pending_agent_resumes(false)); @@ -790,19 +870,4 @@ mod tests { runtime.shutdown(); } } - - #[test] - fn shell_command_from_argv_quotes_resume_arguments() { - let argv = vec![ - "claude".to_string(), - "--resume".to_string(), - "session with ' quote".to_string(), - ]; - - assert_eq!( - shell_command_from_argv(&argv).as_deref(), - Some("claude --resume 'session with '\\'' quote'") - ); - assert_eq!(shell_command_from_argv(&[]), None); - } } diff --git a/src/app/agents.rs b/src/app/agents.rs index 5033505dc4..c856a42eac 100644 --- a/src/app/agents.rs +++ b/src/app/agents.rs @@ -5,7 +5,7 @@ use bytes::Bytes; use super::{terminal_targets::TerminalTargetError, App}; use crate::api::schema::AgentStartParams; -const DEFAULT_AGENT_START_TIMEOUT: Duration = Duration::from_secs(30); +pub(crate) const DEFAULT_AGENT_START_TIMEOUT: Duration = Duration::from_secs(30); pub(crate) const MAX_AGENT_START_TIMEOUT: Duration = Duration::from_secs(300); pub(crate) const AGENT_START_SETTLE_DELAY: Duration = Duration::from_secs(3); const INVALID_AGENT_TIMEOUT_MESSAGE: &str = @@ -36,6 +36,32 @@ impl App { .collect() } + pub(crate) fn reconcile_due_managed_agents(&mut self, now: Instant) -> bool { + let due_terminal_ids = self + .state + .terminals + .values() + .filter(|terminal| { + terminal + .next_managed_agent_deadline() + .is_some_and(|deadline| now >= deadline) + }) + .map(|terminal| terminal.id.clone()) + .collect::>(); + let mut changed = false; + for terminal_id in due_terminal_ids { + changed |= self + .state + .terminals + .get_mut(&terminal_id) + .is_some_and(|terminal| terminal.reconcile_managed_agent_at(now, false)); + } + if changed { + self.state.mark_session_dirty(); + } + changed + } + pub(super) fn reconcile_managed_agent_target(&mut self, target: &str) { let Ok(resolved) = self.resolve_agent_target(target) else { return; @@ -145,12 +171,24 @@ impl App { pub(super) fn start_agent( &mut self, params: AgentStartParams, + ) -> Result<(crate::api::schema::AgentInfo, Vec), AgentStartError> { + self.start_agent_with_registry(params, crate::agents::registry()) + } + + fn start_agent_with_registry( + &mut self, + params: AgentStartParams, + registry: std::sync::Arc, ) -> Result<(crate::api::schema::AgentInfo, Vec), AgentStartError> { let name = params.name; if !valid_agent_name(&name) { return Err(AgentStartError::InvalidName); } - let Some(kind) = crate::detect::parse_agent_label(¶ms.kind) else { + let normalized_kind = params.kind.trim().to_ascii_lowercase(); + let Some(profile) = registry + .profile_by_normalized_alias(&normalized_kind) + .filter(|profile| profile.is_startable()) + else { return Err(AgentStartError::UnsupportedKind(params.kind)); }; if params @@ -160,8 +198,12 @@ impl App { { return Err(AgentStartError::InvalidArgument); } + let kind = profile.legacy_agent(); let persisted_agent_session = - crate::agent_resume::persisted_session_from_launch_args(kind, ¶ms.args); + crate::agent_resume::persisted_session_from_profile_launch_args(profile, ¶ms.args); + let pinned_recipe = crate::agent_resume::PinnedAgentResumeRecipe::capture(profile); + let strict_input_readiness = + crate::detect::manifest::requires_screen_visible_idle(®istry, kind); let conflicts = self.agent_name_conflicts(&name, ""); if !conflicts.is_empty() { return Err(AgentStartError::DuplicateName { @@ -194,7 +236,7 @@ impl App { let shell_name = available_shell_name(runtime) .ok_or_else(|| AgentStartError::TargetBusy(params.pane_id.clone()))?; - let mut argv = vec![crate::detect::interactive_agent_executable(kind).to_string()]; + let mut argv = vec![profile.launch().executable().to_string()]; argv.extend(params.args); let command = crate::platform::interactive_shell_command(&argv, &shell_name) .ok_or(AgentStartError::InvalidArgument)?; @@ -209,25 +251,40 @@ impl App { } let now = Instant::now(); + if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) { + return Err(AgentStartError::InputFailed(err.to_string())); + } let terminal = self .state .terminals .get_mut(&terminal_id) .ok_or_else(|| AgentStartError::TargetUnavailable(params.pane_id.clone()))?; - terminal.begin_managed_agent(name.clone(), kind, now, AGENT_START_SETTLE_DELAY, timeout); - if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) { - terminal.clear_agent_name(); - return Err(AgentStartError::InputFailed(err.to_string())); - } + terminal.begin_managed_agent_with_readiness( + Some(name.clone()), + kind, + strict_input_readiness, + now, + AGENT_START_SETTLE_DELAY, + timeout, + ); + terminal.admit_agent_resume_recipe( + kind, + pinned_recipe.clone(), + persisted_agent_session.clone(), + now, + ); + terminal.pinned_agent_resume_recipe = pinned_recipe; if let Some(session) = persisted_agent_session { terminal.set_managed_agent_launch_session(session); } self.state.mark_session_dirty(); self.schedule_session_save(); - let agent = self + let mut agent = self .agent_info(ws_idx, pane_id) .ok_or(AgentStartError::TargetUnavailable(params.pane_id))?; + // Acknowledge the server-admitted identity without claiming live process detection. + agent.agent = Some(kind.as_str().to_string()); Ok((agent, argv)) } @@ -384,7 +441,8 @@ impl App { terminal_title_stripped: pane.terminal_title_stripped, display_agent: pane.display_agent, agent_status: pane.agent_status, - screen_detection_skipped: terminal.full_lifecycle_hook_authority_active(), + screen_detection_skipped: terminal.full_lifecycle_hook_authority_active() + && !terminal.screen_detection_required_for_managed_startup(), state_labels: pane.state_labels, tokens: pane.tokens, agent_session: pane.agent_session, @@ -426,14 +484,47 @@ fn available_shell_name(runtime: &crate::terminal::TerminalRuntime) -> Option, ) -> bool { #[cfg(test)] if runtime.child_pid().is_none() { return true; } + if let Some(binding) = binding.filter(|binding| binding.agent == expected) { + if let Some((_, process)) = &binding.process { + let Some(pid) = runtime.child_pid() else { + return false; + }; + let registry = crate::agents::registry(); + return crate::platform::foreground_job_with_registry( + ®istry, + pid, + Some((process, ®istry)), + ) + .is_some_and(|job| { + retained_binding_in_foreground(binding, &job, crate::platform::process_identity) + }); + } + } live_runtime_agent(runtime) == Some(expected) } +fn retained_binding_in_foreground( + binding: &crate::agent_resume::LiveAgentResumeBinding, + job: &crate::platform::ForegroundJob, + identity: impl FnOnce(u32) -> Option, +) -> bool { + let Some((process_group_id, process)) = &binding.process else { + return false; + }; + let Some(expected_identity) = binding.process_identity else { + return false; + }; + job.process_group_id == *process_group_id + && job.processes.iter().any(|member| member.pid == process.pid) + && identity(process.pid) == Some(expected_identity) +} + fn live_runtime_agent(runtime: &crate::terminal::TerminalRuntime) -> Option { let job = crate::detect::foreground_job(runtime.child_pid()?)?; crate::detect::identify_agent_in_job(&job) @@ -475,6 +566,433 @@ pub(super) enum AgentRenameError { mod tests { use super::valid_agent_name; + #[tokio::test] + async fn dynamic_launch_retains_registry_identity_argv_and_managed_ownership() { + use super::*; + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &crate::config::Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.workspaces = vec![crate::workspace::Workspace::test_new("dynamic")]; + app.state.active = Some(0); + app.state.ensure_test_terminals(); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + let terminal_id = app.state.workspaces[0] + .terminal_id(pane_id) + .unwrap() + .clone(); + let (runtime, mut input) = crate::terminal::TerminalRuntime::test_with_channel(80, 24); + app.terminal_runtimes.insert(terminal_id.clone(), runtime); + let registry = crate::agent_resume::test_registry( + "novel-42", + "shared-cli", + "separate_flag", + "--session", + ); + let params = AgentStartParams { + name: "reviewer".into(), + kind: " Novel Alias ".into(), + pane_id: app.public_pane_id(0, pane_id).unwrap(), + args: vec!["--session".into(), "native-id".into()], + timeout_ms: None, + }; + let (info, argv) = app + .start_agent_with_registry(params.clone(), registry.clone()) + .unwrap_or_else(|_| panic!("dynamic start failed")); + assert_eq!(argv, ["shared-cli", "--session", "native-id"]); + assert!(info.launch_pending); + assert_eq!(info.agent.as_deref(), Some("novel-42")); + let terminal = &app.state.terminals[&terminal_id]; + assert!(terminal.detected_agent.is_none()); + assert_eq!(terminal.managed_agent_kind().unwrap().as_str(), "novel-42"); + assert!(terminal.hook_authority.is_none()); + assert!(!terminal.full_lifecycle_hook_authority_active()); + assert_eq!( + terminal.persisted_agent_session.as_ref().unwrap().source, + "herdr:launch" + ); + assert_eq!( + terminal + .pinned_agent_resume_recipe + .as_ref() + .unwrap() + .executable, + "shared-cli" + ); + let runtime = app.terminal_runtimes.get(&terminal_id).unwrap(); + let expected_argv = vec!["shared-cli".into(), "--session".into(), "native-id".into()]; + let expected_command = crate::platform::interactive_shell_command( + &expected_argv, + &available_shell_name(runtime).unwrap(), + ) + .unwrap(); + let expected_input = + crate::app::api_helpers::encode_api_submission(runtime, &expected_command); + assert_eq!( + input.try_recv().unwrap().as_ref(), + expected_input.as_slice() + ); + // Failed repeated launch cannot steal the existing owner or enqueue input. + assert!(app.start_agent_with_registry(params, registry).is_err()); + assert!(input.try_recv().is_err()); + assert_eq!(app.state.workspaces.len(), 1); + assert_eq!(app.state.terminals.len(), 1); + assert_eq!( + app.state.terminals[&terminal_id].agent_name.as_deref(), + Some("reviewer") + ); + } + + #[tokio::test] + async fn normal_launch_pins_strict_readiness_and_starts_pending_only_after_input_enqueue() { + use super::*; + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &crate::config::Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.workspaces = vec![crate::workspace::Workspace::test_new("strict")]; + app.state.active = Some(0); + app.state.ensure_test_terminals(); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + let terminal_id = app.state.workspaces[0] + .terminal_id(pane_id) + .unwrap() + .clone(); + let (runtime, mut input) = crate::terminal::TerminalRuntime::test_with_channel(80, 24); + app.terminal_runtimes.insert(terminal_id.clone(), runtime); + let registry = crate::agents::store::snapshot_for_test( + vec![ + ( + "agents/strict-launch/agent.toml".into(), + "schema = 1\nid = 'strict-launch'\nname = 'strict-launch'\naliases = []\nstartable = true\n[launch]\nunix = 'strict-launch'\nwindows = 'strict-launch'\n".into(), + ), + ( + "agents/strict-launch/process.toml".into(), + "names = ['strict-launch']\n".into(), + ), + ( + "agents/strict-launch/detection.toml".into(), + "id = 'strict-launch'\nversion = '2026.09.05.1'\nmin_engine_version = 1\n[[rules]]\nid = 'idle'\nstate = 'idle'\npriority = 10\nregion = 'bottom_lines(4)'\nvisible_idle = true\ncontains = ['ready']\n".into(), + ), + ], + 44, + ) + .unwrap(); + app.start_agent_with_registry( + AgentStartParams { + name: "reviewer".into(), + kind: "strict-launch".into(), + pane_id: app.public_pane_id(0, pane_id).unwrap(), + args: Vec::new(), + timeout_ms: None, + }, + registry, + ) + .unwrap_or_else(|_| panic!("strict launch failed")); + assert!( + input.try_recv().is_ok(), + "command must be enqueued before Pending is installed" + ); + let terminal = &app.state.terminals[&terminal_id]; + assert!(terminal.managed_agent_launch_pending()); + assert!(terminal.screen_detection_required_for_managed_startup()); + assert!(!terminal.managed_agent_interactive_ready()); + + // Active registry changes cannot downgrade an admitted startup's pinned policy. + let _downgraded = crate::agent_resume::test_registry( + "strict-launch", + "strict-launch", + "subcommand", + "resume", + ); + assert!(app.state.terminals[&terminal_id].screen_detection_required_for_managed_startup()); + } + + #[tokio::test] + async fn failed_command_injection_never_starts_managed_pending() { + use super::*; + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &crate::config::Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.workspaces = vec![crate::workspace::Workspace::test_new("failed-input")]; + app.state.active = Some(0); + app.state.ensure_test_terminals(); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + let terminal_id = app.state.workspaces[0] + .terminal_id(pane_id) + .unwrap() + .clone(); + let (runtime, input) = crate::terminal::TerminalRuntime::test_with_channel(80, 24); + drop(input); + app.terminal_runtimes.insert(terminal_id.clone(), runtime); + let result = app.start_agent_with_registry( + AgentStartParams { + name: "reviewer".into(), + kind: "novel-42".into(), + pane_id: app.public_pane_id(0, pane_id).unwrap(), + args: Vec::new(), + timeout_ms: None, + }, + crate::agent_resume::test_registry("novel-42", "shared-cli", "subcommand", "resume"), + ); + assert!(matches!(result, Err(AgentStartError::InputFailed(_)))); + let terminal = &app.state.terminals[&terminal_id]; + assert_eq!(terminal.agent_name, None); + assert_eq!(terminal.managed_agent_kind(), None); + assert!(!terminal.managed_agent_launch_pending()); + assert_eq!(terminal.next_managed_agent_deadline(), None); + } + + #[test] + fn acquired_resume_recipe_is_protected_across_adversarial_identity_state_and_reload() { + let mut state = crate::app::AppState::test_with_adversarial_identity_state(); + state.assert_invariants_for_test(); + let pane_id = state.workspaces[0].tabs[state.workspaces[0].active_tab].root_pane; + let terminal_id = state.workspaces[0].terminal_id(pane_id).unwrap().clone(); + let original = + crate::agent_resume::test_registry("codex", "old-cli", "subcommand", "resume"); + let changed = + crate::agent_resume::test_registry("codex", "new-cli", "subcommand", "continue"); + let recipe = original + .profile_by_id("codex") + .and_then(crate::agent_resume::PinnedAgentResumeRecipe::capture) + .unwrap(); + let now = std::time::Instant::now(); + let binding = crate::agent_resume::LiveAgentResumeBinding { + agent: crate::detect::Agent::Codex, + recipe: Some(recipe.clone()), + process: Some(( + 100, + crate::platform::ForegroundProcess { + pid: 100, + name: "old-cli".into(), + argv0: None, + argv: None, + cmdline: None, + }, + )), + process_identity: Some(crate::platform::ProcessIdentity { + pid: 100, + birth_token: 1, + }), + observed_at: now, + managed_admission: false, + report_proof: None, + resume_options_owner: None, + resume_options: None, + }; + state.handle_app_event(crate::events::AppEvent::AgentResumeProcessBound { + pane_id, + binding: Box::new(binding.clone()), + }); + state.handle_app_event(crate::events::AppEvent::AgentProcessDetected { + pane_id, + agent: crate::detect::Agent::Codex, + observed_at: now, + }); + let mut refreshed = binding; + refreshed.recipe = changed + .profile_by_id("codex") + .and_then(crate::agent_resume::PinnedAgentResumeRecipe::capture); + refreshed.observed_at += std::time::Duration::from_millis(1); + state.handle_app_event(crate::events::AppEvent::AgentResumeProcessBound { + pane_id, + binding: Box::new(refreshed), + }); + state.handle_app_event(crate::events::AppEvent::AgentSessionReported { + pane_id, + source: "herdr:codex".into(), + agent_label: "codex".into(), + seq: None, + session_ref: crate::agent_resume::AgentSessionRef::id("session"), + session_start_source: Some("startup".into()), + }); + state.assert_invariants_for_test(); + let terminal = &state.terminals[&terminal_id]; + assert_eq!(terminal.pinned_agent_resume_recipe.as_ref(), Some(&recipe)); + assert!(crate::agent_resume::pinned_plan( + &changed, + terminal.persisted_agent_session.as_ref().unwrap(), + terminal.pinned_agent_resume_recipe.as_ref() + ) + .is_err()); + } + + #[test] + fn internal_launch_session_is_not_replaceable_by_report_events() { + let mut state = crate::app::AppState::test_with_adversarial_identity_state(); + state.assert_invariants_for_test(); + let pane_id = state.workspaces[0].tabs[state.workspaces[0].active_tab].root_pane; + let terminal_id = state.workspaces[0].terminal_id(pane_id).unwrap().clone(); + let registry = crate::agent_resume::test_registry( + "novel-42", + "shared-cli", + "separate_flag", + "--session", + ); + let profile = registry.profile_by_id("novel-42").unwrap(); + let captured = crate::agent_resume::persisted_session_from_profile_launch_args( + profile, + &["--session".into(), "real-id".into()], + ) + .unwrap(); + let terminal = state.terminals.get_mut(&terminal_id).unwrap(); + terminal.restore_managed_agent("reviewer".into(), profile.legacy_agent()); + terminal.set_persisted_agent_session(captured.clone()); + terminal.pinned_agent_resume_recipe = + crate::agent_resume::PinnedAgentResumeRecipe::capture(profile); + let pinned = terminal.pinned_agent_resume_recipe.clone(); + for event in [ + crate::events::AppEvent::AgentSessionReported { + pane_id, + source: "herdr:launch".into(), + agent_label: "novel-42".into(), + seq: Some(99), + session_ref: crate::agent_resume::AgentSessionRef::id("forged-id"), + session_start_source: Some("new".into()), + }, + crate::events::AppEvent::HookStateReported { + pane_id, + source: "herdr:launch".into(), + agent_label: "novel-42".into(), + state: crate::detect::AgentState::Working, + message: None, + seq: Some(100), + session_ref: crate::agent_resume::AgentSessionRef::id("forged-id"), + }, + ] { + assert!(state.handle_app_event(event).is_empty()); + state.assert_invariants_for_test(); + let terminal = &state.terminals[&terminal_id]; + assert_eq!(terminal.persisted_agent_session.as_ref(), Some(&captured)); + assert_eq!(terminal.pinned_agent_resume_recipe, pinned); + assert_eq!(terminal.agent_name.as_deref(), Some("reviewer")); + assert!(terminal.hook_authority.is_none()); + assert!(!terminal.full_lifecycle_hook_authority_active()); + } + } + + #[tokio::test] + async fn dynamic_launch_rejection_does_not_mutate_app_state_or_write_input() { + use super::*; + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &crate::config::Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.workspaces = vec![crate::workspace::Workspace::test_new("dynamic")]; + app.state.active = Some(0); + app.state.ensure_test_terminals(); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + let terminal_id = app.state.workspaces[0] + .terminal_id(pane_id) + .unwrap() + .clone(); + let (runtime, mut input) = crate::terminal::TerminalRuntime::test_with_channel(80, 24); + app.terminal_runtimes.insert(terminal_id.clone(), runtime); + let registry = crate::agent_resume::test_registry( + "novel-42", + "shared-cli", + "separate_flag", + "--session", + ); + for (kind, args, timeout_ms) in [ + ("absent", vec![], None), + ("novel-42", vec!["bad\nargument".into()], None), + ("novel-42", vec![], Some(1)), + ] { + let params = AgentStartParams { + name: "reviewer".into(), + kind: kind.into(), + pane_id: app.public_pane_id(0, pane_id).unwrap(), + args, + timeout_ms, + }; + assert!(app + .start_agent_with_registry(params, registry.clone()) + .is_err()); + let terminal = &app.state.terminals[&terminal_id]; + assert!(terminal.agent_name.is_none()); + assert!(terminal.managed_agent_kind().is_none()); + assert!(terminal.persisted_agent_session.is_none()); + assert!(terminal.pinned_agent_resume_recipe.is_none()); + assert!(input.try_recv().is_err()); + assert_eq!(app.state.workspaces[0].tabs[0].panes.len(), 1); + } + } + + #[test] + fn retained_prompt_identity_requires_foreground_membership_and_birth_not_mutable_argv() { + let process = crate::platform::ForegroundProcess { + pid: 100, + name: "old-cli".into(), + argv0: None, + argv: None, + cmdline: None, + }; + let identity = crate::platform::ProcessIdentity { + pid: 100, + birth_token: 1, + }; + let binding = crate::agent_resume::LiveAgentResumeBinding { + agent: crate::detect::Agent::parse("removed-agent").unwrap(), + recipe: None, + process: Some((100, process.clone())), + process_identity: Some(identity), + observed_at: std::time::Instant::now(), + managed_admission: false, + report_proof: None, + resume_options_owner: None, + resume_options: None, + }; + let mut job = crate::platform::ForegroundJob { + process_group_id: 100, + processes: vec![process], + }; + job.processes[0].name = "mutable-title".into(); + job.processes[0].argv = Some(vec!["different presentation".into()]); + assert!(super::retained_binding_in_foreground( + &binding, + &job, + |_| Some(identity) + )); + assert!(!super::retained_binding_in_foreground( + &binding, + &job, + |_| Some(crate::platform::ProcessIdentity { + birth_token: 2, + ..identity + }) + )); + assert!(!super::retained_binding_in_foreground( + &binding, + &job, + |_| None + )); + job.process_group_id = 101; + assert!(!super::retained_binding_in_foreground( + &binding, + &job, + |_| Some(identity) + )); + } + #[test] fn agent_names_use_a_small_cli_safe_grammar() { for name in ["a", "reviewer-one", "reviewer_2", &"a".repeat(32)] { diff --git a/src/app/api.rs b/src/app/api.rs index 9bd0248100..56f2c52a64 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -29,6 +29,9 @@ enum RuntimeExitAction { impl App { pub(crate) fn handle_internal_event_with_render_impact(&mut self, ev: AppEvent) -> bool { + let Some(ev) = ev.into_current_detection(crate::agents::store::generation()) else { + return false; + }; match ev { AppEvent::GitStatusRefreshed { results, @@ -83,6 +86,9 @@ impl App { &mut self, ev: AppEvent, ) -> Vec { + let Some(ev) = ev.into_current_detection(crate::agents::store::generation()) else { + return Vec::new(); + }; let mut worktree_restore_failed = false; let ev = match ev { AppEvent::WorktreeRuntimeRestoreFailed { @@ -303,12 +309,6 @@ impl App { } else { None }; - let manifest_update_agents = - if let AppEvent::AgentDetectionManifestsUpdated { activated, .. } = &ev { - Some(activated.clone()) - } else { - None - }; let terminal_cwd_reported = matches!(ev, AppEvent::TerminalCwdReported { .. }); let previous_toast = self.state.toast.clone(); let mut pane_updates = self.state.handle_app_event(ev); @@ -318,9 +318,6 @@ impl App { if checkpointed_pane_exit { self.finish_checkpointed_pane_exit(); } - if let Some(agents) = manifest_update_agents { - self.reset_agent_detection_for_agents(&agents); - } if let Some((pane_id, agent)) = released_agent { if pane_updates.iter().any(|update| update.pane_id == pane_id) { if let Some((ws_idx, _)) = self.find_pane(pane_id) { @@ -369,23 +366,6 @@ impl App { pane_updates } - fn reset_agent_detection_for_agents(&self, agents: &[crate::detect::Agent]) { - if agents.is_empty() { - return; - } - for (terminal_id, terminal) in &self.state.terminals { - let Some(agent) = terminal.effective_known_agent().or(terminal.detected_agent) else { - continue; - }; - if !agents.contains(&agent) { - continue; - } - if let Some(runtime) = self.terminal_runtimes.get(terminal_id) { - runtime.reset_agent_detection(); - } - } - } - fn reset_all_agent_detection_runtimes(&self) { for runtime in self.terminal_runtimes.values() { runtime.reset_agent_detection(); @@ -448,7 +428,8 @@ impl App { continue; }; runtime.set_full_lifecycle_authority_active( - terminal.full_lifecycle_hook_authority_active(), + terminal.full_lifecycle_hook_authority_active() + && !terminal.screen_detection_required_for_managed_startup(), ); } } @@ -921,7 +902,7 @@ impl App { } Method::ServerAgentManifests(_) => { self.state.refresh_agent_manifest_summaries(); - let update_status = crate::detect::manifest_update::load_status(); + let update_status = crate::detect::manifest_compat::load_status(); SuccessResponse { id: request.id, result: ResponseResult::AgentManifestStatus { @@ -940,7 +921,7 @@ impl App { Method::ServerReloadAgentManifests(_) => { let summaries = crate::detect::manifest::reload_manifests(); self.state.agent_manifest_summaries = summaries.clone(); - let update_status = crate::detect::manifest_update::load_status(); + let update_status = crate::detect::manifest_compat::load_status(); self.reset_all_agent_detection_runtimes(); SuccessResponse { id: request.id, @@ -952,6 +933,10 @@ impl App { }, } } + Method::RegistryPresentationRefresh(_) => { + self.refresh_registry_integration_recommendations(); + return responses::encode_success(request.id, ResponseResult::Ok {}); + } Method::NotificationShow(params) => { return self.handle_notification_show(request.id, params); } @@ -1327,11 +1312,11 @@ fn sanitized_notification_text(value: &str, max_chars: usize) -> Option fn agent_manifest_info( summary: crate::detect::manifest::AgentManifestSummary, - update_status: &crate::detect::manifest_update::ManifestUpdateStatus, + update_status: &crate::detect::manifest_compat::ManifestUpdateStatus, ) -> crate::api::schema::AgentManifestInfo { let remote = update_status.agent_status(summary.agent); crate::api::schema::AgentManifestInfo { - agent: crate::detect::agent_label(summary.agent).to_string(), + agent: crate::detect::agent_label(&summary.agent).to_string(), source: summary.active_source.label(), source_kind: summary.active_source.kind().to_string(), active_version: summary.active_version, @@ -1411,8 +1396,8 @@ mod tests { app } - #[tokio::test] - async fn manifest_activation_event_resets_matching_agent_detection_runtime() { + #[test] + fn stale_registry_detection_is_rejected_before_identity_or_state_mutation() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), @@ -1421,33 +1406,44 @@ mod tests { api_rx, crate::api::EventHub::default(), ); - app.state.workspaces = vec![crate::workspace::Workspace::test_new("manifest-reset")]; + let workspace = crate::workspace::Workspace::test_new("stale-detection"); + let pane_id = workspace.tabs[0].root_pane; + let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap(); + app.state.workspaces = vec![workspace]; app.state.ensure_test_terminals(); - let pane_id = app.state.workspaces[0].tabs[0].root_pane; - let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id] - .attached_terminal_id - .clone(); app.state .terminals .get_mut(&terminal_id) .unwrap() - .detected_agent = Some(Agent::Codex); - let (runtime, _rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 24); - let reset_notify = runtime.agent_detection_reset_notify_for_test(); - app.terminal_runtimes.insert(terminal_id, runtime); - - app.handle_internal_event(AppEvent::AgentDetectionManifestsUpdated { - updated: Vec::new(), - activated: vec![Agent::Codex], - status: crate::detect::manifest_update::ManifestUpdateStatus::default(), + .set_detected_state(Some(Agent::Pi), AgentState::Working); + let stale_generation = crate::agents::store::generation().wrapping_sub(1); + let updates = app.handle_internal_event_with_pane_updates(AppEvent::AgentDetection { + registry_generation: stale_generation, + observation: Box::new(AppEvent::StateChanged { + pane_id, + agent: None, + state: AgentState::Unknown, + visible_idle: false, + visible_blocker: false, + visible_working: false, + process_exited: true, + observed_at: Instant::now(), + }), }); - - tokio::time::timeout( - std::time::Duration::from_millis(50), - reset_notify.notified(), - ) - .await - .expect("matching agent detection runtime should be reset"); + assert!(updates.is_empty()); + assert!( + !app.handle_internal_event_with_render_impact(AppEvent::AgentDetection { + registry_generation: stale_generation, + observation: Box::new(AppEvent::AgentProcessDetected { + pane_id, + agent: Agent::Codex, + observed_at: Instant::now(), + }), + }) + ); + let terminal = app.state.terminals.get(&terminal_id).unwrap(); + assert_eq!(terminal.detected_agent, Some(Agent::Pi)); + assert_eq!(terminal.fallback_state, AgentState::Working); } #[test] @@ -1843,6 +1839,7 @@ mod tests { pane_id: root, agent: Some(Agent::Codex), state: AgentState::Working, + visible_idle: false, visible_blocker: false, visible_working: false, process_exited: false, @@ -1852,6 +1849,7 @@ mod tests { pane_id: root, agent: Some(Agent::Codex), state: AgentState::Idle, + visible_idle: false, visible_blocker: false, visible_working: false, process_exited: false, @@ -1936,6 +1934,7 @@ mod tests { pane_id: root, agent: Some(Agent::Codex), state: AgentState::Working, + visible_idle: false, visible_blocker: false, visible_working: false, process_exited: false, @@ -1945,6 +1944,7 @@ mod tests { pane_id: root, agent: Some(Agent::Codex), state: AgentState::Idle, + visible_idle: false, visible_blocker: false, visible_working: false, process_exited: false, @@ -2061,6 +2061,7 @@ mod tests { pane_id, agent: Some(Agent::Pi), state: AgentState::Idle, + visible_idle: false, visible_blocker: false, visible_working: false, process_exited: true, @@ -2115,6 +2116,7 @@ mod tests { pane_id, agent: Some(Agent::Codex), state: AgentState::Idle, + visible_idle: false, visible_blocker: false, visible_working: false, process_exited: true, @@ -2295,6 +2297,7 @@ mod tests { pane_id, agent: Some(crate::detect::Agent::OpenCode), state: AgentState::Idle, + visible_idle: false, visible_blocker: false, visible_working: false, process_exited: true, @@ -2419,6 +2422,7 @@ mod tests { pane_id: root, agent: Some(Agent::Codex), state: AgentState::Working, + visible_idle: false, visible_blocker: false, visible_working: false, process_exited: false, @@ -2439,6 +2443,7 @@ mod tests { pane_id: root, agent: Some(Agent::Codex), state: AgentState::Idle, + visible_idle: false, visible_blocker: false, visible_working: false, process_exited: false, diff --git a/src/app/api/agents.rs b/src/app/api/agents.rs index 1c0a5369b1..09b426e360 100644 --- a/src/app/api/agents.rs +++ b/src/app/api/agents.rs @@ -162,7 +162,11 @@ impl App { let Some(runtime) = self.lookup_runtime_sender(resolved.ws_idx, resolved.pane_id) else { return Err(agent_not_found(id, ¶ms.target)); }; - if !super::super::agents::runtime_hosts_agent(runtime, expected_agent) { + if !super::super::agents::runtime_hosts_agent( + runtime, + expected_agent, + terminal.live_agent_resume_binding.as_ref(), + ) { return Err(encode_error( id, "agent_not_ready", @@ -276,7 +280,9 @@ impl App { let Some(terminal) = self.state.terminals.get(terminal_id) else { return agent_not_found(id, &target.target); }; - if terminal.full_lifecycle_hook_authority_active() { + if terminal.full_lifecycle_hook_authority_active() + && !terminal.screen_detection_required_for_managed_startup() + { let explain = serde_json::json!({ "agent": terminal.effective_agent_label().unwrap_or("unknown"), "state": crate::detect::manifest::agent_state_label(terminal.state), @@ -355,7 +361,14 @@ impl App { let Some(runtime) = self.lookup_runtime_sender(resolved.ws_idx, resolved.pane_id) else { return agent_not_found(id, ¶ms.target); }; - if !super::super::agents::runtime_hosts_agent(runtime, expected_agent) { + if !super::super::agents::runtime_hosts_agent( + runtime, + expected_agent, + self.state + .terminals + .get(terminal_id) + .and_then(|terminal| terminal.live_agent_resume_binding.as_ref()), + ) { return agent_not_ready(id, ¶ms.target); } let encoded = match super::super::api_helpers::encode_api_keys(runtime, ¶ms.keys) { diff --git a/src/app/api/integrations.rs b/src/app/api/integrations.rs index ba1603e2d8..5c0c88b767 100644 --- a/src/app/api/integrations.rs +++ b/src/app/api/integrations.rs @@ -7,6 +7,27 @@ use crate::app::App; use super::responses::{encode_error, encode_success}; impl App { + pub(super) fn refresh_registry_integration_recommendations(&mut self) { + crate::api::REGISTRY_PUBLICATION_WAKEUP.begin_refresh(); + let snapshot = crate::agents::store::snapshot(); + if refresh_recommendations_for_generation( + &mut self.integration_registry_generation, + &mut self.state.integration_recommendations, + snapshot.generation, + || crate::integration::integration_recommendations_with_registry(&snapshot), + ) { + self.render_dirty.request_generic(); + self.render_notify.notify_one(); + } + } + + fn refresh_installed_integration_recommendations(&mut self) { + let snapshot = crate::agents::store::snapshot(); + self.state.integration_recommendations = + crate::integration::integration_recommendations_with_registry(&snapshot); + self.integration_registry_generation = snapshot.generation; + } + pub(super) fn handle_integration_list(&self, id: String) -> String { let integrations = crate::integration::integration_recommendations() .into_iter() @@ -39,7 +60,7 @@ impl App { Ok(messages) => messages, Err(err) => return encode_error(id, "integration_install_failed", err.to_string()), }; - self.state.integration_recommendations = crate::integration::integration_recommendations(); + self.refresh_installed_integration_recommendations(); encode_success( id, @@ -60,7 +81,7 @@ impl App { Ok(messages) => messages, Err(err) => return encode_error(id, "integration_uninstall_failed", err.to_string()), }; - self.state.integration_recommendations = crate::integration::integration_recommendations(); + self.refresh_installed_integration_recommendations(); encode_success( id, @@ -71,3 +92,98 @@ impl App { ) } } + +// Explicit publication maintenance only: never called from compute_view, render, +// or per-pane reconciliation. Obsolete/coalesced wakeups do no filesystem work. +fn refresh_recommendations_for_generation( + observed: &mut u64, + recommendations: &mut Vec, + generation: u64, + refresh: impl FnOnce() -> Vec, +) -> bool { + if generation <= *observed { + return false; + } + *recommendations = refresh(); + *observed = generation; + true +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::schema::IntegrationTarget; + use crate::integration::{IntegrationRecommendation, IntegrationStatusKind}; + + fn recommendation(label: &str) -> IntegrationRecommendation { + IntegrationRecommendation { + target: IntegrationTarget::Claude, + label: label.into(), + command: label.into(), + available: true, + path: "unused".into(), + state: IntegrationStatusKind::Outdated, + } + } + + #[test] + fn publication_refresh_uses_the_pinned_snapshot_registry_and_generation() { + let snapshot = crate::agents::store::snapshot_for_test( + vec![( + "agents/claude/agent.toml".into(), + "schema = 1\nid = 'claude'\nname = 'claude'\naliases = []\nstartable = true\n[launch]\nunix = 'claude'\nwindows = 'claude'\n".into(), + )], + 42, + ).unwrap(); + let mut observed = 41; + let mut recommendations = vec![recommendation("old-label")]; + assert!(refresh_recommendations_for_generation( + &mut observed, + &mut recommendations, + snapshot.generation, + || crate::integration::integration_recommendations_with_registry(&snapshot), + )); + assert_eq!(observed, 42); + // The pinned source removed integration metadata; the global bundled + // registry must not supply its unrelated recommendation instead. + assert!(recommendations.is_empty()); + } + + #[test] + fn publication_refresh_relabels_and_removes_cached_recommendations_once_per_generation() { + let mut observed = 1; + let mut recommendations = vec![recommendation("old-label")]; + assert!(!refresh_recommendations_for_generation( + &mut observed, + &mut recommendations, + 1, + || panic!("unchanged generation must not probe integration files"), + )); + assert!(refresh_recommendations_for_generation( + &mut observed, + &mut recommendations, + 3, + || vec![recommendation("new-label")], + )); + assert_eq!(recommendations[0].label, "new-label"); + for stale in [2, 3] { + assert!(!refresh_recommendations_for_generation( + &mut observed, + &mut recommendations, + stale, + || panic!("coalesced or stale wakeup must not refresh"), + )); + } + assert!(refresh_recommendations_for_generation( + &mut observed, + &mut recommendations, + 4, + Vec::new, + )); + let state = crate::app::state::AppState { + integration_recommendations: recommendations, + ..crate::app::state::AppState::test_new() + }; + assert!(!state.integration_updates_available()); + } +} diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index 9b9a28cc7b..d039f19a68 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -1528,25 +1528,69 @@ impl App { ) } + fn select_bound_report_session( + &mut self, + pane_id: crate::layout::PaneId, + source: &str, + agent: &str, + id: Option, + path: Option, + ) -> Option { + let terminal_id = self + .state + .workspaces + .iter() + .find_map(|ws| ws.terminal_id(pane_id)) + .cloned()?; + let reference = self + .state + .terminals + .get_mut(&terminal_id)? + .session_ref_from_bound_report(source, agent, id, path)?; + // A one-shot startup report may beat the detector. Associate it only + // with a foreground process lifetime observed now, never with a future + // process that happens to report the same canonical agent identity. + if let Some(identity) = self + .terminal_runtimes + .get(&terminal_id) + .and_then(|runtime| report_foreground_process_identity(runtime, agent)) + { + self.state + .terminals + .get_mut(&terminal_id)? + .record_report_process_proof(agent, &reference, identity); + } + Some(reference) + } + pub(super) fn handle_pane_report_agent( &mut self, id: String, params: PaneReportAgentParams, ) -> String { + if params.source == "herdr:launch" { + return encode_error( + id, + "invalid_agent_source", + "launch provenance is internal only", + ); + } let Some((_ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { return pane_not_found(id, ¶ms.pane_id); }; let Some(agent_label) = normalize_reported_agent_label(¶ms.agent) else { return invalid_agent(id); }; + let session_ref = self.select_bound_report_session( + pane_id, + ¶ms.source, + &agent_label, + params.agent_session_id, + params.agent_session_path, + ); self.handle_internal_event(crate::events::AppEvent::HookStateReported { pane_id, - session_ref: crate::agent_resume::session_ref_from_report( - ¶ms.source, - &agent_label, - params.agent_session_id, - params.agent_session_path, - ), + session_ref, source: params.source, agent_label, state: detect_state_from_api(params.state), @@ -1562,20 +1606,29 @@ impl App { id: String, params: PaneReportAgentSessionParams, ) -> String { + if params.source == "herdr:launch" { + return encode_error( + id, + "invalid_agent_source", + "launch provenance is internal only", + ); + } let Some((_ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { return pane_not_found(id, ¶ms.pane_id); }; let Some(agent_label) = normalize_reported_agent_label(¶ms.agent) else { return invalid_agent(id); }; + let session_ref = self.select_bound_report_session( + pane_id, + ¶ms.source, + &agent_label, + params.agent_session_id, + params.agent_session_path, + ); self.handle_internal_event(crate::events::AppEvent::AgentSessionReported { pane_id, - session_ref: crate::agent_resume::session_ref_from_report( - ¶ms.source, - &agent_label, - params.agent_session_id, - params.agent_session_path, - ), + session_ref, source: params.source, agent_label, seq: params.seq, @@ -2198,6 +2251,29 @@ fn invalid_agent(id: String) -> String { encode_error(id, "invalid_agent", "agent label must not be empty") } +fn report_foreground_process_identity( + runtime: &crate::terminal::TerminalRuntime, + agent: &str, +) -> Option { + let registry = crate::agents::registry(); + let job = crate::platform::foreground_job_with_registry(®istry, runtime.child_pid()?, None)?; + job.processes.iter().find_map(|process| { + let identified = crate::detect::identify_agent_in_job_with_registry( + ®istry, + &crate::platform::ForegroundJob { + process_group_id: process.pid, + processes: vec![process.clone()], + }, + ) + .map(|(agent, _)| agent) + .or_else(|| crate::platform::process_agent_hint_with_registry(®istry, process.pid)); + if identified.is_none_or(|identified| identified.as_str() != agent) { + return None; + } + crate::platform::process_identity(process.pid) + }) +} + #[cfg(test)] mod tests { use super::*; @@ -2224,6 +2300,41 @@ mod tests { (app, public_pane_id) } + #[test] + fn agent_reports_cannot_claim_internal_launch_provenance() { + let (mut app, _) = app_with_test_workspace(); + app.state = crate::app::AppState::test_with_adversarial_identity_state(); + let workspace = &app.state.workspaces[0]; + let pane_id = app + .public_pane_id(0, workspace.tabs[workspace.active_tab].root_pane) + .unwrap(); + let params = serde_json::json!({ + "pane_id": pane_id, + "source": "herdr:launch", + "agent": "novel-agent", + "state": "working", + "agent_session_id": "forged-id" + }); + let state_response = app.handle_pane_report_agent( + "state".into(), + serde_json::from_value(params.clone()).unwrap(), + ); + let mut session_params = params; + session_params.as_object_mut().unwrap().remove("state"); + let session_response = app.handle_pane_report_agent_session( + "session".into(), + serde_json::from_value(session_params).unwrap(), + ); + for response in [state_response, session_response] { + let response: serde_json::Value = serde_json::from_str(&response).unwrap(); + assert_eq!(response["error"]["code"], "invalid_agent_source"); + } + assert!(app.state.terminals.values().all(|terminal| { + terminal.hook_authority.is_none() && terminal.persisted_agent_session.is_none() + })); + app.state.assert_invariants_for_test(); + } + #[test] fn pane_input_set_changes_only_the_target_pane() { let (mut app, public_pane_id) = app_with_test_workspace(); diff --git a/src/app/api/worktrees.rs b/src/app/api/worktrees.rs index 71f2e03a1e..edc9455989 100644 --- a/src/app/api/worktrees.rs +++ b/src/app/api/worktrees.rs @@ -1195,6 +1195,7 @@ mod tests { async fn deferred_api_worktree_create_completes_after_source_workspace_changes() { let event_hub = crate::api::EventHub::default(); let mut app = test_app_with_event_hub(event_hub.clone()); + app.state.default_shell = test_shell().into(); let repo = create_committed_repo("api-worktree-create-changed-source-repo"); let checkout = unique_temp_path("api-worktree-create-changed-source-checkout"); std::fs::create_dir_all(&checkout).unwrap(); @@ -2270,6 +2271,7 @@ mod tests { pane_id, agent: Some(crate::detect::Agent::Codex), state: crate::detect::AgentState::Blocked, + visible_idle: false, visible_blocker: true, visible_working: false, process_exited: false, diff --git a/src/app/api_helpers.rs b/src/app/api_helpers.rs index a224938d3b..fc7163f5ed 100644 --- a/src/app/api_helpers.rs +++ b/src/app/api_helpers.rs @@ -194,7 +194,7 @@ pub(super) fn normalize_reported_agent_label(agent: &str) -> Option { return None; } if let Some(agent) = crate::detect::parse_agent_label(trimmed) { - return Some(crate::detect::agent_label(agent).to_string()); + return Some(crate::detect::agent_label(&agent).to_string()); } Some(trimmed.to_string()) } diff --git a/src/app/mod.rs b/src/app/mod.rs index 4ac109c778..2f97fc22aa 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -117,6 +117,7 @@ pub struct App { pub(crate) config_diagnostic_deadline: Option, pub(crate) toast_deadline: Option, pub(crate) last_api_notification_at: Option, + pub(crate) integration_registry_generation: u64, pub(crate) last_git_remote_status_refresh: Instant, pub(crate) last_git_repo_discovery_refresh: Instant, pub(crate) git_refresh_in_flight: bool, @@ -130,8 +131,8 @@ pub struct App { pub(crate) pending_worktree_remove_runtime_restores: HashMap, pub(crate) next_api_worktree_operation_id: u64, pub(crate) next_auto_update_check: Option, - pub(crate) next_agent_manifest_update_check: Option, pub(crate) update_version_check_enabled: bool, + pub(crate) next_agent_registry_update_check: Option, pub(crate) update_manifest_check_enabled: bool, pub(crate) loaded_host_cursor: crate::config::HostCursorModeConfig, pub(crate) agent_metadata_deadline: Option, @@ -441,6 +442,8 @@ impl App { let theme_runtime = theme_runtime_config(config, true); let (theme_palette, theme_name) = resolve_effective_theme(&theme_runtime, None); + let integration_registry = crate::agents::store::snapshot(); + let integration_registry_generation = integration_registry.generation; let mut state = AppState { terminals: std::collections::HashMap::new(), direct_attach_resize_locks: std::collections::HashSet::new(), @@ -510,9 +513,9 @@ impl App { theme_runtime, host_terminal_appearance: None, host_terminal_appearance_explicit: false, - integration_recommendations: crate::integration::integration_recommendations(), + integration_recommendations: + crate::integration::integration_recommendations_with_registry(&integration_registry), agent_manifest_summaries, - agent_manifest_update_status: crate::detect::manifest_update::load_status(), installed_plugins: load_plugin_registry(policy.persist_plugin_registry), plugin_panes: std::collections::HashMap::new(), popup_pane: None, @@ -539,20 +542,10 @@ impl App { // running binary out from under spawned test processes. let version_check_enabled = background_update_check_enabled(policy.background_updates, config.update.version_check); - let manifest_check_enabled = background_update_check_enabled( - policy.background_updates, - config.update.manifest_check, - ); if version_check_enabled { let update_tx = event_tx.clone(); std::thread::spawn(move || crate::update::auto_update(update_tx)); } - if manifest_check_enabled { - let manifest_update_tx = event_tx.clone(); - std::thread::spawn(move || { - crate::detect::manifest_update::auto_update(manifest_update_tx) - }); - } let last_focus = state.active.and_then(|idx| { state @@ -568,6 +561,7 @@ impl App { config_diagnostic_deadline: None, toast_deadline: None, last_api_notification_at: None, + integration_registry_generation, state, pane_graphics: pane_graphics::Runtime::default(), pane_graphics_files: Arc::new(crate::pane_graphics_files::FileStore::default()), @@ -590,9 +584,12 @@ impl App { next_api_worktree_operation_id: 1, next_auto_update_check: version_check_enabled .then_some(Instant::now() + AUTO_UPDATE_CHECK_INTERVAL), - next_agent_manifest_update_check: manifest_check_enabled - .then_some(Instant::now() + AUTO_UPDATE_CHECK_INTERVAL), update_version_check_enabled: config.update.version_check, + next_agent_registry_update_check: background_update_check_enabled( + policy.background_updates, + config.update.manifest_check, + ) + .then_some(Instant::now()), update_manifest_check_enabled: config.update.manifest_check, loaded_host_cursor: config.ui.host_cursor, agent_metadata_deadline: None, @@ -893,9 +890,17 @@ impl App { if !invalid_section("update") { let now = Instant::now(); let previous_version_check_enabled = self.update_version_check_enabled; - let previous_manifest_check_enabled = self.update_manifest_check_enabled; self.update_version_check_enabled = config.update.version_check; + let previous_manifest_check_enabled = self.update_manifest_check_enabled; self.update_manifest_check_enabled = config.update.manifest_check; + if !background_update_check_enabled( + self.policy.background_updates, + self.update_manifest_check_enabled, + ) { + self.next_agent_registry_update_check = None; + } else if !previous_manifest_check_enabled { + self.next_agent_registry_update_check = Some(now); + } if !self.update_version_check_enabled { self.next_auto_update_check = None; @@ -908,17 +913,6 @@ impl App { { self.next_auto_update_check = Some(now); } - - if !self.update_manifest_check_enabled { - self.next_agent_manifest_update_check = None; - } else if !previous_manifest_check_enabled - && background_update_check_enabled( - self.policy.background_updates, - self.update_manifest_check_enabled, - ) - { - self.next_agent_manifest_update_check = Some(now); - } } if !invalid_section("terminal") { @@ -1686,7 +1680,6 @@ mod tests { let mut app = test_app(); app.next_auto_update_check = Some(Instant::now()); - app.next_agent_manifest_update_check = Some(Instant::now()); let report = app.reload_config(); assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); @@ -1716,9 +1709,7 @@ mod tests { crate::config::NewTerminalCwdConfig::Home ); assert!(!app.update_version_check_enabled); - assert!(!app.update_manifest_check_enabled); assert!(app.next_auto_update_check.is_none()); - assert!(app.next_agent_manifest_update_check.is_none()); assert!(app.state.config_diagnostic.is_none()); let toast = app.state.toast.as_ref().unwrap(); assert_eq!(toast.kind, crate::app::state::ToastKind::UpdateInstalled); @@ -1729,6 +1720,64 @@ mod tests { let _ = std::fs::remove_dir_all(path.parent().unwrap()); } + #[test] + fn manifest_check_controls_registry_schedule_independently_of_binary_updates() { + let _guard = config_env_lock().lock().unwrap(); + let path = temp_config_path("registry-auto-update"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + let original = std::env::var_os(crate::config::CONFIG_PATH_ENV_VAR); + std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); + let mut config = Config::default(); + config.update.version_check = false; + config.update.manifest_check = true; + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &config, + AppPolicy { + background_updates: true, + ..AppPolicy::TEST + }, + None, + api_rx, + crate::api::EventHub::default(), + ); + let accepted = crate::agents::registry(); + assert!(app.next_auto_update_check.is_none()); + assert_eq!( + app.next_agent_registry_update_check.is_some(), + background_update_check_enabled(true, true) + ); + for enabled in [false, true, true] { + let previous_deadline = app.next_agent_registry_update_check; + let previous_enabled = app.update_manifest_check_enabled; + let content = format!("[update]\nversion_check = false\nmanifest_check = {enabled}\n"); + std::fs::write(&path, &content).unwrap(); + assert_eq!( + app.reload_config().status, + crate::config::ConfigReloadStatus::Applied + ); + assert!(app.next_auto_update_check.is_none()); + assert_eq!(app.update_manifest_check_enabled, enabled); + assert_eq!( + app.next_agent_registry_update_check.is_some(), + background_update_check_enabled(true, enabled) + ); + if previous_enabled == enabled { + assert_eq!(app.next_agent_registry_update_check, previous_deadline); + } + assert!(std::sync::Arc::ptr_eq( + &accepted, + &crate::agents::registry() + )); + assert_eq!(std::fs::read_to_string(&path).unwrap(), content); + } + match original { + Some(value) => std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, value), + None => std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR), + } + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + #[test] fn reload_config_keeps_kitty_graphics_until_restart() { let _guard = config_env_lock().lock().unwrap(); @@ -3219,6 +3268,7 @@ mod tests { pane_id, agent: Some(Agent::Pi), state: AgentState::Working, + visible_idle: false, visible_blocker: false, visible_working: false, process_exited: false, @@ -3243,6 +3293,7 @@ mod tests { pane_id, agent: Some(Agent::Pi), state: AgentState::Idle, + visible_idle: false, visible_blocker: false, visible_working: false, process_exited: false, diff --git a/src/app/runtime.rs b/src/app/runtime.rs index 99769e7b86..b52147e896 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -6,6 +6,18 @@ use std::time::Duration; use super::{ background_update_check_enabled, App, AUTO_UPDATE_CHECK_INTERVAL, MIN_RENDER_INTERVAL, }; +fn take_due_registry_check(deadline: &mut Option, now: Instant, enabled: bool) -> bool { + if !enabled { + *deadline = None; + return false; + } + if !deadline.is_some_and(|deadline| now >= deadline) { + return false; + } + *deadline = Some(now + AUTO_UPDATE_CHECK_INTERVAL); + true +} + fn retain_detached_process_after_wait( pid: u32, result: std::io::Result>, @@ -117,19 +129,35 @@ impl App { std::thread::spawn(move || crate::update::auto_update(update_tx)); } - pub(crate) fn run_agent_manifest_update_check(&mut self) { - if !background_update_check_enabled( + pub(crate) fn run_agent_registry_update_check( + &mut self, + now: Instant, + api_tx: Option<&crate::api::ApiRequestSender>, + ) { + let enabled = background_update_check_enabled( self.policy.background_updates, self.update_manifest_check_enabled, - ) { - self.next_agent_manifest_update_check = None; + ); + if !take_due_registry_check(&mut self.next_agent_registry_update_check, now, enabled) { return; } - - self.next_agent_manifest_update_check = Some(Instant::now() + AUTO_UPDATE_CHECK_INTERVAL); - - let manifest_update_tx = self.event_tx.clone(); - std::thread::spawn(move || crate::detect::manifest_update::auto_update(manifest_update_tx)); + let Some(api_tx) = api_tx.cloned() else { + return; + }; + std::thread::spawn(move || { + let before = crate::agents::store::generation(); + match crate::agents::store::auto_update_remote() { + Ok(Some(status)) => { + crate::api::REGISTRY_PUBLICATION_WAKEUP.notify( + before, + status.generation, + &api_tx, + ); + } + Ok(None) => {} + Err(error) => tracing::warn!(%error, "automatic agent registry update failed"), + } + }); } pub(crate) fn next_headless_loop_deadline_with_git_refresh( @@ -155,7 +183,7 @@ impl App { .then(|| self.git_refresh_deadline()) .flatten(), self.next_auto_update_check, - self.next_agent_manifest_update_check, + self.next_agent_registry_update_check, self.agent_metadata_deadline, self.pending_agent_resume_deadline, self.session_save_deadline, @@ -207,6 +235,45 @@ mod tests { use super::*; use crate::workspace::Workspace; + #[test] + fn registry_schedule_runs_at_startup_then_every_thirty_minutes_without_catchup_bursts() { + let now = Instant::now(); + let mut deadline = Some(now); + assert!(take_due_registry_check(&mut deadline, now, true)); + assert_eq!(deadline, Some(now + Duration::from_secs(30 * 60))); + assert!(!take_due_registry_check(&mut deadline, now, true)); + assert!(!take_due_registry_check( + &mut deadline, + now + Duration::from_secs(30 * 60 - 1), + true + )); + assert!(take_due_registry_check( + &mut deadline, + now + Duration::from_secs(30 * 60), + true + )); + let after_sleep = now + Duration::from_secs(5 * 60 * 60); + assert!(take_due_registry_check(&mut deadline, after_sleep, true)); + assert_eq!(deadline, Some(after_sleep + AUTO_UPDATE_CHECK_INTERVAL)); + assert!(!take_due_registry_check(&mut deadline, after_sleep, true)); + assert!(!take_due_registry_check(&mut deadline, after_sleep, false)); + assert!(deadline.is_none()); + assert!(!take_due_registry_check(&mut deadline, after_sleep, true)); + } + + #[test] + fn registry_deadline_wakes_headless_loop_without_a_client_or_pending_render() { + let (mut app, _) = test_app_with_pane(); + let now = Instant::now(); + app.next_agent_registry_update_check = Some(now); + assert_eq!( + app.next_headless_loop_deadline_with_git_refresh(now, false, false), + Some(now) + ); + app.run_agent_registry_update_check(now, None); + assert!(app.next_agent_registry_update_check.is_none()); + } + #[test] fn hidden_render_attempt_keeps_presentation_cadence_available() { let (mut app, _) = test_app_with_pane(); diff --git a/src/app/state.rs b/src/app/state.rs index 892ec5e9b9..5cbf0123f4 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -868,8 +868,6 @@ pub struct AppState { /// Cached integration recommendations and detection manifest summaries. pub integration_recommendations: Vec, pub agent_manifest_summaries: Vec, - /// Cached remote detection manifest update diagnostics for runtime/API status. - pub agent_manifest_update_status: crate::detect::manifest_update::ManifestUpdateStatus, /// Installed or linked plugins known to this running Herdr instance. pub(crate) installed_plugins: InstalledPluginRegistry, /// Pane ids opened through the plugin pane API. @@ -1094,8 +1092,6 @@ impl AppState { host_terminal_appearance_explicit: false, integration_recommendations: Vec::new(), agent_manifest_summaries: Vec::new(), - agent_manifest_update_status: - crate::detect::manifest_update::ManifestUpdateStatus::default(), installed_plugins: std::collections::HashMap::new(), plugin_panes: std::collections::HashMap::new(), popup_pane: None, diff --git a/src/cli.rs b/src/cli.rs index 154e0c99ba..b0e143a7b1 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -31,6 +31,7 @@ mod notification; mod pane; mod plugin; mod protocol_guard; +mod registry; mod runtime; mod server; mod server_not_running; @@ -129,6 +130,7 @@ pub fn maybe_run(args: &[String]) -> std::io::Result { "pane" => pane::run_pane_command(&args[2..])?, "plugin" => plugin::run_plugin_command(&args[2..])?, "integration" => integration::run_integration_command(&args[2..])?, + "registry" => registry::run_registry_command(&args[2..])?, "session" => run_session_command(&args[2..])?, _ => return Ok(CommandOutcome::NotCli), }; diff --git a/src/cli/agent.rs b/src/cli/agent.rs index 1af3e0d6ac..ca98c93afe 100644 --- a/src/cli/agent.rs +++ b/src/cli/agent.rs @@ -342,11 +342,6 @@ fn agent_start(args: &[String]) -> std::io::Result { eprintln!("missing required --pane"); return Ok(2); }; - let Some(expected_kind) = crate::detect::parse_agent_label(&kind) else { - eprintln!("unsupported interactive agent kind: {kind}"); - return Ok(2); - }; - let expected_kind = crate::detect::agent_label(expected_kind).to_string(); let agent_args = if separator < args.len() { args[separator + 1..].to_vec() } else { @@ -416,13 +411,16 @@ fn agent_start(args: &[String]) -> std::io::Result { { return super::print_response(&agent_name_lost_error("cli:agent:start", name)); } - let waited = wait_for_named_agent( - name, - &pane_id, - timeout, - &expected_kind, - expected_terminal_id, - ); + // The server owns kind admission and alias resolution, including registry + // identities that this client has never loaded. + let Some(expected_kind) = response["result"]["agent"]["agent"].as_str() else { + return super::print_response(&cli_agent_error( + "cli:agent:start", + "agent_start_failed", + "agent start response did not include canonical agent kind", + )); + }; + let waited = wait_for_named_agent(name, &pane_id, timeout, expected_kind, expected_terminal_id); match waited { Ok(Ok(agent)) => { response["result"]["agent"] = agent; @@ -942,7 +940,7 @@ fn print_agent_help() { " herdr agent explain --file PATH --agent LABEL [--json|--format text|json] [--verbose]" ); eprintln!(" targets accept unique agent names and pane ids that currently host agents"); - eprintln!(" kinds: {}", super::spec::agent_kind_values().join("|")); + eprintln!(" kinds and aliases are resolved by the server's active registry"); } fn parse_timeout(value: &str) -> Result { diff --git a/src/cli/integration.rs b/src/cli/integration.rs index d0a53f7b53..bc7efbb329 100644 --- a/src/cli/integration.rs +++ b/src/cli/integration.rs @@ -104,48 +104,50 @@ fn print_integration_messages(messages: Vec) { } } +fn integration_target_labels() -> Vec { + crate::agents::registry() + .integration_capable_profiles() + .map(|profile| { + profile + .integration() + .expect("integration-capable profile must contain metadata") + .cli_label() + .to_owned() + }) + .collect() +} + +fn print_integration_usage(action: &str) { + eprintln!( + "usage: herdr integration {action} <{}>", + integration_target_labels().join("|") + ); +} + fn parse_integration_target( args: &[String], action: &str, ) -> std::io::Result> { let Some(target) = args.first().map(|arg| arg.as_str()) else { - eprintln!( - "usage: herdr integration {action} " - ); + print_integration_usage(action); return Ok(None); }; if args.len() != 1 { - eprintln!( - "usage: herdr integration {action} " - ); + print_integration_usage(action); return Ok(None); } - let parsed = match target { - "pi" => IntegrationTarget::Pi, - "omp" => IntegrationTarget::Omp, - "claude" => IntegrationTarget::Claude, - "codex" => IntegrationTarget::Codex, - "copilot" => IntegrationTarget::Copilot, - "devin" => IntegrationTarget::Devin, - "droid" => IntegrationTarget::Droid, - "kimi" => IntegrationTarget::Kimi, - "opencode" => IntegrationTarget::Opencode, - "kilo" => IntegrationTarget::Kilo, - "hermes" => IntegrationTarget::Hermes, - "qodercli" => IntegrationTarget::Qodercli, - "qwen" => IntegrationTarget::Qwen, - "cursor" => IntegrationTarget::Cursor, - "mastracode" => IntegrationTarget::Mastracode, - "antigravity-cli" | "antigravity_cli" => IntegrationTarget::AntigravityCli, - "grok" => IntegrationTarget::Grok, - _ => { - eprintln!("unknown integration target: {target}"); - eprintln!( - "currently supported: pi, omp, claude, codex, copilot, devin, droid, kimi, opencode, kilo, hermes, qodercli, qwen, cursor, mastracode, antigravity-cli, grok" - ); - return Ok(None); - } + let Some(parsed) = crate::agents::registry() + .profile_by_integration_cli_name(target) + .and_then(|profile| profile.integration()) + .map(|integration| integration.target()) + else { + eprintln!("unknown integration target: {target}"); + eprintln!( + "currently supported: {}", + integration_target_labels().join(", ") + ); + return Ok(None); }; Ok(Some(parsed)) @@ -153,39 +155,40 @@ fn parse_integration_target( fn print_integration_help() { eprintln!("herdr integration commands:"); - eprintln!(" herdr integration install pi"); - eprintln!(" herdr integration install omp"); - eprintln!(" herdr integration install claude"); - eprintln!(" herdr integration install codex"); - eprintln!(" herdr integration install copilot"); - eprintln!(" herdr integration install devin"); - eprintln!(" herdr integration install droid"); - eprintln!(" herdr integration install kimi"); - eprintln!(" herdr integration install opencode"); - eprintln!(" herdr integration install kilo"); - eprintln!(" herdr integration install hermes"); - eprintln!(" herdr integration install qodercli"); - eprintln!(" herdr integration install qwen"); - eprintln!(" herdr integration install cursor"); - eprintln!(" herdr integration install mastracode"); - eprintln!(" herdr integration install antigravity-cli"); - eprintln!(" herdr integration install grok"); - eprintln!(" herdr integration uninstall pi"); - eprintln!(" herdr integration uninstall omp"); - eprintln!(" herdr integration uninstall claude"); - eprintln!(" herdr integration uninstall codex"); - eprintln!(" herdr integration uninstall copilot"); - eprintln!(" herdr integration uninstall devin"); - eprintln!(" herdr integration uninstall droid"); - eprintln!(" herdr integration uninstall kimi"); - eprintln!(" herdr integration uninstall opencode"); - eprintln!(" herdr integration uninstall kilo"); - eprintln!(" herdr integration uninstall hermes"); - eprintln!(" herdr integration uninstall qodercli"); - eprintln!(" herdr integration uninstall qwen"); - eprintln!(" herdr integration uninstall cursor"); - eprintln!(" herdr integration uninstall mastracode"); - eprintln!(" herdr integration uninstall antigravity-cli"); - eprintln!(" herdr integration uninstall grok"); + for action in ["install", "uninstall"] { + for label in integration_target_labels() { + eprintln!(" herdr integration {action} {label}"); + } + } eprintln!(" herdr integration status [--outdated-only]"); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn integration_cli_labels_and_aliases_route_through_registry() { + for profile in crate::agents::registry().integration_capable_profiles() { + let integration = profile.integration().expect("integration metadata"); + for name in std::iter::once(integration.cli_label()) + .chain(integration.cli_aliases().iter().map(String::as_str)) + { + assert_eq!( + parse_integration_target(&[name.to_string()], "install").unwrap(), + Some(integration.target()) + ); + } + } + } + + #[test] + fn integration_cli_parsing_remains_exact() { + for rejected in ["agy", "antigravity", "Antigravity-cli", " kilo "] { + assert_eq!( + parse_integration_target(&[rejected.to_string()], "install").unwrap(), + None + ); + } + } +} diff --git a/src/cli/registry.rs b/src/cli/registry.rs new file mode 100644 index 0000000000..7ab0f64760 --- /dev/null +++ b/src/cli/registry.rs @@ -0,0 +1,381 @@ +//! Offline validation and explicit selected-session registry control. +use std::io; +use std::path::Path; + +use crate::api::schema::{ + EmptyParams, Method, RegistryReloadParams, RegistryUpdateParams, Request, +}; + +use crate::agents::files::read_source; + +const USAGE: &str = "usage: herdr registry validate | validate-snapshot [--runtime-compatible] | status | reload [directory] | check [--channel stable|preview|staging] | update [--channel stable|preview|staging] | reset"; + +pub(super) fn run_registry_command(args: &[String]) -> io::Result { + match args { + [command, rest @ ..] if command == "check" || command == "update" => { + if matches!(rest, [help] if is_help(help)) { + print_help(); + return Ok(0); + } + let channel = match rest { + [] => crate::agents::remote::Channel::Stable, + [flag, value] if flag == "--channel" => match value.as_str() { + "stable" => crate::agents::remote::Channel::Stable, + "preview" => crate::agents::remote::Channel::Preview, + "staging" => crate::agents::remote::Channel::Staging, + _ => { + eprintln!("{USAGE}"); + return Ok(2); + } + }, + _ => { + eprintln!("{USAGE}"); + return Ok(2); + } + }; + let params = RegistryUpdateParams { channel }; + return send_registry_request(if command == "check" { + Method::RegistryCheck(params) + } else { + Method::RegistryUpdate(params) + }); + } + [command] if command == "reset" => { + return send_registry_request(Method::RegistryReset(EmptyParams::default())); + } + [command, rest @ ..] if command == "validate-snapshot" => { + if matches!(rest, [help] if is_help(help)) { + print_help(); + return Ok(0); + } + let (file, runtime_compatible) = match rest { + [file] if !file.starts_with('-') => (file, false), + [file, flag] if !file.starts_with('-') && flag == "--runtime-compatible" => { + (file, true) + } + _ => { + eprintln!("{USAGE}"); + return Ok(2); + } + }; + return match validate_snapshot(Path::new(file), runtime_compatible) { + Ok(digest) => { + println!("agent registry snapshot valid: {digest} (not activated)"); + Ok(0) + } + Err(error) => { + eprintln!("registry snapshot validation failed: {error}"); + Ok(1) + } + }; + } + [command] if command == "status" => { + return send_registry_request(Method::RegistryStatus(EmptyParams::default())); + } + [command, rest @ ..] if command == "reload" => { + if matches!(rest, [help] if is_help(help)) { + print_help(); + return Ok(0); + } + let directory = match rest { + [] => None, + [directory] if !directory.is_empty() && !directory.starts_with('-') => { + Some(directory) + } + [separator, directory] if separator == "--" && !directory.is_empty() => { + Some(directory) + } + _ => { + eprintln!("{USAGE}"); + return Ok(2); + } + }; + let source = match directory + .map(|path| caller_source_path(path.as_str())) + .transpose() + { + Ok(source) => source, + Err(error) => { + eprintln!("registry reload failed: {error}"); + return Ok(2); + } + }; + return send_registry_request(Method::RegistryReload(RegistryReloadParams { source })); + } + [command, help] if matches!(command.as_str(), "status" | "reset") && is_help(help) => { + print_help(); + return Ok(0); + } + _ => {} + } + let directory = match args { + [help] if is_help(help) => { + print_help(); + return Ok(0); + } + [command, help] if command == "validate" && is_help(help) => { + print_help(); + return Ok(0); + } + [command, directory] + if command == "validate" && !directory.is_empty() && !directory.starts_with('-') => + { + directory + } + [command, separator, directory] + if command == "validate" && separator == "--" && !directory.is_empty() => + { + directory + } + _ => { + eprintln!("{USAGE}"); + return Ok(2); + } + }; + match validate_directory(Path::new(directory)) { + Ok(count) => { + println!("agent registry: {count} package(s) valid (not activated)"); + Ok(0) + } + Err(error) => { + eprintln!("registry validation failed: {error}"); + Ok(1) + } + } +} + +fn send_registry_request(method: Method) -> io::Result { + super::print_response(&super::send_request(&Request { + id: "cli:registry".into(), + method, + })?) +} + +// Resolve once in the caller, without reading or writing the registry locally. +fn caller_source_path(source: &str) -> io::Result { + if source.contains("://") { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "registry source must be a local directory", + )); + } + let path = Path::new(source); + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir()?.join(path) + }; + let source = absolute.into_os_string().into_string().map_err(|_| { + io::Error::new(io::ErrorKind::InvalidInput, "registry source must be UTF-8") + })?; + RegistryReloadParams { + source: Some(source.clone()), + } + .source_path() + .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; + Ok(source) +} + +fn is_help(value: &str) -> bool { + matches!(value, "help" | "--help" | "-h") +} + +fn print_help() { + eprintln!("{USAGE}"); + eprintln!( + "Validate local agents/ packages offline; status/reload target the selected session." + ); + eprintln!( + "Reload is offline. Check validates R2 without activation; update activates it explicitly." + ); + eprintln!( + "Reset returns to bundled/managed mode without modifying local overrides or source files." + ); +} + +fn validate_snapshot(path: &Path, runtime_compatible: bool) -> Result { + let text = crate::agents::files::read_capped( + crate::agents::files::open_regular(path)?, + crate::agents::remote::MAX_SNAPSHOT_BYTES as u64, + )?; + let snapshot = crate::agents::remote::validate_snapshot(text.as_bytes())?; + if runtime_compatible { + let borrowed: Vec<_> = snapshot + .files + .iter() + .map(|(p, t)| (p.as_str(), t.as_str())) + .collect(); + let packages = crate::agents::validate_packages(&borrowed)?; + crate::agents::store::validate_integration_baseline(&packages)?; + } + Ok(snapshot.content_sha256) +} + +fn validate_directory(root: &Path) -> Result { + let files = read_source(root)?; + let borrowed: Vec<_> = files + .iter() + .map(|(path, text)| (path.as_str(), text.as_str())) + .collect(); + crate::agents::validate_packages(&borrowed).map(|packages| packages.len()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::PathBuf; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::time::{SystemTime, UNIX_EPOCH}; + + const AGENT: &str = "schema = 1\nid = 'future-agent'\nname = 'Future agent'\naliases = []\nstartable = true\n[launch]\nunix = 'future-agent'\nwindows = 'future-agent.cmd'\n"; + const DETECTION: &str = "id = 'future-agent'\nversion = '2026.01.01.1'\nmin_engine_version = 1\nupdated_at = '2026-01-01T00:00:00Z'\n[[rules]]\nid = 'idle'\nstate = 'idle'\ncontains = ['ready']\n"; + + struct Fixture(PathBuf); + + impl Fixture { + fn new() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + #[cfg(unix)] + let base = PathBuf::from("/var/tmp"); + #[cfg(not(unix))] + let base = std::env::temp_dir(); + let path = base.join(format!( + "herdr-registry-test-{}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(), + NEXT.fetch_add(1, Ordering::Relaxed), + )); + fs::create_dir(&path).unwrap(); + let fixture = Self(path); + fixture.put("agents/future-agent/agent.toml", AGENT.as_bytes()); + fixture + } + + fn put(&self, relative: &str, bytes: &[u8]) -> PathBuf { + let path = self.0.join(relative); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, bytes).unwrap(); + path + } + } + + impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + fn args(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_string()).collect() + } + + #[test] + fn registry_validate_routes_unknown_packages_without_activating_them() { + let fixture = Fixture::new(); + fixture.put("agents/future-agent/detection.toml", DETECTION.as_bytes()); + let config = fixture.put("config.toml", b"untouched invalid config"); + assert!(crate::agents::registry() + .profile_by_id("future-agent") + .is_none()); + let outcome = super::super::maybe_run(&args(&[ + "herdr", + "registry", + "validate", + fixture.0.to_str().unwrap(), + ])) + .unwrap(); + assert!(matches!(outcome, super::super::CommandOutcome::Handled(0))); + assert_eq!(fs::read(config).unwrap(), b"untouched invalid config"); + assert!(crate::agents::registry() + .profile_by_id("future-agent") + .is_none()); + } + + #[test] + fn registry_validate_rejects_invalid_package_and_actual_detection_regex() { + let fixture = Fixture::new(); + fixture.put("agents/future-agent/agent.toml", b"schema = 999"); + assert!(validate_directory(&fixture.0).is_err()); + fixture.put("agents/future-agent/agent.toml", AGENT.as_bytes()); + fixture.put( + "agents/future-agent/detection.toml", + DETECTION + .replace("contains = ['ready']", "regex = ['[']") + .as_bytes(), + ); + assert!(validate_directory(&fixture.0).is_err()); + assert_eq!( + run_registry_command(&args(&["validate", fixture.0.to_str().unwrap(),])).unwrap(), + 1 + ); + } + + #[test] + fn registry_validate_counts_packages_and_enforces_cross_package_identity_rules() { + let fixture = Fixture::new(); + let second = AGENT.replace("future-agent", "another-agent"); + fixture.put("agents/another-agent/agent.toml", second.as_bytes()); + assert_eq!(validate_directory(&fixture.0).unwrap(), 2); + fixture.put( + "agents/another-agent/agent.toml", + second + .replace("aliases = []", "aliases = ['future-agent']") + .as_bytes(), + ); + assert!(validate_directory(&fixture.0).is_err()); + } + + #[test] + fn registry_reload_resolves_source_in_caller_without_filesystem_access() { + let relative = "nonexistent-registry-source"; + let source = caller_source_path(relative).unwrap(); + assert_eq!( + Path::new(&source), + std::env::current_dir().unwrap().join(relative) + ); + assert_eq!(caller_source_path(&source).unwrap(), source); + assert!(caller_source_path("https://example.com/registry").is_err()); + } + + #[test] + fn registry_status_reload_help_and_argument_errors() { + for values in [&["status", "--help"][..], &["reload", "-h"]] { + assert_eq!(run_registry_command(&args(values)).unwrap(), 0); + } + for values in [ + &["status", "extra"][..], + &["reload", "one", "two"], + &["reload", "--unknown"], + &["reload", ""], + &["reload", "https://example.com/registry"], + ] { + assert_eq!(run_registry_command(&args(values)).unwrap(), 2); + } + } + + #[test] + fn registry_validate_help_and_argument_errors() { + for values in [&["help"][..], &["--help"], &["validate", "-h"]] { + assert_eq!(run_registry_command(&args(values)).unwrap(), 0); + } + for values in [ + &[][..], + &["validate"], + &["install", "."], + &["validate", "--unknown"], + &["validate", ".", "extra"], + &["validate", ""], + ] { + assert_eq!(run_registry_command(&args(values)).unwrap(), 2); + } + let fixture = Fixture::new(); + assert_eq!( + run_registry_command(&args(&["validate", "--", fixture.0.to_str().unwrap(),])).unwrap(), + 0 + ); + } +} diff --git a/src/cli/server.rs b/src/cli/server.rs index 2045f420ba..248a24dd84 100644 --- a/src/cli/server.rs +++ b/src/cli/server.rs @@ -99,15 +99,13 @@ fn server_update_agent_manifests(args: &[String]) -> std::io::Result { } }; - let response = match update_agent_manifest_status(super::send_request, || { - crate::detect::manifest_update::check_and_update().map(|_| ()) - })? { + let response = match update_agent_manifest_status(super::send_request)? { Ok(response) => response, Err(err) => { if json { return super::print_response(&agent_manifest_update_error_response(&err)); } - eprintln!("failed to update agent detection manifests: {err}"); + eprintln!("failed to update agent registry: {err}"); return Ok(1); } }; @@ -121,25 +119,37 @@ fn server_update_agent_manifests(args: &[String]) -> std::io::Result { fn update_agent_manifest_status( mut send_request: impl FnMut(&Request) -> std::io::Result, - update_manifests: impl FnOnce() -> Result<(), String>, ) -> std::io::Result> { - if let Err(err) = update_manifests() { - return Ok(Err(err)); - } - - let reload_response = send_request(&Request { - id: "cli:server:reload-agent-manifests".into(), - method: Method::ServerReloadAgentManifests(EmptyParams::default()), + let update_response = send_request(&Request { + id: "cli:server:update-agent-manifests".into(), + method: Method::RegistryUpdate(crate::api::schema::RegistryUpdateParams::default()), })?; - if reload_response.get("error").is_some() { - return Ok(Ok(reload_response)); + if let Some(error) = update_response.get("error") { + return Ok(Err(error["message"] + .as_str() + .unwrap_or("registry update failed") + .to_string())); } - send_request(&Request { + let mut response = send_request(&Request { id: "cli:server:agent-manifests".into(), method: Method::ServerAgentManifests(EmptyParams::default()), - }) - .map(Ok) + })?; + if let Some(result) = response + .get_mut("result") + .and_then(serde_json::Value::as_object_mut) + { + let registry = &update_response["result"]["registry"]; + result.insert( + "last_result".into(), + serde_json::json!(format!( + "registry update complete; generation {}, digest {}", + registry["generation"], + registry["digest"].as_str().unwrap_or("unavailable"), + )), + ); + } + Ok(Ok(response)) } fn agent_manifest_update_error_response(err: &str) -> serde_json::Value { @@ -154,12 +164,10 @@ fn agent_manifest_update_error_response(err: &str) -> serde_json::Value { fn print_agent_manifest_status(response: &serde_json::Value) { let result = &response["result"]; - let last_check = result["last_check_unix"] - .as_u64() - .map(|value| value.to_string()) - .unwrap_or_else(|| "never".to_string()); + if let Some(last_check) = result["last_check_unix"].as_u64() { + println!("last check: {last_check}"); + } let last_result = result["last_result"].as_str().unwrap_or("not checked"); - println!("last check: {last_check}"); println!("result: {last_result}"); println!(); @@ -263,7 +271,7 @@ fn print_server_help() { eprintln!(" herdr server live-handoff hand off live panes to a new local server"); eprintln!(" herdr server reload-config reload config.toml in the running server"); eprintln!(" herdr server agent-manifests [--json] show agent detection manifest status"); - eprintln!(" herdr server update-agent-manifests [--json] fetch and reload agent detection manifests"); + eprintln!(" herdr server update-agent-manifests [--json] compatibility alias for registry update (R2)"); eprintln!(" herdr server reload-agent-manifests reload agent detection manifests in the running server"); } @@ -272,80 +280,69 @@ mod tests { use super::*; #[test] - fn update_agent_manifest_status_fetches_reloads_then_reads_status() { + fn update_agent_manifest_status_updates_selected_server_then_reads_status() { let mut methods = Vec::new(); - let response = update_agent_manifest_status( - |request| { - methods.push(request.method.clone()); - match &request.method { - Method::ServerReloadAgentManifests(_) => Ok(serde_json::json!({ - "id": request.id, - "result": { "type": "agent_manifest_reload", "manifests": [] } - })), - Method::ServerAgentManifests(_) => Ok(serde_json::json!({ - "id": request.id, - "result": { - "type": "agent_manifest_status", - "last_result": "checked", - "manifests": [] - } - })), - _ => panic!("unexpected request"), - } - }, - || Ok(()), - ) + let response = update_agent_manifest_status(|request| { + methods.push(request.method.clone()); + match &request.method { + Method::RegistryUpdate(_) => Ok(serde_json::json!({ + "id": request.id, + "result": { "type": "agent_registry", "registry": { "generation": 7, "digest": "abc123" } } + })), + Method::ServerAgentManifests(_) => Ok(serde_json::json!({ + "id": request.id, + "result": { + "type": "agent_manifest_status", + "last_result": "checked", + "manifests": [] + } + })), + _ => panic!("unexpected request"), + } + }) .unwrap() .unwrap(); assert_eq!(response["result"]["type"], "agent_manifest_status"); + assert_eq!( + response["result"]["last_result"], + "registry update complete; generation 7, digest abc123" + ); + assert_eq!(response["result"]["manifests"], serde_json::json!([])); assert_eq!( methods, vec![ - Method::ServerReloadAgentManifests(EmptyParams::default()), + Method::RegistryUpdate(crate::api::schema::RegistryUpdateParams::default()), Method::ServerAgentManifests(EmptyParams::default()) ] ); } #[test] - fn update_agent_manifest_status_skips_server_when_fetch_fails() { - let response = update_agent_manifest_status( - |_request| panic!("server should not be called after fetch failure"), - || Err("network unavailable".to_string()), - ) - .unwrap(); - - assert_eq!(response, Err("network unavailable".to_string())); - assert_eq!( - agent_manifest_update_error_response("network unavailable")["error"]["code"], - "agent_manifest_update_failed" - ); - } - - #[test] - fn update_agent_manifest_status_stops_after_reload_error() { + fn update_agent_manifest_status_stops_after_remote_error() { let mut methods = Vec::new(); - let response = update_agent_manifest_status( - |request| { - methods.push(request.method.clone()); - Ok(serde_json::json!({ - "id": request.id, - "error": { - "code": "reload_failed", - "message": "reload failed" - } - })) - }, - || Ok(()), - ) - .unwrap() + let response = update_agent_manifest_status(|request| { + methods.push(request.method.clone()); + Ok(serde_json::json!({ + "id": request.id, + "error": { + "code": "reload_failed", + "message": "reload failed" + } + })) + }) .unwrap(); - assert_eq!(response["error"]["code"], "reload_failed"); + assert_eq!(response, Err("reload failed".into())); assert_eq!( methods, - vec![Method::ServerReloadAgentManifests(EmptyParams::default())] + vec![Method::RegistryUpdate( + crate::api::schema::RegistryUpdateParams::default() + )] + ); + assert_eq!( + agent_manifest_update_error_response("reload failed")["error"]["code"], + "agent_manifest_update_failed" ); } diff --git a/src/cli/spec.rs b/src/cli/spec.rs index 0d8a58ea05..38b6a0849c 100644 --- a/src/cli/spec.rs +++ b/src/cli/spec.rs @@ -46,6 +46,7 @@ pub(super) fn command() -> Command { .subcommand(terminal_command()) .subcommand(session_command()) .subcommand(integration_command()) + .subcommand(registry_command()) .subcommand(plugin_command()); configure_help(command, 0) } @@ -142,6 +143,54 @@ fn config_command() -> Command { .subcommand(Command::new("reset-keys").about("Reset custom keybindings")) } +fn registry_command() -> Command { + Command::new("registry") + .about("Validate local sources or control the selected session's agent registry") + .subcommand_required(true) + .subcommand( + Command::new("validate") + .about("Validate agents/ packages and detection rules offline") + .arg(required("directory", "DIRECTORY").value_hint(ValueHint::DirPath)), + ) + .subcommand( + Command::new("validate-snapshot") + .about("Validate immutable registry snapshot bytes offline") + .arg(required("file", "FILE").value_hint(ValueHint::FilePath)) + .arg( + Arg::new("runtime-compatible") + .long("runtime-compatible") + .action(ArgAction::SetTrue), + ), + ) + .subcommand(Command::new("status").about("Show the selected session's active registry")) + .subcommand( + Command::new("reset") + .about("Return to bundled registry and re-enable managed R2 updates"), + ) + .subcommands(["check", "update"].map(|name| { + Command::new(name) + .about(if name == "check" { + "Validate an R2 update without activation" + } else { + "Download and activate an R2 registry snapshot" + }) + .arg( + Arg::new("channel") + .long("channel") + .value_parser(["stable", "preview", "staging"]), + ) + })) + .subcommand( + Command::new("reload") + .about("Explicitly reload a local source in the selected session") + .arg( + Arg::new("directory") + .value_name("DIRECTORY") + .value_hint(ValueHint::DirPath), + ), + ) +} + fn channel_command() -> Command { Command::new("channel") .about("Manage stable and preview update channels") @@ -168,7 +217,7 @@ fn server_command() -> Command { ) .subcommand( Command::new("update-agent-manifests") - .about("Fetch and reload agent detection manifests") + .about("Compatibility alias for registry update (full R2 registry)") .arg(json_flag()), ) .subcommand( @@ -409,8 +458,7 @@ fn agent_command() -> Command { .arg( option("kind", "KIND") .required(true) - .value_parser(agent_kind_values()) - .help("Supported agent kind and canonical executable"), + .help("Agent kind or alias resolved by the server's active registry"), ) .arg( option("pane", "ID") @@ -448,13 +496,6 @@ fn agent_command() -> Command { ) } -pub(super) fn agent_kind_values() -> Vec<&'static str> { - crate::detect::Agent::ALL - .into_iter() - .map(crate::detect::agent_label) - .collect() -} - fn pane_command() -> Command { Command::new("pane") .about("Control terminal panes") @@ -891,14 +932,18 @@ fn integration_target_arg() -> Arg { Arg::new("target") .value_name("TARGET") .required(true) - .value_parser(integration_target_values()) -} - -fn integration_target_values() -> Vec<&'static str> { - crate::api::schema::IntegrationTarget::ALL - .into_iter() - .map(crate::integration::integration_target_label) - .collect() + .value_parser(|value: &str| -> Result { + let registry = crate::agents::registry(); + if registry + .profile_by_integration_cli_name(value) + .and_then(|profile| profile.integration()) + .is_some() + { + Ok(value.to_owned()) + } else { + Err(format!("unknown integration target: {value}")) + } + }) } fn id_command(name: &'static str, id: &'static str, about: &'static str) -> Command { @@ -1118,20 +1163,53 @@ mod tests { } #[test] - fn spec_matches_all_integration_targets() { + fn registry_validate_requires_one_local_directory_and_renders_help() { let cmd = super::command(); - let install = command_path(&cmd, &["integration", "install"]); + let validate = command_path(&cmd, &["registry", "validate"]); assert_eq!( - argument(install, "target") - .get_value_parser() - .possible_values() - .unwrap() - .map(|value| value.get_name().to_string()) - .collect::>(), - crate::api::schema::IntegrationTarget::ALL - .map(crate::integration::integration_target_label) - .map(str::to_string) + argument(validate, "directory").get_value_hint(), + clap::ValueHint::DirPath ); + for valid in [ + &["herdr", "registry", "validate", "/var/tmp/registry"][..], + &["herdr", "registry", "validate", "--", "-directory"][..], + ] { + assert!(super::command().try_get_matches_from(valid).is_ok()); + } + for invalid in [ + &["herdr", "registry"][..], + &["herdr", "registry", "validate"][..], + &["herdr", "registry", "validate", ".", "extra"][..], + &["herdr", "registry", "install", "."][..], + ] { + assert!(super::command().try_get_matches_from(invalid).is_err()); + } + assert!(long_help(&["registry", "validate"]).contains("offline")); + assert!(long_help(&["registry", "validate"]) + .contains("Usage: herdr registry validate ")); + } + + #[test] + fn spec_matches_all_integration_targets() { + let registry = crate::agents::registry(); + for profile in registry.integration_capable_profiles() { + let integration = profile.integration().expect("integration metadata"); + for name in std::iter::once(integration.cli_label()) + .chain(integration.cli_aliases().iter().map(String::as_str)) + { + assert!( + super::command() + .try_get_matches_from(["herdr", "integration", "install", name]) + .is_ok(), + "integration target {name}" + ); + } + } + for rejected in ["future-agent", "Pi", " pi "] { + assert!(super::command() + .try_get_matches_from(["herdr", "integration", "install", rejected]) + .is_err()); + } } #[test] @@ -1284,12 +1362,23 @@ mod tests { let cmd = super::command(); let agent_start = command_path(&cmd, &["agent", "start"]); assert!(has_option(agent_start, "kind")); - assert_eq!( - option_values(agent_start, "kind"), - crate::detect::Agent::ALL - .map(crate::detect::agent_label) - .map(str::to_string) - ); + assert!(option_values(agent_start, "kind").is_empty()); + for kind in ["future-agent", "cursor-agent", "Remote Alias"] { + let matches = super::command() + .try_get_matches_from([ + "herdr", "agent", "start", "worker", "--kind", kind, "--pane", "pane-1", + ]) + .expect("kind admission belongs to the server"); + let start = matches + .subcommand_matches("agent") + .unwrap() + .subcommand_matches("start") + .unwrap(); + assert_eq!( + start.get_one::("kind").map(String::as_str), + Some(kind) + ); + } assert!(has_option(agent_start, "pane")); for legacy in ["cwd", "workspace", "tab", "split", "focus", "env", "argv"] { assert!(!has_option(agent_start, legacy), "legacy option --{legacy}"); diff --git a/src/client/endpoint/control.rs b/src/client/endpoint/control.rs index 7cf1159f57..6925d574cc 100644 --- a/src/client/endpoint/control.rs +++ b/src/client/endpoint/control.rs @@ -2,6 +2,7 @@ use super::ClientEndpointId; pub(crate) enum EndpointControlMessage { HealthPong, + Notification(crate::protocol::endpoint::EndpointNotification), Snapshot(Box), Ignored, } @@ -13,6 +14,11 @@ pub(crate) fn decode_endpoint_control( if kind == crate::protocol::endpoint::HEALTH_PONG_KIND { return Ok(EndpointControlMessage::HealthPong); } + if kind == crate::protocol::endpoint::ENDPOINT_NOTIFICATION_KIND { + let notification = serde_json::from_str(data) + .map_err(|error| format!("invalid endpoint notification: {error}"))?; + return Ok(EndpointControlMessage::Notification(notification)); + } if kind == crate::protocol::endpoint::ENDPOINT_SNAPSHOT_KIND { let snapshot = serde_json::from_str(data) .map_err(|error| format!("invalid endpoint snapshot: {error}"))?; diff --git a/src/client/handshake.rs b/src/client/handshake.rs index 937f92ac89..78c6960e43 100644 --- a/src/client/handshake.rs +++ b/src/client/handshake.rs @@ -182,6 +182,7 @@ pub(super) fn do_handshake( endpoint_keybindings, mouse_capture, surface_active, + notification_sound_profile: true, snapshot_codecs: vec![SNAPSHOT_CODEC_V1.into()], surface_codecs: vec![SURFACE_CODEC_V1.into()], input_codecs: vec![INPUT_CODEC_V1.into()], diff --git a/src/client/mod.rs b/src/client/mod.rs index b815a057e7..55688c7768 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1825,6 +1825,32 @@ async fn run_client_loop( } let snapshot = match endpoint::decode_endpoint_control(&kind, &data) { Ok(endpoint::EndpointControlMessage::HealthPong) => continue, + Ok(endpoint::EndpointControlMessage::Notification(event)) => { + if let Some(shell) = state.shell.as_mut() { + let (effects, repaint) = shell + .receive_notification_with_sound_profile( + &endpoint_id, + event.notification, + shell::ClientNotificationSoundProfile::Resolved( + event.sound_profile, + ), + now, + ); + let frame = repaint + .then(|| { + shell.compose( + state.reported_size.0, + state.reported_size.1, + ) + }) + .flatten(); + handle_shell_notification_effects(effects, &state.sound_config); + if let Some(frame) = frame { + state.present_frame(frame); + } + } + continue; + } Ok(endpoint::EndpointControlMessage::Ignored) => { debug!(%kind, "ignoring unknown endpoint control message"); continue; diff --git a/src/client/notifications.rs b/src/client/notifications.rs index 7c97742e13..07d0c11472 100644 --- a/src/client/notifications.rs +++ b/src/client/notifications.rs @@ -12,9 +12,12 @@ pub(super) fn handle_shell_notification_effects( ) { for effect in effects { match effect { - shell::ClientShellNotificationEffect::Sound { sound, agent } => { - let agent = agent.as_deref().and_then(crate::detect::parse_agent_label); - if sound_config.allows(agent) { + shell::ClientShellNotificationEffect::Sound { + sound, + agent, + sound_profile, + } => { + if notification_sound_allowed(sound_config, agent.as_deref(), &sound_profile) { crate::sound::play(sound, sound_config); } } @@ -35,6 +38,22 @@ pub(super) fn handle_shell_notification_effects( } } +fn notification_sound_allowed( + config: &crate::config::SoundConfig, + agent: Option<&str>, + sound_profile: &shell::ClientNotificationSoundProfile, +) -> bool { + match sound_profile { + shell::ClientNotificationSoundProfile::LocalRegistry => { + config.allows(agent.and_then(|id| crate::detect::Agent::parse(id).ok())) + } + shell::ClientNotificationSoundProfile::Resolved(profile) => config.allows_resolved_sound( + profile.as_ref().map(|profile| profile.config_key.as_str()), + profile.as_ref().is_some_and(|profile| profile.default_off), + ), + } +} + pub(super) fn handle_notify( kind: NotifyKind, message: &str, @@ -100,3 +119,68 @@ pub(super) fn sound_from_notify_message(message: &str) -> Option None, } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::endpoint::NotificationSoundProfile; + use shell::ClientNotificationSoundProfile::{LocalRegistry, Resolved}; + + #[test] + fn resolved_sound_overrides_stale_local_registry_without_changing_legacy_fallback() { + let config = crate::config::SoundConfig::default(); + assert!(!notification_sound_allowed( + &config, + Some("droid"), + &LocalRegistry + )); + assert!(notification_sound_allowed( + &config, + Some("droid"), + &Resolved(None) + )); + let profile = |off| { + Resolved(Some(NotificationSoundProfile { + config_key: "new_remote_key".into(), + default_off: off, + })) + }; + assert!(!notification_sound_allowed( + &config, + Some("future-agent"), + &profile(true) + )); + assert!(notification_sound_allowed( + &config, + Some("future-agent"), + &profile(false) + )); + let muted = crate::config::SoundConfig { + enabled: false, + ..config + }; + assert!(!notification_sound_allowed( + &muted, + Some("future-agent"), + &profile(false) + )); + } + + #[test] + fn resolved_sound_respects_explicit_local_remote_keys() { + let config: crate::config::SoundConfig = + toml::from_str("[agents]\nremote_key = 'on'\nother_key = 'off'\n").unwrap(); + for (key, default_off, expected) in + [("remote_key", true, true), ("other_key", false, false)] + { + let profile = Resolved(Some(NotificationSoundProfile { + config_key: key.into(), + default_off, + })); + assert_eq!( + notification_sound_allowed(&config, Some("unknown-remote-agent"), &profile), + expected + ); + } + } +} diff --git a/src/client/shell/agent_sidebar.rs b/src/client/shell/agent_sidebar.rs index d6fe33529b..a3cab31cdd 100644 --- a/src/client/shell/agent_sidebar.rs +++ b/src/client/shell/agent_sidebar.rs @@ -282,7 +282,7 @@ pub(super) fn agent_rows( let canonical_agent = agent .agent .as_deref() - .and_then(crate::detect::parse_agent_label); + .and_then(|id| crate::detect::Agent::parse(id).ok()); let rows = crate::ui::sidebar_agent_rows( &config.agents, crate::ui::AgentTokenContext { diff --git a/src/client/shell/notification_policy.rs b/src/client/shell/notification_policy.rs index f3eeeb3c4f..90b4a4dfa7 100644 --- a/src/client/shell/notification_policy.rs +++ b/src/client/shell/notification_policy.rs @@ -101,6 +101,21 @@ impl ClientShellState { endpoint_id: &ClientEndpointId, event: SemanticNotification, now: std::time::Instant, + ) -> (Vec, bool) { + self.receive_notification_with_sound_profile( + endpoint_id, + event, + ClientNotificationSoundProfile::LocalRegistry, + now, + ) + } + + pub(crate) fn receive_notification_with_sound_profile( + &mut self, + endpoint_id: &ClientEndpointId, + event: SemanticNotification, + sound_profile: ClientNotificationSoundProfile, + now: std::time::Instant, ) -> (Vec, bool) { let delay = if event.kind == SemanticNotificationKind::Custom { 0 @@ -136,6 +151,7 @@ impl ClientShellState { self.pending_notifications.push(ClientPendingNotification { endpoint_id: endpoint_id.clone(), event, + sound_profile, deadline, expires_at: now.checked_add(COMPLETION_EVIDENCE_GRACE).unwrap_or(now), validate_state, @@ -204,6 +220,7 @@ impl ClientShellState { SemanticNotificationSound::Request => crate::sound::Sound::Request, }, agent: pending.event.agent.clone(), + sound_profile: pending.sound_profile.clone(), }); } } diff --git a/src/client/shell/notifications.rs b/src/client/shell/notifications.rs index b8437ea749..1be0ac8e45 100644 --- a/src/client/shell/notifications.rs +++ b/src/client/shell/notifications.rs @@ -227,6 +227,53 @@ mod tests { } } + #[test] + fn delayed_notifications_retain_each_endpoints_resolved_sound_metadata() { + let mut config = Config::default(); + config.ui.toast.delay_seconds = 1; + config.ui.toast.delivery = crate::config::ToastDelivery::Off; + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + let now = std::time::Instant::now(); + let endpoints = [ + ClientEndpointId::Local, + ClientEndpointId::Ssh( + crate::client::endpoint::ProfileId::parse("0123456789abcdef0123456789abcdef") + .unwrap(), + ), + ]; + let profiles = [ + ClientNotificationSoundProfile::Resolved(Some( + crate::protocol::endpoint::NotificationSoundProfile { + config_key: "remote_key".into(), + default_off: true, + }, + )), + ClientNotificationSoundProfile::Resolved(None), + ]; + for (endpoint, profile) in endpoints.iter().zip(&profiles) { + let mut event = notification().event; + event.kind = SemanticNotificationKind::NeedsAttention; + event.agent = Some("future-agent".into()); + event.sound = Some(SemanticNotificationSound::Request); + let (effects, _) = state.receive_notification_with_sound_profile( + endpoint, + event, + profile.clone(), + now, + ); + assert!(effects.is_empty()); + } + let (effects, _) = state.tick_notifications(now + std::time::Duration::from_secs(1)); + let retained = effects + .into_iter() + .filter_map(|effect| match effect { + ClientShellNotificationEffect::Sound { sound_profile, .. } => Some(sound_profile), + _ => None, + }) + .collect::>(); + assert_eq!(retained, profiles); + } + #[test] fn mobile_notification_is_a_bottom_banner_with_released_title() { let palette = crate::app::client_palette_from_config(&Config::default()); diff --git a/src/client/shell/state.rs b/src/client/shell/state.rs index e2e406a289..598d0712c5 100644 --- a/src/client/shell/state.rs +++ b/src/client/shell/state.rs @@ -753,10 +753,18 @@ pub(crate) struct ClientShellEndpointError { pub message: String, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ClientNotificationSoundProfile { + /// Only legacy servers require the client-local registry fallback. + LocalRegistry, + Resolved(Option), +} + pub(crate) enum ClientShellNotificationEffect { Sound { sound: crate::sound::Sound, agent: Option, + sound_profile: ClientNotificationSoundProfile, }, Terminal { title: String, @@ -771,6 +779,7 @@ pub(crate) enum ClientShellNotificationEffect { pub(super) struct ClientPendingNotification { pub(super) endpoint_id: ClientEndpointId, pub(super) event: SemanticNotification, + pub(super) sound_profile: ClientNotificationSoundProfile, pub(super) deadline: std::time::Instant, pub(super) expires_at: std::time::Instant, pub(super) validate_state: bool, diff --git a/src/client/shell/tests/agents_worktrees_notifications.rs b/src/client/shell/tests/agents_worktrees_notifications.rs index 8b4cc8bd37..0697d5bb5e 100644 --- a/src/client/shell/tests/agents_worktrees_notifications.rs +++ b/src/client/shell/tests/agents_worktrees_notifications.rs @@ -429,6 +429,40 @@ fn pane_cycle_last_and_agent_actions_resolve_to_stable_pane_ids() { )); } +#[test] +fn agent_sidebar_uses_canonical_overrides_without_local_registry_membership() { + let mut projected = snapshot(); + projected.agents = vec![ClientShellAgent { + pane_id: "pane_1".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + name: Some("remote worker".into()), + display_agent: None, + agent: Some("future-agent".into()), + title: None, + terminal_title: None, + terminal_title_stripped: None, + agent_status: AgentStatus::Done, + state_change_seq: 1, + state_labels: Vec::new(), + tokens: vec![("summary".into(), "remote-only".into())], + focused: true, + }]; + let config: Config = + toml::from_str("[ui.sidebar.agents.rows_by_agent]\nfuture-agent = [[\"$summary\"]]\n") + .expect("canonical remote identity is valid config"); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + let frame = state.compose(106, 30).expect("agent sidebar frame"); + let text = frame + .cells + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::(); + assert!(text.contains("remote-only"), "frame: {text}"); +} + #[test] fn agent_sidebar_honors_priority_symbols_tokens_and_stable_hits() { let mut projected = snapshot(); diff --git a/src/config/model.rs b/src/config/model.rs index 604fe9ad3e..f55a87f2b9 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -33,6 +33,7 @@ impl UpdateChannelConfig { pub struct UpdateConfig { pub channel: UpdateChannelConfig, pub version_check: bool, + // Retain the existing setting name for automatic full-registry updates. pub manifest_check: bool, } @@ -1058,10 +1059,7 @@ pub struct ExperimentalConfig { /// detected agent matches one of these names (case-insensitive). Empty /// list means apply to any focused pane. Unknown agent names are ignored; /// if the list contains no valid names, the reveal does not apply. - /// Accepted names: pi, claude, codex, gemini, cursor, devin, cline, - /// opencode, copilot, kimi, kiro, droid, amp, grok, hermes, kilo, - /// qodercli, qoder, qwen, qwen-code, maki. - /// Default: empty. + /// Accepted names are canonical agent IDs or registered aliases. Default: empty. pub cjk_ime_agents: Vec, /// Cursor shape rendered for the IME anchor when /// `reveal_hidden_cursor_for_cjk_ime` is enabled. Default: "steady_block". diff --git a/src/config/sidebar.rs b/src/config/sidebar.rs index 9a4cb5e0a0..7f69ce056a 100644 --- a/src/config/sidebar.rs +++ b/src/config/sidebar.rs @@ -408,12 +408,26 @@ where D: serde::Deserializer<'de>, { let rows_by_agent = BTreeMap::::deserialize(deserializer)?; + if rows_by_agent.is_empty() { + return Ok(rows_by_agent); + } + let registry = crate::agents::registry(); for (id, rows) in &rows_by_agent { - if crate::detect::parse_canonical_agent_label(id).is_none() { + if Agent::parse(id).is_err() { return Err(serde::de::Error::custom(format!( - "unknown canonical agent id `{id}` in sidebar rows_by_agent" + "invalid canonical agent id `{id}` in sidebar rows_by_agent" ))); } + // Unknown canonical IDs are valid for remote clients. An alias known + // to this snapshot, however, must not shadow its canonical identity. + if let Some(profile) = registry.profile_by_normalized_alias(id) { + if profile.canonical_id() != id { + return Err(serde::de::Error::custom(format!( + "agent alias `{id}` in sidebar rows_by_agent; use canonical id `{}`", + profile.canonical_id() + ))); + } + } validate_sidebar_rows(rows).map_err(serde::de::Error::custom)?; } Ok(rows_by_agent) @@ -432,7 +446,7 @@ pub struct AgentsSidebarConfig { impl AgentsSidebarConfig { pub(crate) fn rows_for_agent(&self, agent: Option) -> &AgentSidebarRows { agent - .and_then(|agent| self.rows_by_agent.get(crate::detect::agent_label(agent))) + .and_then(|agent| self.rows_by_agent.get(crate::detect::agent_label(&agent))) .unwrap_or(&self.rows) } } @@ -704,10 +718,15 @@ rows = [[{ token = "$status", rules = [{ contains = "error", bold = true }] }]] #[test] fn accepts_every_canonical_agent_override_key() { - let agents = Agent::ALL; + let agents = crate::agents::registry() + .known_profiles() + .map(|profile| profile.legacy_agent()) + .collect::>(); + assert_eq!(agents.len(), 23); + assert!(agents.contains(&Agent::Muse)); let entries = agents .iter() - .map(|agent| format!("{} = [[\"agent\"]]", crate::detect::agent_label(*agent))) + .map(|agent| format!("{} = [[\"agent\"]]", crate::detect::agent_label(agent))) .collect::>() .join("\n"); let input = format!("[ui.sidebar.agents.rows_by_agent]\n{entries}\n"); @@ -717,8 +736,41 @@ rows = [[{ token = "$status", rules = [{ contains = "error", bold = true }] }]] } #[test] - fn rejects_alias_case_whitespace_and_unknown_override_keys() { - for key in ["claude-code", "Claude", "' claude '", "unknown"] { + fn accepts_unregistered_canonical_override_keys() { + let config: crate::config::Config = + toml::from_str("[ui.sidebar.agents.rows_by_agent]\nfuture-agent = [[\"agent\"]]\n") + .unwrap(); + let agent = Agent::parse("future-agent").unwrap(); + assert_eq!( + config.ui.sidebar.agents.rows_for_agent(Some(agent)), + &config.ui.sidebar.agents.rows_by_agent["future-agent"], + ); + } + + #[test] + fn rejects_known_alias_override_keys_in_favor_of_canonical_ids() { + for (alias, canonical) in [ + ("claude-code", "claude"), + ("cursor-agent", "cursor"), + ("github-copilot", "copilot"), + ("antigravity", "agy"), + ] { + // All these aliases are syntactically valid IDs; only active + // registry resolution distinguishes them from novel IDs. + assert!(Agent::parse(alias).is_ok()); + let input = format!("[ui.sidebar.agents.rows_by_agent]\n{alias} = [[\"agent\"]]\n"); + let error = toml::from_str::(&input).unwrap_err(); + assert!(error + .to_string() + .contains(&format!("use canonical id `{canonical}`"))); + let input = format!("[ui.sidebar.agents.rows_by_agent]\n{canonical} = [[\"agent\"]]\n"); + assert!(toml::from_str::(&input).is_ok()); + } + } + + #[test] + fn rejects_invalid_canonical_override_keys() { + for key in ["Claude", "' claude '", "'a_b'", "'1agent'"] { let input = format!("[ui.sidebar.agents.rows_by_agent]\n{key} = [[\"agent\"]]\n"); assert!( toml::from_str::(&input).is_err(), diff --git a/src/config/sound.rs b/src/config/sound.rs index 2215c832bd..e67d858f43 100644 --- a/src/config/sound.rs +++ b/src/config/sound.rs @@ -1,8 +1,11 @@ -use std::path::PathBuf; +use std::{collections::BTreeMap, fmt, path::PathBuf}; -use serde::Deserialize; +use serde::{ + de::{IgnoredAny, MapAccess, Visitor}, + Deserialize, Deserializer, +}; -use crate::detect::Agent; +use crate::{agents::presentation::SoundDefaultPolicy, detect::Agent}; use super::io::resolve_config_relative_path; @@ -22,30 +25,9 @@ pub struct SoundConfig { pub agents: AgentSoundOverrides, } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] -#[serde(default)] +#[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct AgentSoundOverrides { - pub pi: AgentSoundSetting, - pub claude: AgentSoundSetting, - pub codex: AgentSoundSetting, - pub gemini: AgentSoundSetting, - pub cursor: AgentSoundSetting, - pub devin: AgentSoundSetting, - pub agy: AgentSoundSetting, - pub cline: AgentSoundSetting, - pub open_code: AgentSoundSetting, - pub github_copilot: AgentSoundSetting, - pub kimi: AgentSoundSetting, - pub kiro: AgentSoundSetting, - pub droid: AgentSoundSetting, - pub amp: AgentSoundSetting, - pub grok: AgentSoundSetting, - pub hermes: AgentSoundSetting, - pub kilo: AgentSoundSetting, - pub qodercli: AgentSoundSetting, - pub qwen: AgentSoundSetting, - pub maki: AgentSoundSetting, - pub muse: AgentSoundSetting, + overrides: BTreeMap, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)] @@ -57,6 +39,14 @@ pub enum AgentSoundSetting { Off, } +#[cfg(test)] +fn setting_for_policy(policy: SoundDefaultPolicy) -> AgentSoundSetting { + match policy { + SoundDefaultPolicy::Default => AgentSoundSetting::Default, + SoundDefaultPolicy::Off => AgentSoundSetting::Off, + } +} + impl SoundConfig { pub fn allows(&self, agent: Option) -> bool { if !self.enabled { @@ -66,6 +56,16 @@ impl SoundConfig { !matches!(self.agents.for_agent(agent), AgentSoundSetting::Off) } + /// Apply local user policy to package metadata resolved by the notifying + /// server. A missing key is authoritative, not a request for local lookup. + pub(crate) fn allows_resolved_sound(&self, key: Option<&str>, default_off: bool) -> bool { + self.enabled + && !matches!( + self.agents.for_resolved_sound(key, default_off), + AgentSoundSetting::Off + ) + } + pub fn path_for(&self, sound: crate::sound::Sound) -> Option { let path = match sound { crate::sound::Sound::Done => self.done_path.as_ref().or(self.path.as_ref()), @@ -119,33 +119,102 @@ impl SoundConfig { } impl AgentSoundOverrides { + fn for_resolved_sound(&self, key: Option<&str>, default_off: bool) -> AgentSoundSetting { + key.and_then(|key| self.overrides.get(key)) + .copied() + .unwrap_or({ + if default_off { + AgentSoundSetting::Off + } else { + AgentSoundSetting::Default + } + }) + } + pub fn for_agent(&self, agent: Option) -> AgentSoundSetting { - match agent { - Some(Agent::Pi) => self.pi, - Some(Agent::Claude) => self.claude, - Some(Agent::Codex) => self.codex, - Some(Agent::Gemini) => self.gemini, - Some(Agent::Cursor) => self.cursor, - Some(Agent::Devin) => self.devin, - Some(Agent::Antigravity) => self.agy, - Some(Agent::Cline) => self.cline, - Some(Agent::Omp) => AgentSoundSetting::Default, - Some(Agent::Mastracode) => AgentSoundSetting::Default, - Some(Agent::OpenCode) => self.open_code, - Some(Agent::GithubCopilot) => self.github_copilot, - Some(Agent::Kimi) => self.kimi, - Some(Agent::Kiro) => self.kiro, - Some(Agent::Droid) => self.droid, - Some(Agent::Amp) => self.amp, - Some(Agent::Grok) => self.grok, - Some(Agent::Hermes) => self.hermes, - Some(Agent::Kilo) => self.kilo, - Some(Agent::Qodercli) => self.qodercli, - Some(Agent::Qwen) => self.qwen, - Some(Agent::Maki) => self.maki, - Some(Agent::Muse) => self.muse, - None => AgentSoundSetting::Default, + let Some(agent) = agent else { + return AgentSoundSetting::Default; + }; + let registry = crate::agents::registry(); + let Some(sound) = registry + .profile_by_agent(agent) + .and_then(|profile| profile.sound()) + else { + return AgentSoundSetting::Default; + }; + + self.for_resolved_sound( + Some(sound.config_key()), + sound.default_policy() == SoundDefaultPolicy::Off, + ) + } +} + +impl<'de> Deserialize<'de> for AgentSoundOverrides { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct OverridesVisitor; + + impl<'de> Visitor<'de> for OverridesVisitor { + type Value = AgentSoundOverrides; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("an agent sound override table") + } + + fn visit_map(self, mut map: M) -> Result + where + M: MapAccess<'de>, + { + let mut result = AgentSoundOverrides::default(); + let registry = crate::agents::registry(); + + #[derive(Deserialize)] + #[serde(untagged)] + enum RawSetting { + Setting(AgentSoundSetting), + Other(IgnoredAny), + } + + while let Some(key) = map.next_key::()? { + // Same bounded, exact namespace as package sound keys. + let valid_key = !key.is_empty() + && key.len() <= 128 + && key.bytes().all(|byte| { + byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || b"-_".contains(&byte) + }) + && key.bytes().any(|byte| byte.is_ascii_lowercase()); + if !valid_key { + map.next_value::()?; + continue; + } + let setting = match map.next_value::()? { + RawSetting::Setting(setting) => setting, + RawSetting::Other(_) => { + if registry.sound_profile_by_config_key(&key).is_some() { + return Err(serde::de::Error::custom(format!( + "invalid sound setting for `{key}`; expected default, on, or off" + ))); + } + continue; + } + }; + // Preserve explicit choices even if they match today's package + // default; a later server notification can carry a new default. + if result.overrides.insert(key.clone(), setting).is_some() { + return Err(serde::de::Error::custom(format!("duplicate field `{key}`"))); + } + } + + Ok(result) + } } + + deserializer.deserialize_map(OverridesVisitor) } } @@ -161,34 +230,6 @@ impl Default for SoundConfig { } } -impl Default for AgentSoundOverrides { - fn default() -> Self { - Self { - pi: AgentSoundSetting::Default, - claude: AgentSoundSetting::Default, - codex: AgentSoundSetting::Default, - gemini: AgentSoundSetting::Default, - cursor: AgentSoundSetting::Default, - devin: AgentSoundSetting::Default, - agy: AgentSoundSetting::Default, - cline: AgentSoundSetting::Default, - open_code: AgentSoundSetting::Default, - github_copilot: AgentSoundSetting::Default, - kimi: AgentSoundSetting::Default, - kiro: AgentSoundSetting::Default, - droid: AgentSoundSetting::Off, - amp: AgentSoundSetting::Default, - grok: AgentSoundSetting::Default, - hermes: AgentSoundSetting::Default, - kilo: AgentSoundSetting::Default, - qodercli: AgentSoundSetting::Default, - qwen: AgentSoundSetting::Default, - maki: AgentSoundSetting::Default, - muse: AgentSoundSetting::Default, - } - } -} - #[cfg(test)] mod tests { use std::path::PathBuf; @@ -196,8 +237,154 @@ mod tests { use super::*; use crate::config::{config_path, Config}; + const EXPECTED_SOUND_PROFILES: [(Agent, Option<&str>, AgentSoundSetting); 23] = [ + (Agent::Pi, Some("pi"), AgentSoundSetting::Default), + (Agent::Claude, Some("claude"), AgentSoundSetting::Default), + (Agent::Codex, Some("codex"), AgentSoundSetting::Default), + (Agent::Gemini, Some("gemini"), AgentSoundSetting::Default), + (Agent::Cursor, Some("cursor"), AgentSoundSetting::Default), + (Agent::Devin, Some("devin"), AgentSoundSetting::Default), + (Agent::Antigravity, Some("agy"), AgentSoundSetting::Default), + (Agent::Cline, Some("cline"), AgentSoundSetting::Default), + (Agent::Omp, None, AgentSoundSetting::Default), + (Agent::Mastracode, None, AgentSoundSetting::Default), + ( + Agent::OpenCode, + Some("open_code"), + AgentSoundSetting::Default, + ), + ( + Agent::GithubCopilot, + Some("github_copilot"), + AgentSoundSetting::Default, + ), + (Agent::Kimi, Some("kimi"), AgentSoundSetting::Default), + (Agent::Kiro, Some("kiro"), AgentSoundSetting::Default), + (Agent::Droid, Some("droid"), AgentSoundSetting::Off), + (Agent::Amp, Some("amp"), AgentSoundSetting::Default), + (Agent::Grok, Some("grok"), AgentSoundSetting::Default), + (Agent::Hermes, Some("hermes"), AgentSoundSetting::Default), + (Agent::Kilo, Some("kilo"), AgentSoundSetting::Default), + ( + Agent::Qodercli, + Some("qodercli"), + AgentSoundSetting::Default, + ), + (Agent::Qwen, Some("qwen"), AgentSoundSetting::Default), + (Agent::Maki, Some("maki"), AgentSoundSetting::Default), + (Agent::Muse, Some("muse"), AgentSoundSetting::Default), + ]; + + fn config_with_all_sound_keys(setting: &str) -> Config { + let entries = EXPECTED_SOUND_PROFILES + .iter() + .filter_map(|(_, key, _)| key.map(|key| format!("{key} = \"{setting}\""))) + .collect::>() + .join("\n"); + toml::from_str(&format!("[ui.sound.agents]\n{entries}\n")).unwrap() + } + #[test] - fn sound_table_config_parses() { + fn registry_preserves_the_existing_sound_key_and_default_matrix() { + let registry = crate::agents::registry(); + assert_eq!(EXPECTED_SOUND_PROFILES.len(), 23); + + for (agent, key, default) in EXPECTED_SOUND_PROFILES { + let profile = registry.profile_for_agent(agent); + assert_eq!(profile.sound().map(|sound| sound.config_key()), key); + assert_eq!( + AgentSoundOverrides::default().for_agent(Some(agent)), + default + ); + + if let Some(key) = key { + let sound = registry + .sound_profile_by_config_key(key) + .expect("registered sound config key"); + assert!(profile + .sound() + .is_some_and(|registered| std::ptr::eq(registered, sound))); + assert_eq!(setting_for_policy(sound.default_policy()), default); + } + } + + assert_eq!( + registry + .known_profiles() + .filter(|profile| profile.sound().is_some()) + .count(), + 21 + ); + assert!(registry.sound_profile_by_config_key("unknown").is_none()); + assert_eq!( + AgentSoundOverrides::default().for_agent(None), + AgentSoundSetting::Default + ); + assert_eq!( + AgentSoundOverrides::default().for_agent(Some(Agent::parse("future-agent").unwrap())), + AgentSoundSetting::Default + ); + } + + #[test] + fn explicit_registry_defaults_remain_local_choices_after_package_changes() { + let config: Config = toml::from_str( + r#" +[ui.sound.agents] +claude = "default" +droid = "off" +"#, + ) + .unwrap(); + + assert!(config.ui.sound.allows_resolved_sound(Some("claude"), true)); + assert!(!config.ui.sound.allows_resolved_sound(Some("droid"), false)); + } + + #[test] + fn rejects_duplicate_known_key_even_when_the_value_matches_package_default() { + type ValueDeserializer<'a> = serde::de::value::StrDeserializer<'a, serde::de::value::Error>; + let entries = [ + ( + ValueDeserializer::new("claude"), + ValueDeserializer::new("default"), + ), + ( + ValueDeserializer::new("claude"), + ValueDeserializer::new("default"), + ), + ]; + let result: Result = + ::deserialize( + serde::de::value::MapDeserializer::new(entries.into_iter()), + ); + + assert!(result + .unwrap_err() + .to_string() + .contains("duplicate field `claude`")); + } + + #[test] + fn all_registered_sound_keys_parse_explicit_on_and_off_overrides() { + for (setting_name, expected) in [ + ("on", AgentSoundSetting::On), + ("off", AgentSoundSetting::Off), + ] { + let config = config_with_all_sound_keys(setting_name); + for (agent, key, _) in EXPECTED_SOUND_PROFILES { + let expected = if key.is_some() { + expected + } else { + AgentSoundSetting::Default + }; + assert_eq!(config.ui.sound.agents.for_agent(Some(agent)), expected); + } + } + } + + #[test] + fn sound_table_config_parses_without_exposing_typed_agent_fields() { let toml = r#" [ui.sound] enabled = true @@ -206,8 +393,8 @@ done_path = "sounds/done.mp3" request_path = "/tmp/request.mp3" [ui.sound.agents] -droid = "off" -claude = "on" +droid = "on" +claude = "off" "#; let config: Config = toml::from_str(toml).unwrap(); assert!(config.ui.sound.enabled); @@ -220,9 +407,81 @@ claude = "on" config.ui.sound.request_path, Some(PathBuf::from("/tmp/request.mp3")) ); - assert_eq!(config.ui.sound.agents.droid, AgentSoundSetting::Off); - assert_eq!(config.ui.sound.agents.claude, AgentSoundSetting::On); - assert_eq!(config.ui.sound.agents.pi, AgentSoundSetting::Default); + assert_eq!( + config.ui.sound.agents.for_agent(Some(Agent::Droid)), + AgentSoundSetting::On + ); + assert_eq!( + config.ui.sound.agents.for_agent(Some(Agent::Claude)), + AgentSoundSetting::Off + ); + assert_eq!( + config.ui.sound.agents.for_agent(Some(Agent::Pi)), + AgentSoundSetting::Default + ); + } + + #[test] + fn unknown_alias_case_and_whitespace_sound_keys_are_ignored() { + let config: Config = toml::from_str( + r#" +[ui.sound.agents] +pi = "on" +omp = "not-a-setting" +mastracode = "on" +opencode = "off" +copilot = "off" +antigravity = "off" +"claude-code" = "off" +Claude = "off" +" claude " = "off" +unknown = "not-a-setting" +"#, + ) + .expect("unknown sound keys remain ignored"); + + assert_eq!( + config.ui.sound.agents.for_agent(Some(Agent::Pi)), + AgentSoundSetting::On + ); + for agent in [ + Agent::Omp, + Agent::Mastracode, + Agent::OpenCode, + Agent::GithubCopilot, + Agent::Antigravity, + Agent::Claude, + ] { + assert_eq!( + config.ui.sound.agents.for_agent(Some(agent)), + AgentSoundSetting::Default + ); + } + assert_eq!( + config.ui.sound.agents.for_agent(Some(Agent::Droid)), + AgentSoundSetting::Off + ); + } + + #[test] + fn remote_sound_keys_are_exact_and_preserved_before_the_package_is_known() { + let config: SoundConfig = toml::from_str( + "[agents]\nfuture_key = 'off'\nfuture_on = 'on'\nfuture_default = 'default'\n", + ) + .unwrap(); + assert!(!config.allows_resolved_sound(Some("future_key"), false)); + assert!(config.allows_resolved_sound(Some("future_on"), true)); + assert!(config.allows_resolved_sound(Some("future_default"), true)); + assert!(config.allows_resolved_sound(Some("future-key"), false)); + assert!(config.allows_resolved_sound(None, false)); + assert!(!config.allows_resolved_sound(Some("unconfigured_key"), true)); + assert!(config.allows_resolved_sound(Some("unconfigured_key"), false)); + } + + #[test] + fn invalid_known_sound_settings_still_fail_validation() { + assert!(toml::from_str::("[agents]\nclaude = 'invalid'\n").is_err()); + assert!(toml::from_str::("[agents]\nunknown = 'invalid'\n").is_ok()); } #[test] diff --git a/src/detect/manifest.rs b/src/detect/manifest.rs index 4ef77d2a28..bc38f4e877 100644 --- a/src/detect/manifest.rs +++ b/src/detect/manifest.rs @@ -1,15 +1,9 @@ -use std::{ - path::{Path, PathBuf}, - sync::{Mutex, OnceLock, RwLock}, -}; +use std::path::{Path, PathBuf}; use regex::Regex; use serde::Deserialize; -use super::{ - agent_label, manifest_update::ManifestVersion, parse_agent_label, Agent, AgentDetection, - AgentState, -}; +use super::{agent_label, manifest_version::ManifestVersion, Agent, AgentDetection, AgentState}; pub const DEFAULT_KNOWN_AGENT_IDLE_FALLBACK: &str = "default_known_agent_idle_fallback"; @@ -49,7 +43,14 @@ pub struct DetectionExplain { #[derive(Debug, Clone, PartialEq, Eq)] pub enum ManifestSource { Bundled, - Remote { path: PathBuf, version: String }, + LocalRegistry(PathBuf), + R2 { + origin: String, + channel: String, + generation: u64, + snapshot_sha256: String, + commit: String, + }, Override(PathBuf), } @@ -57,7 +58,16 @@ impl ManifestSource { pub fn label(&self) -> String { match self { Self::Bundled => "bundled".to_string(), - Self::Remote { path, .. } => format!("remote:{}", path.display()), + Self::LocalRegistry(path) => format!("local-registry:{}", path.display()), + Self::R2 { + origin, + channel, + generation, + snapshot_sha256, + .. + } => { + format!("r2:{origin}/{channel}/{generation}/{snapshot_sha256}") + } Self::Override(path) => path.display().to_string(), } } @@ -65,7 +75,8 @@ impl ManifestSource { pub fn kind(&self) -> &'static str { match self { Self::Bundled => "bundled", - Self::Remote { .. } => "remote", + Self::LocalRegistry(_) => "local registry", + Self::R2 { .. } => "r2", Self::Override(_) => "local override", } } @@ -82,12 +93,8 @@ pub(crate) struct AgentManifestSummary { } pub(crate) fn manifest_summaries() -> Vec { - let lock = manifest_cache(); - let guard = match lock.read() { - Ok(guard) => guard, - Err(poisoned) => poisoned.into_inner(), - }; - manifest_summaries_from_cache(&guard) + let snapshot = crate::agents::registry(); + summaries(&snapshot.manifests) } #[derive(Debug, Clone, PartialEq, Eq)] @@ -127,11 +134,12 @@ struct LoadedManifest { source: ManifestSource, warning: Option, cached_remote_version: Option, + selected_package_version: Option, local_override_shadowing_remote: bool, } #[derive(Debug, Clone)] -struct ManifestCache { +pub(crate) struct ManifestCache { manifests: Vec<(Agent, Option)>, } @@ -236,33 +244,6 @@ fn default_region() -> String { "whole_recent".to_string() } -const BUNDLED_MANIFESTS: &[(&str, &str)] = &[ - ("amp", include_str!("manifests/amp.toml")), - ("agy", include_str!("manifests/antigravity.toml")), - ("claude", include_str!("manifests/claude.toml")), - ("cline", include_str!("manifests/cline.toml")), - ("codex", include_str!("manifests/codex.toml")), - ("cursor", include_str!("manifests/cursor.toml")), - ("devin", include_str!("manifests/devin.toml")), - ("droid", include_str!("manifests/droid.toml")), - ("gemini", include_str!("manifests/gemini.toml")), - ("grok", include_str!("manifests/grok.toml")), - ("hermes", include_str!("manifests/hermes.toml")), - ("kilo", include_str!("manifests/kilo.toml")), - ("kimi", include_str!("manifests/kimi.toml")), - ("kiro", include_str!("manifests/kiro.toml")), - ("maki", include_str!("manifests/maki.toml")), - ("muse", include_str!("manifests/muse.toml")), - ("opencode", include_str!("manifests/opencode.toml")), - ("pi", include_str!("manifests/pi.toml")), - ("qodercli", include_str!("manifests/qodercli.toml")), - ("qwen", include_str!("manifests/qwen.toml")), - ("copilot", include_str!("manifests/github-copilot.toml")), -]; - -static MANIFEST_CACHE: OnceLock> = OnceLock::new(); -static MANIFEST_RELOAD_LOCK: OnceLock> = OnceLock::new(); - const MAX_RULES_PER_MANIFEST: usize = 128; const MAX_GATE_DEPTH: usize = 8; const MAX_TOTAL_GATES: usize = 512; @@ -271,64 +252,47 @@ const MAX_TOTAL_MATCHERS: usize = 1024; const MAX_MATCHER_CHARS: usize = 512; pub(crate) fn reload_manifests() -> Vec { - let _reload_guard = MANIFEST_RELOAD_LOCK - .get_or_init(|| Mutex::new(())) - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let cache = build_manifest_cache(); - let summaries = manifest_summaries_from_cache(&cache); - let lock = MANIFEST_CACHE.get_or_init(|| RwLock::new(cache.clone())); - match lock.write() { - Ok(mut guard) => *guard = cache, - Err(poisoned) => *poisoned.into_inner() = cache, - } - summaries + crate::agents::store::refresh_detection(None) } +#[cfg(test)] pub(crate) fn reload_manifests_for_agents(agents: &[Agent]) { - if agents.is_empty() { - return; - } - - let _reload_guard = MANIFEST_RELOAD_LOCK - .get_or_init(|| Mutex::new(())) - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let lock = manifest_cache(); - let replacements = Agent::SCREEN_MANIFEST_AGENTS - .into_iter() - .filter(|agent| agents.contains(agent)) - .map(|agent| (agent, load_manifest_uncached(agent))) - .collect::>(); - let mut cache = match lock.write() { - Ok(guard) => guard, - Err(poisoned) => poisoned.into_inner(), - }; - for (agent, replacement) in replacements { - if let Some((_, loaded)) = cache - .manifests - .iter_mut() - .find(|(cached_agent, _)| *cached_agent == agent) - { - *loaded = replacement; - } + if !agents.is_empty() { + crate::agents::store::refresh_detection(Some(agents)); } } -fn manifest_cache() -> &'static RwLock { - MANIFEST_CACHE.get_or_init(|| RwLock::new(build_manifest_cache())) -} - -fn build_manifest_cache() -> ManifestCache { +/// Compile detection against the candidate metadata, before it is published. +/// Nothing in this loading path may reacquire the global registry. +pub(crate) fn build_manifest_cache(registry: &crate::agents::AgentRegistry) -> ManifestCache { ManifestCache { - manifests: Agent::SCREEN_MANIFEST_AGENTS - .into_iter() - .map(|agent| (agent, load_manifest_uncached(agent))) + manifests: registry + .screen_detectable_profiles() + .map(|profile| { + let agent = profile.legacy_agent(); + (agent, load_manifest_uncached(registry, agent)) + }) .collect(), } } -fn manifest_summaries_from_cache(cache: &ManifestCache) -> Vec { +/// Refresh only the requested agents, retaining every other loaded rule and +/// override exactly as it was in the previous publication. +pub(crate) fn build_manifest_cache_for_agents( + registry: &crate::agents::AgentRegistry, + previous: &ManifestCache, + agents: &[Agent], +) -> ManifestCache { + let mut cache = previous.clone(); + for (agent, loaded) in &mut cache.manifests { + if agents.contains(agent) { + *loaded = load_manifest_uncached(registry, *agent); + } + } + cache +} + +pub(crate) fn summaries(cache: &ManifestCache) -> Vec { cache .manifests .iter() @@ -364,12 +328,19 @@ pub fn detect(agent: Agent, screen_content: &str) -> AgentDetection { } pub fn detect_with_osc(agent: Agent, input: DetectionInput<'_>) -> AgentDetection { - let Some(loaded) = load_manifest(agent) else { - return fallback_explain(Some(agent), None, false).into_detection(); - }; - evaluate_loaded_manifest(agent, input, loaded, false).into_detection() + let snapshot = crate::agents::registry(); + detect_with_registry(&snapshot, agent, input) } +pub(crate) fn detect_with_registry( + snapshot: &crate::agents::RegistrySnapshot, + agent: Agent, + input: DetectionInput<'_>, +) -> AgentDetection { + explain_with_cache(&snapshot.manifests, agent, input, false).into_detection() +} + +#[cfg(test)] pub fn explain(agent: Agent, screen_content: &str) -> DetectionExplain { explain_with_input( agent, @@ -382,14 +353,33 @@ pub fn explain(agent: Agent, screen_content: &str) -> DetectionExplain { } pub fn explain_with_input(agent: Agent, input: DetectionInput<'_>) -> DetectionExplain { - let Some(loaded) = load_manifest(agent) else { - return fallback_explain(Some(agent), None, true); + let snapshot = crate::agents::registry(); + explain_with_registry(&snapshot, agent, input) +} + +pub(crate) fn explain_with_registry( + snapshot: &crate::agents::RegistrySnapshot, + agent: Agent, + input: DetectionInput<'_>, +) -> DetectionExplain { + explain_with_cache(&snapshot.manifests, agent, input, true) +} + +fn explain_with_cache( + cache: &ManifestCache, + agent: Agent, + input: DetectionInput<'_>, + include_update_status: bool, +) -> DetectionExplain { + let Some(loaded) = load_manifest(cache, agent) else { + return fallback_explain(Some(agent), None, include_update_status); }; - evaluate_loaded_manifest(agent, input, loaded, true) + evaluate_loaded_manifest(agent, input, loaded, include_update_status) } pub fn explain_for_label(agent_label: &str, screen_content: &str) -> DetectionExplain { - let Some(agent) = parse_agent_label(agent_label) else { + let snapshot = crate::agents::registry(); + let Some(agent) = parse_agent_label_with_registry(&snapshot, agent_label) else { return DetectionExplain { agent: Some(agent_label.to_string()), state: AgentState::Unknown, @@ -411,24 +401,15 @@ pub fn explain_for_label(agent_label: &str, screen_content: &str) -> DetectionEx remote_update_error: None, }; }; - explain(agent, screen_content) -} - -pub fn should_skip_state_update(agent: Agent, screen_content: &str) -> bool { - let Some(loaded) = load_manifest(agent) else { - return false; - }; - evaluate_loaded_manifest( + explain_with_registry( + &snapshot, agent, DetectionInput { screen: screen_content, osc_title: "", osc_progress: "", }, - loaded, - false, ) - .skip_state_update } impl DetectionExplain { @@ -437,6 +418,10 @@ impl DetectionExplain { state: self.state, skip_state_update: self.skip_state_update, visible_idle: self.visible_idle, + screen_visible_idle: self.visible_idle + && self.matched_rule.as_ref().is_some_and(|rule| { + !matches!(rule.region.trim(), "osc_title" | "osc_progress") + }), visible_blocker: self.visible_blocker, visible_working: self.visible_working, } @@ -494,11 +479,11 @@ fn evaluate_loaded_manifest( .then(|| format!("matched_rule:{}", rule.id)); let remote_update_status = include_update_status - .then(|| remote_update_status(agent)) + .then(|| super::manifest_compat::status_for_version(loaded.cached_remote_version.clone())) .flatten(); DetectionExplain { - agent: Some(agent_label(agent).to_string()), + agent: Some(agent_label(&agent).to_string()), state, source: Some(loaded.source), matched_rule: Some(MatchedRule { @@ -552,11 +537,11 @@ fn fallback_explain( .unwrap_or((None, Vec::new(), None, None, None, false)); let known_agent = agent.is_some(); let remote_update_status = include_update_status - .then(|| agent.and_then(remote_update_status)) + .then(|| super::manifest_compat::status_for_version(cached_remote_version.clone())) .flatten(); DetectionExplain { - agent: agent.map(|agent| agent_label(agent).to_string()), + agent: agent.map(|agent| agent_label(&agent).to_string()), state: if known_agent { AgentState::Idle } else { @@ -583,115 +568,105 @@ fn fallback_explain( } } -fn load_manifest(agent: Agent) -> Option { - let lock = manifest_cache(); - let guard = match lock.read() { - Ok(guard) => guard, - Err(poisoned) => poisoned.into_inner(), - }; - guard +fn load_manifest(cache: &ManifestCache, agent: Agent) -> Option { + cache .manifests .iter() .find(|(cached_agent, _)| *cached_agent == agent) .and_then(|(_, loaded)| loaded.clone()) } -fn load_manifest_uncached(agent: Agent) -> Option { - let bundled = bundled_manifest(agent)?; - let mut remote = read_remote_manifest(agent, &bundled); - let cached_remote_version = remote.as_ref().and_then(|loaded| match &loaded.source { - _ if loaded.cached_remote_version.is_some() => loaded.cached_remote_version.clone(), - ManifestSource::Remote { version, .. } => Some(version.clone()), - _ => None, - }); - let Some(path) = override_path(agent) else { - if let Some(loaded) = remote.as_mut() { - loaded.cached_remote_version = cached_remote_version.clone(); - } - return Some(remote.unwrap_or_else(|| { - bundled_loaded_manifest(agent, bundled, None, cached_remote_version, false) - })); - }; - let local_override_shadowing_remote = path.exists() && cached_remote_version.is_some(); - if let Some(loaded) = remote.as_mut() { - loaded.cached_remote_version = cached_remote_version.clone(); - loaded.local_override_shadowing_remote = local_override_shadowing_remote; - } - - if !path.exists() { - return Some(remote.unwrap_or_else(|| { - bundled_loaded_manifest( - agent, - bundled, - None, - cached_remote_version, - local_override_shadowing_remote, - ) - })); - } +/// Managed launches opt into strict input readiness only when the immutable +/// registry snapshot selected a visible-idle rule backed by terminal cells. +/// OSC-only readiness is not proof that the product input surface was painted. +pub(crate) fn requires_screen_visible_idle( + snapshot: &crate::agents::store::RegistrySnapshot, + agent: Agent, +) -> bool { + load_manifest(&snapshot.manifests, agent).is_some_and(|loaded| { + loaded.manifest.rules.iter().any(|rule| { + rule.visible_idle + && rule.state == Some(ManifestState::Idle) + && !matches!(rule.region.trim(), "osc_title" | "osc_progress") + }) + }) +} - match read_override_manifest(&path) { - Ok(manifest) if manifest_matches_agent(&manifest, agent) => { - match loaded_manifest( - manifest, - ManifestSource::Override(path.clone()), - None, - cached_remote_version.clone(), - local_override_shadowing_remote, - ) { - Ok(loaded) => Some(loaded), - Err(err) => { - let mut loaded = remote.unwrap_or_else(|| { - bundled_loaded_manifest( - agent, - bundled, - None, - cached_remote_version, - local_override_shadowing_remote, - ) - }); - loaded.warning = Some(format!( - "ignored override {} because it could not be compiled: {err}", - path.display() - )); - Some(loaded) - } - } - } - Ok(manifest) => { - let mut loaded = remote.unwrap_or_else(|| { - bundled_loaded_manifest( - agent, - bundled, - None, - cached_remote_version, - local_override_shadowing_remote, - ) - }); - loaded.warning = Some(format!( - "ignored override {} because manifest id {} does not match {}", - path.display(), +fn load_manifest_uncached( + registry: &crate::agents::AgentRegistry, + agent: Agent, +) -> Option { + // A profile and its detection package must exist in the selected registry + // before an override is considered: orphan overrides cannot resurrect agents. + let package = bundled_manifest(registry, agent)?; + let package_version = package.version.as_ref().map(ToString::to_string); + let mut selected = bundled_loaded_manifest(agent, package, None, None, false); + let Some(path) = override_path(agent).filter(|path| path.exists()) else { + return Some(selected); + }; + let result = read_override_manifest(&path).and_then(|manifest| { + if !manifest_matches_agent(registry, &manifest, agent) { + return Err(format!( + "manifest id {} does not match {}", manifest.id, - agent_label(agent) + agent_label(&agent) )); + } + loaded_manifest( + manifest, + ManifestSource::Override(path.clone()), + None, + None, + false, + ) + }); + match result { + Ok(mut loaded) => { + loaded.selected_package_version = package_version; Some(loaded) } Err(err) => { - let mut loaded = remote.unwrap_or_else(|| { - bundled_loaded_manifest( - agent, - bundled, - None, - cached_remote_version, - local_override_shadowing_remote, - ) - }); - loaded.warning = Some(format!( + selected.warning = Some(format!( "ignored override {} because it could not be loaded: {err}", path.display() )); - Some(loaded) + Some(selected) + } + } +} + +/// Attach provenance only after the candidate cache has been compiled. This is +/// deliberately independent of the global registry so startup cannot recurse. +/// Reapply after a partial or full detection refresh as well as source changes. +pub(crate) fn apply_registry_provenance( + cache: &mut ManifestCache, + source: Option<&Path>, + remote: Option<&crate::agents::remote::RemoteRevision>, +) { + let package_source = if let Some(path) = source { + ManifestSource::LocalRegistry(path.to_path_buf()) + } else if let Some(remote) = remote { + ManifestSource::R2 { + origin: remote.origin.clone(), + channel: remote.pointer.channel.as_str().to_string(), + generation: remote.pointer.generation, + snapshot_sha256: remote.pointer.snapshot_sha256.clone(), + commit: remote.commit.clone(), + } + } else { + ManifestSource::Bundled + }; + let is_r2 = matches!(package_source, ManifestSource::R2 { .. }); + for (_, loaded) in &mut cache.manifests { + let Some(loaded) = loaded else { continue }; + let is_override = matches!(loaded.source, ManifestSource::Override(_)); + if !is_override { + loaded.source = package_source.clone(); } + loaded.cached_remote_version = is_r2 + .then(|| loaded.selected_package_version.clone()) + .flatten(); + loaded.local_override_shadowing_remote = is_override && is_r2; } } @@ -704,6 +679,7 @@ fn loaded_manifest( ) -> Result { let compiled_rules = compile_manifest(&manifest)?; Ok(LoadedManifest { + selected_package_version: manifest.version.as_ref().map(ToString::to_string), manifest, compiled_rules, source, @@ -730,20 +706,22 @@ fn bundled_loaded_manifest( .unwrap_or_else(|err| { panic!( "bundled {} manifest could not be compiled: {err}", - agent_label(agent) + agent_label(&agent) ) }) } -fn bundled_manifest(agent: Agent) -> Option { - let id = agent_label(agent); - BUNDLED_MANIFESTS - .iter() - .find(|(manifest_id, _)| *manifest_id == id) - .map(|(_, content)| { - parse_manifest(content) - .unwrap_or_else(|err| panic!("bundled {id} manifest is invalid: {err}")) - }) +fn bundled_manifest( + registry: &crate::agents::AgentRegistry, + agent: Agent, +) -> Option { + let profile = registry.profile_by_agent(agent)?; + let content = profile.detection()?; + let id = profile.canonical_id(); + Some( + parse_manifest(content) + .unwrap_or_else(|err| panic!("bundled {id} manifest is invalid: {err}")), + ) } fn read_override_manifest(path: &Path) -> Result { @@ -751,74 +729,6 @@ fn read_override_manifest(path: &Path) -> Result { parse_manifest(&content) } -fn read_remote_manifest(agent: Agent, bundled: &AgentManifest) -> Option { - let path = super::manifest_update::remote_manifest_path(agent); - if !path.exists() { - return None; - } - match std::fs::read_to_string(&path) - .map_err(|err| err.to_string()) - .and_then(|content| { - parse_remote_manifest_for_agent(agent, &content).map(|parsed| parsed.manifest) - }) { - Ok(manifest) => { - let version = manifest - .version - .as_ref() - .map(ToString::to_string) - .unwrap_or_else(|| "unknown".to_string()); - if let (Some(remote_version), Some(bundled_version)) = - (manifest.version.as_ref(), bundled.version.as_ref()) - { - if remote_version < bundled_version { - return Some(bundled_loaded_manifest( - agent, - bundled.clone(), - Some(format!( - "ignored remote manifest {} because cached version {remote_version} is older than bundled {bundled_version}", - path.display() - )), - Some(remote_version.to_string()), - false, - )); - } - } - match loaded_manifest( - manifest, - ManifestSource::Remote { - path: path.clone(), - version, - }, - None, - None, - false, - ) { - Ok(loaded) => Some(loaded), - Err(err) => Some(bundled_loaded_manifest( - agent, - bundled.clone(), - Some(format!( - "ignored remote manifest {} because it could not be compiled: {err}", - path.display() - )), - None, - false, - )), - } - } - Err(err) => Some(bundled_loaded_manifest( - agent, - bundled.clone(), - Some(format!( - "ignored remote manifest {} because it could not be loaded: {err}", - path.display() - )), - None, - false, - )), - } -} - pub fn agent_state_label(state: AgentState) -> &'static str { match state { AgentState::Idle => "idle", @@ -865,6 +775,7 @@ pub fn explain_to_json_value(explain: &DetectionExplain) -> serde_json::Value { "agent": explain.agent, "state": agent_state_label(explain.state), "manifest_source": explain.source.as_ref().map(|source| source.label()), + "manifest_source_kind": explain.source.as_ref().map(|source| source.kind()), "manifest_version": &explain.manifest_version, "cached_remote_version": &explain.cached_remote_version, "local_override_shadowing_remote": explain.local_override_shadowing_remote, @@ -883,43 +794,32 @@ pub fn explain_to_json_value(explain: &DetectionExplain) -> serde_json::Value { }) } -pub(crate) struct ParsedRemoteManifest { - pub(crate) manifest: AgentManifest, - pub(crate) version: ManifestVersion, -} - pub(crate) fn parse_manifest(content: &str) -> Result { let manifest = toml::from_str::(content).map_err(|err| err.to_string())?; validate_manifest(&manifest)?; Ok(manifest) } -pub(crate) fn parse_remote_manifest_for_agent( - agent: Agent, +pub(crate) fn validate_package_manifest( content: &str, -) -> Result { + id: &str, + aliases: &[String], +) -> Result<(), String> { let manifest = parse_manifest(content)?; - if !manifest_matches_agent(&manifest, agent) { + if manifest.id != id && !aliases.contains(&manifest.id) { return Err(format!( - "manifest id {} does not match {}", - manifest.id, - agent_label(agent) + "detection manifest {} does not belong to {id}", + manifest.id )); } - let version = manifest - .version - .clone() - .ok_or("remote manifest must include version")?; - let min_engine_version = manifest + if manifest .min_engine_version - .ok_or("remote manifest must include min_engine_version")?; - if min_engine_version > super::manifest_update::MANIFEST_ENGINE_VERSION { - return Err(format!( - "manifest requires engine {min_engine_version}, current engine is {}", - super::manifest_update::MANIFEST_ENGINE_VERSION - )); + .is_some_and(|version| version > super::manifest_version::MANIFEST_ENGINE_VERSION) + { + return Err(format!("{id} detection requires a newer engine")); } - Ok(ParsedRemoteManifest { manifest, version }) + compile_manifest(&manifest)?; + Ok(()) } fn validate_manifest(manifest: &AgentManifest) -> Result<(), String> { @@ -1130,23 +1030,35 @@ fn override_path(agent: Agent) -> Option { Some( crate::config::config_dir() .join("agent-detection") - .join(format!("{}.toml", agent_label(agent))), + .join(format!("{}.toml", agent_label(&agent))), ) } -fn remote_update_status(agent: Agent) -> Option { - super::manifest_update::load_status().agent_status(agent) +fn parse_agent_label_with_registry( + registry: &crate::agents::AgentRegistry, + label: &str, +) -> Option { + let name = super::normalized_agent_lookup_name(label); + let name = super::path_basename(&name); + registry + .profile_by_normalized_alias(name) + .or_else(|| registry.profile_by_versioned_process_name(name)) + .map(|profile| profile.legacy_agent()) } -fn manifest_matches_agent(manifest: &AgentManifest, agent: Agent) -> bool { - let id = agent_label(agent); +fn manifest_matches_agent( + registry: &crate::agents::AgentRegistry, + manifest: &AgentManifest, + agent: Agent, +) -> bool { + let id = agent_label(&agent); manifest.id == id || manifest.aliases.iter().any(|alias| alias == id) - || parse_agent_label(&manifest.id) == Some(agent) + || parse_agent_label_with_registry(registry, &manifest.id) == Some(agent) || manifest .aliases .iter() - .any(|alias| parse_agent_label(alias) == Some(agent)) + .any(|alias| parse_agent_label_with_registry(registry, alias) == Some(agent)) } fn manifest_gate_from_rule(rule: &ManifestRule) -> ManifestGate { diff --git a/src/detect/manifest/tests.rs b/src/detect/manifest/tests.rs index e1073f3c6a..588937a2c9 100644 --- a/src/detect/manifest/tests.rs +++ b/src/detect/manifest/tests.rs @@ -1,6 +1,6 @@ use super::*; -fn remote_manifest(version: &str, state: &str, contains: &str) -> String { +fn versioned_manifest(version: &str, state: &str, contains: &str) -> String { format!( r#" id = "codex" @@ -67,19 +67,58 @@ fn with_manifest_dirs(name: &str, f: impl FnOnce() -> T) -> T { result } -fn write_remote_codex(content: &str) { - let path = crate::detect::manifest_update::remote_manifest_path(Agent::Codex); +fn historical_remote_path() -> PathBuf { + crate::config::state_dir().join("agent-detection/remote/codex.toml") +} + +fn write_historical_remote(content: &str) { + let path = historical_remote_path(); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(path, content).unwrap(); - reload_manifests(); } -fn write_remote_codex_without_reload(content: &str) { - let path = crate::detect::manifest_update::remote_manifest_path(Agent::Codex); +fn write_local_codex_without_reload(content: &str) { + let path = override_path(Agent::Codex).unwrap(); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(path, content).unwrap(); } +fn selected_codex_registry() -> crate::agents::AgentRegistry { + let detection = versioned_manifest("7.2.1", "blocked", "package-ready"); + let packages = crate::agents::source::load_packages(&[ + ("agents/codex/agent.toml", "schema = 1\nid = 'codex'\nname = 'Codex'\naliases = []\nstartable = true\n[launch]\nunix = 'codex'\nwindows = 'codex'\n"), + ("agents/codex/detection.toml", detection.as_str()), + ]).unwrap(); + crate::agents::AgentRegistry::from_packages(packages).unwrap() +} + +fn cache_explain(cache: &ManifestCache, screen: &str) -> DetectionExplain { + explain_with_cache( + cache, + Agent::Codex, + DetectionInput { + screen, + osc_title: "", + osc_progress: "", + }, + true, + ) +} + +fn test_remote_revision() -> crate::agents::remote::RemoteRevision { + crate::agents::remote::RemoteRevision { + origin: "https://registry.herdr.dev".into(), + pointer: crate::agents::remote::ChannelPointer { + schema: 1, + channel: crate::agents::remote::Channel::Stable, + generation: 9, + snapshot_sha256: "a".repeat(64), + snapshot_bytes: 100, + }, + commit: "b".repeat(40), + } +} + fn write_local_codex(content: &str) { let path = override_path(Agent::Codex).unwrap(); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); @@ -154,149 +193,221 @@ line_regex = ["^exact line$"] } #[test] -fn remote_manifest_loads_between_local_override_and_bundled() { - with_manifest_dirs("remote-source", || { - write_remote_codex(&remote_manifest("9999.01.01.1", "blocked", "remote-ready")); - - let explain = explain(Agent::Codex, "remote-ready"); - - assert_eq!(explain.state, AgentState::Blocked); - assert!(matches!( - explain.source, - Some(ManifestSource::Remote { .. }) - )); - assert_eq!(explain.manifest_version.as_deref(), Some("9999.01.01.1")); +fn historical_website_cache_and_status_are_ignored_and_preserved() { + with_manifest_dirs("historical-cache", || { + let historical = versioned_manifest("9999.1", "working", "historical-ready"); + write_historical_remote(&historical); + let status_path = crate::config::state_dir().join("agent-detection/status.toml"); + let historical_status = "last_check_unix = 123\nlast_result = 'historical-website'\n"; + std::fs::write(&status_path, historical_status).unwrap(); + reload_manifests(); + let explanation = explain(Agent::Codex, "historical-ready"); + assert_eq!(explanation.source, Some(ManifestSource::Bundled)); + assert_eq!(explanation.cached_remote_version, None); + assert_eq!(explanation.remote_update_status, None); + assert_eq!(explanation.warning, None); assert_eq!( - explain.cached_remote_version.as_deref(), - Some("9999.01.01.1") + std::fs::read_to_string(historical_remote_path()).unwrap(), + historical + ); + assert_eq!( + std::fs::read_to_string(&status_path).unwrap(), + historical_status + ); + let status = crate::detect::manifest_compat::load_status(); + assert_eq!(status.last_check_unix, None); + assert_eq!( + status.last_result.as_deref(), + Some("active registry snapshot") ); + assert!(status.agents.is_empty()); }); } #[test] -fn fallback_explain_preserves_active_manifest_version() { - with_manifest_dirs("fallback-version", || { - write_remote_codex(&remote_manifest("9999.01.01.1", "blocked", "remote-ready")); - - let explain = explain(Agent::Codex, "ordinary prompt text"); - - assert_eq!(explain.state, AgentState::Idle); +fn selected_package_provenance_and_versions_are_not_website_cache_or_snapshot_hashes() { + with_manifest_dirs("package-provenance", || { + write_historical_remote(&versioned_manifest("9999.1", "working", "package-ready")); + let registry = selected_codex_registry(); + let mut cache = build_manifest_cache(®istry); + apply_registry_provenance(&mut cache, Some(Path::new("/selected/agents")), None); + let local = cache_explain(&cache, "package-ready"); + assert_eq!(local.state, AgentState::Blocked); assert_eq!( - explain.fallback_reason.as_deref(), - Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK) + local.source, + Some(ManifestSource::LocalRegistry("/selected/agents".into())) ); - assert_eq!(explain.manifest_version.as_deref(), Some("9999.01.01.1")); + assert_eq!(local.cached_remote_version, None); + assert_eq!(local.manifest_version.as_deref(), Some("7.2.1")); + + let remote = test_remote_revision(); + apply_registry_provenance(&mut cache, None, Some(&remote)); + let accepted = cache_explain(&cache, "package-ready"); assert!(matches!( - explain.source, - Some(ManifestSource::Remote { .. }) + accepted.source, + Some(ManifestSource::R2 { generation: 9, .. }) )); - }); -} - -#[test] -fn older_cached_remote_manifest_does_not_shadow_newer_bundled_manifest() { - with_manifest_dirs("older-remote-bundled-fallback", || { - write_remote_codex(&remote_manifest("2026.06.10.0", "blocked", "remote-ready")); - - let explain = explain(Agent::Codex, "remote-ready"); - - assert_eq!(explain.state, AgentState::Idle); - assert!(matches!(explain.source, Some(ManifestSource::Bundled))); + assert_eq!(accepted.cached_remote_version.as_deref(), Some("7.2.1")); + assert_eq!( + accepted.remote_update_status.as_deref(), + Some("accepted_registry") + ); + let fallback = cache_explain(&cache, "no match"); + assert_eq!(fallback.manifest_version.as_deref(), Some("7.2.1")); + assert_eq!(fallback.cached_remote_version.as_deref(), Some("7.2.1")); + assert_eq!(fallback.remote_update_status, accepted.remote_update_status); + apply_registry_provenance(&mut cache, None, None); assert_eq!( - explain.cached_remote_version.as_deref(), - Some("2026.06.10.0") + cache_explain(&cache, "package-ready").source, + Some(ManifestSource::Bundled) + ); + assert_eq!( + cache_explain(&cache, "package-ready").cached_remote_version, + None ); - assert!(explain - .warning - .as_deref() - .is_some_and(|warning| warning.contains("older than bundled"))); }); } #[test] -fn local_override_shadows_cached_remote_manifest() { - with_manifest_dirs("local-shadows-remote", || { - write_remote_codex(&remote_manifest("9999.01.01.1", "blocked", "remote-ready")); - write_local_codex(&local_manifest("idle", "local-ready")); - - let explain = explain(Agent::Codex, "local-ready"); - - assert_eq!(explain.state, AgentState::Idle); - assert!(matches!(explain.source, Some(ManifestSource::Override(_)))); - assert!(explain.local_override_shadowing_remote); +fn local_override_is_preserved_over_selected_local_and_r2_packages() { + with_manifest_dirs("override-selected-package", || { + let content = versioned_manifest("8.1", "working", "local-ready"); + write_local_codex_without_reload(&content); + let registry = selected_codex_registry(); + let mut cache = build_manifest_cache(®istry); + apply_registry_provenance(&mut cache, Some(Path::new("/selected/agents")), None); + let local = cache_explain(&cache, "local-ready"); + assert_eq!(local.state, AgentState::Working); + assert!(matches!(local.source, Some(ManifestSource::Override(_)))); + assert!(!local.local_override_shadowing_remote); + apply_registry_provenance(&mut cache, None, Some(&test_remote_revision())); + let remote = cache_explain(&cache, "local-ready"); + assert_eq!(remote.manifest_version.as_deref(), Some("8.1")); + assert_eq!(remote.cached_remote_version.as_deref(), Some("7.2.1")); + assert!(remote.local_override_shadowing_remote); assert_eq!( - explain.cached_remote_version.as_deref(), - Some("9999.01.01.1") + std::fs::read_to_string(override_path(Agent::Codex).unwrap()).unwrap(), + content ); + // A source replacement lacking this profile cannot resurrect its override. + let empty = build_manifest_cache(&crate::agents::AgentRegistry::default()); + assert!(summaries(&empty).is_empty()); + assert_eq!(cache_explain(&empty, "local-ready").source, None); }); } #[test] -fn invalid_local_override_falls_back_to_cached_remote_manifest() { - with_manifest_dirs("invalid-local-remote-fallback", || { - write_remote_codex(&remote_manifest("9999.01.01.1", "blocked", "remote-ready")); - write_local_codex("id = "); - - let explain = explain(Agent::Codex, "remote-ready"); - - assert_eq!(explain.state, AgentState::Blocked); - assert!(matches!( - explain.source, - Some(ManifestSource::Remote { .. }) - )); - assert!(explain.warning.is_some()); +fn invalid_local_override_falls_back_to_selected_package_with_warning() { + with_manifest_dirs("invalid-override-selected", || { + let registry = selected_codex_registry(); + for content in [ + "id = ".to_string(), + local_manifest("working", "package-ready").replace("codex", "cursor"), + ] { + write_local_codex_without_reload(&content); + let mut cache = build_manifest_cache(®istry); + apply_registry_provenance(&mut cache, None, Some(&test_remote_revision())); + let explanation = cache_explain(&cache, "package-ready"); + assert_eq!(explanation.state, AgentState::Blocked); + assert!(matches!( + explanation.source, + Some(ManifestSource::R2 { .. }) + )); + assert!(explanation + .warning + .as_deref() + .unwrap() + .contains("ignored override")); + assert!(!explanation.local_override_shadowing_remote); + assert_eq!(explanation.cached_remote_version.as_deref(), Some("7.2.1")); + assert_eq!( + std::fs::read_to_string(override_path(Agent::Codex).unwrap()).unwrap(), + content + ); + } }); } #[test] -fn detection_uses_cached_manifest_until_explicit_reload() { +fn detection_uses_cached_local_override_until_explicit_reload() { with_manifest_dirs("cache-boundary", || { - write_remote_codex(&remote_manifest("9999.01.01.1", "blocked", "cached-ready")); - - let cached = explain(Agent::Codex, "cached-ready"); - assert_eq!(cached.state, AgentState::Blocked); - assert!(matches!(cached.source, Some(ManifestSource::Remote { .. }))); + write_local_codex(&versioned_manifest( + "9999.01.01.1", + "blocked", + "cached-ready", + )); assert_eq!( - cached.matched_rule.as_ref().map(|rule| rule.id.as_str()), - Some("test") + explain(Agent::Codex, "cached-ready").state, + AgentState::Blocked ); - - write_remote_codex_without_reload(&remote_manifest("9999.01.01.2", "working", "new-ready")); - + write_local_codex_without_reload(&versioned_manifest( + "9999.01.01.2", + "working", + "new-ready", + )); let unchanged = explain(Agent::Codex, "new-ready"); assert_eq!(unchanged.state, AgentState::Idle); - assert_eq!( - unchanged.fallback_reason.as_deref(), - Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK) - ); - assert_eq!( - unchanged.cached_remote_version.as_deref(), - Some("9999.01.01.1") - ); - + assert_eq!(unchanged.manifest_version.as_deref(), Some("9999.01.01.1")); reload_manifests(); - let reloaded = explain(Agent::Codex, "new-ready"); assert_eq!(reloaded.state, AgentState::Working); - assert_eq!( - reloaded.cached_remote_version.as_deref(), - Some("9999.01.01.2") - ); - assert_eq!( - reloaded.matched_rule.as_ref().map(|rule| rule.id.as_str()), - Some("test") - ); + assert_eq!(reloaded.manifest_version.as_deref(), Some("9999.01.01.2")); }); } #[test] -fn all_bundled_manifests_parse_and_validate() { - for agent in Agent::SCREEN_MANIFEST_AGENTS { - assert!( - bundled_manifest(agent).is_some(), - "missing bundled manifest for {}", - agent_label(agent) +fn source_tree_agent_directories_and_manifests_match_registry() { + let agents_root = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("vendor/agent-registry/agents"); + let mut source_directories = Vec::new(); + for entry in std::fs::read_dir(&agents_root).expect("agent source directory should be readable") + { + let entry = entry.expect("agent source entry should be readable"); + if entry + .file_type() + .expect("agent source entry type should be readable") + .is_dir() + { + source_directories.push( + entry + .file_name() + .into_string() + .expect("agent source directory names should be UTF-8"), + ); + } + } + source_directories.sort(); + + let registry = crate::agents::registry(); + let mut registered_directories: Vec<_> = registry + .known_profiles() + .map(|profile| profile.canonical_id().to_string()) + .collect(); + registered_directories.sort(); + assert_eq!(source_directories, registered_directories); + + for profile in registry.known_profiles() { + let manifest_path = agents_root + .join(profile.canonical_id()) + .join("detection.toml"); + let detection = profile.detection(); + assert_eq!( + manifest_path.is_file(), + detection.is_some(), + "detection file capability mismatch for {}", + profile.canonical_id() ); + + let Some(detection) = detection else { + continue; + }; + let content = std::fs::read_to_string(&manifest_path) + .expect("registered detection manifest should be readable"); + assert_eq!(content, detection); + + let parsed = parse_manifest(&content).expect("bundled manifest should parse"); + assert_eq!(parsed.id, profile.canonical_id()); + assert!(bundled_manifest(&crate::agents::registry(), profile.legacy_agent()).is_some()); } } @@ -1264,3 +1375,240 @@ fn codex_osc_working_beats_weak_blocker_screen() { Some("osc_title_working") ); } + +#[test] +fn retained_snapshot_keeps_compiled_detection_after_reload() { + with_manifest_dirs("retained-snapshot", || { + write_local_codex(&versioned_manifest( + "9999.01.01.1", + "blocked", + "snapshot-ready", + )); + let retained = crate::agents::registry(); + write_local_codex_without_reload(&versioned_manifest( + "9999.01.01.2", + "working", + "snapshot-ready", + )); + reload_manifests(); + let current = crate::agents::registry(); + let input = DetectionInput { + screen: "snapshot-ready", + osc_title: "", + osc_progress: "", + }; + + assert_eq!( + detect_with_registry(&retained, Agent::Codex, input).state, + AgentState::Blocked + ); + assert_eq!( + detect_with_registry(¤t, Agent::Codex, input).state, + AgentState::Working + ); + assert_eq!( + explain_with_registry(&retained, Agent::Codex, input) + .manifest_version + .as_deref(), + Some("9999.01.01.1") + ); + assert!(std::sync::Arc::ptr_eq( + &retained.registry, + ¤t.registry + )); + assert_eq!(retained.digest, current.digest); + }); +} + +#[test] +fn selective_reload_retains_unselected_local_override_exactly() { + with_manifest_dirs("selective-local-snapshot", || { + let path = override_path(Agent::Cursor).unwrap(); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write( + &path, + local_manifest("blocked", "retained-local").replace("codex", "cursor"), + ) + .unwrap(); + reload_manifests(); + let before = crate::agents::registry(); + let cursor_summary = summaries(&before.manifests) + .into_iter() + .find(|summary| summary.agent == Agent::Cursor) + .unwrap(); + + // Both files change, but only Codex is part of this publication. + std::fs::write( + &path, + local_manifest("working", "changed-local").replace("codex", "cursor"), + ) + .unwrap(); + write_local_codex_without_reload(&versioned_manifest( + "9999.01.01.2", + "working", + "fresh-remote", + )); + reload_manifests_for_agents(&[Agent::Codex]); + let after = crate::agents::registry(); + assert_eq!( + summaries(&after.manifests) + .into_iter() + .find(|summary| summary.agent == Agent::Cursor) + .unwrap(), + cursor_summary + ); + assert_eq!( + explain(Agent::Cursor, "retained-local").state, + AgentState::Blocked + ); + assert_eq!( + explain(Agent::Cursor, "changed-local").state, + AgentState::Idle + ); + assert_eq!( + explain(Agent::Codex, "fresh-remote").state, + AgentState::Working + ); + assert!(std::sync::Arc::ptr_eq(&before.registry, &after.registry)); + }); +} + +#[test] +fn candidate_alias_matching_does_not_use_published_registry() { + let manifest = + parse_manifest(&local_manifest("working", "ready").replace("codex", "claude-code")) + .unwrap(); + let published = crate::agents::registry(); + assert!(manifest_matches_agent(&published, &manifest, Agent::Claude)); + assert!(!manifest_matches_agent( + &crate::agents::AgentRegistry::default(), + &manifest, + Agent::Claude + )); +} + +#[test] +fn empty_candidate_detection_keeps_known_agent_idle_fallback() { + let registry = crate::agents::AgentRegistry::default(); + let manifests = build_manifest_cache(®istry); + assert!(summaries(&manifests).is_empty()); + let explanation = explain_with_cache( + &manifests, + Agent::Codex, + DetectionInput { + screen: "anything", + osc_title: "", + osc_progress: "", + }, + false, + ); + assert_eq!(explanation.state, AgentState::Idle); + assert_eq!(explanation.source, None); + assert_eq!( + explanation.fallback_reason.as_deref(), + Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK) + ); +} + +#[test] +fn strict_readiness_requires_a_screen_bound_visible_idle_rule() { + fn snapshot( + region: &str, + visible_idle: bool, + ) -> std::sync::Arc { + crate::agents::store::snapshot_for_test( + vec![ + ( + "agents/strict-test/agent.toml".into(), + "schema = 1\nid = 'strict-test'\nname = 'strict-test'\naliases = []\nstartable = true\n[launch]\nunix = 'strict-test'\nwindows = 'strict-test'\n".into(), + ), + ( + "agents/strict-test/process.toml".into(), + "names = ['strict-test']\n".into(), + ), + ( + "agents/strict-test/detection.toml".into(), + format!( + "id = 'strict-test'\nversion = '2026.06.10.1'\nmin_engine_version = 1\n[[rules]]\nid = 'idle'\nstate = 'idle'\npriority = 10\nregion = '{region}'\nvisible_idle = {visible_idle}\ncontains = ['ready']\n" + ), + ), + ], + 1, + ) + .unwrap() + } + + let agent = crate::detect::Agent::parse("strict-test").unwrap(); + assert!(super::requires_screen_visible_idle( + &snapshot("bottom_non_empty_lines(4)", true), + agent + )); + assert!(!super::requires_screen_visible_idle( + &snapshot("osc_title", true), + agent + )); + assert!(!super::requires_screen_visible_idle( + &snapshot("bottom_lines(4)", false), + agent + )); + for (region, screen_visible_idle) in [ + ("bottom_non_empty_lines(4)", true), + ("osc_title", false), + ("osc_progress", false), + ] { + let result = super::detect_with_registry( + &snapshot(region, true), + agent, + DetectionInput { + screen: "ready", + osc_title: "ready", + osc_progress: "ready", + }, + ); + assert_eq!(result.state, AgentState::Idle); + assert!(result.visible_idle, "OSC idle retains its status evidence"); + assert_eq!(result.screen_visible_idle, screen_visible_idle, "{region}"); + } +} + +#[test] +fn opencode_visible_idle_uses_structural_bottom_controls_at_wide_and_narrow_widths() { + for screen in [ + " ┃\n ┃ Ask anything...\n ┃\n ┃ Build · model\n ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀\n tab agents key commands\n", + " ┃\n ┃ Build · model name\n ┃ wrapped\n ╹▀▀▀▀▀▀▀▀▀▀▀▀\n tab agents\n other-key commands\n", + " prior conversation\n ┃\n ┃ Build · model\n ╹▀▀▀▀▀▀▀▀▀▀▀▀\n project 2 (0%) other-key\n commands\n", + ] { + let result = super::detect(Agent::OpenCode, screen); + assert_eq!(result.state, AgentState::Idle); + assert!(result.visible_idle); + assert!(!result.skip_state_update); + } +} + +#[test] +fn opencode_priority_keeps_palette_working_and_blocked_surfaces_out_of_idle() { + let palette_with_controls_behind = + "╹▀▀▀▀▀▀▀▀▀▀▀▀\ncommands\nCommands esc\nSearch\nSuggested\nSwitch model\n"; + let palette = super::detect(Agent::OpenCode, palette_with_controls_behind); + assert_eq!(palette.state, AgentState::Unknown); + assert!(palette.skip_state_update); + assert!(!palette.visible_idle); + + let working = super::detect(Agent::OpenCode, "┃\n╹▀▀▀▀▀▀▀▀▀▀▀▀\ncommands\n■■■■■■\n"); + assert_eq!(working.state, AgentState::Working); + assert!(working.visible_working); + assert!(!working.visible_idle); + + let blocked = super::detect( + Agent::OpenCode, + "┃\n╹▀▀▀▀▀▀▀▀▀▀▀▀\ncommands\n△ Permission required\n", + ); + assert_eq!(blocked.state, AgentState::Blocked); + assert!(blocked.visible_blocker); + assert!(!blocked.visible_idle); + + let blank = super::detect(Agent::OpenCode, ""); + assert_eq!(blank.state, AgentState::Unknown); + assert!(blank.skip_state_update); + assert!(!blank.visible_idle); +} diff --git a/src/detect/manifest_compat.rs b/src/detect/manifest_compat.rs new file mode 100644 index 0000000000..71aaf29279 --- /dev/null +++ b/src/detect/manifest_compat.rs @@ -0,0 +1,59 @@ +//! Offline status adapters for the legacy manifest API. +//! Historical website cache/status files are neither read nor deleted; +//! automatic and manual registry updates are server-owned. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use super::{agent_label, Agent}; + +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub(crate) struct ManifestUpdateStatus { + pub(crate) last_check_unix: Option, + pub(crate) last_result: Option, + #[serde(default)] + pub(crate) agents: BTreeMap, +} + +impl ManifestUpdateStatus { + pub(crate) fn agent_status(&self, agent: Agent) -> Option { + self.agents.get(agent_label(&agent)).cloned() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct AgentRemoteStatus { + pub(crate) cached_version: Option, + pub(crate) attempted_version: Option, + pub(crate) last_checked_unix: Option, + pub(crate) last_result: String, + pub(crate) last_error: Option, +} + +/// Derive the legacy status shape from the accepted immutable registry only. +/// Must not be called while constructing a registry/cache. +pub(crate) fn load_status() -> ManifestUpdateStatus { + let agents = super::manifest::manifest_summaries() + .into_iter() + .filter_map(|summary| { + status_for_version(summary.cached_remote_version) + .map(|status| (agent_label(&summary.agent).to_string(), status)) + }) + .collect(); + ManifestUpdateStatus { + last_check_unix: None, + last_result: Some("active registry snapshot".into()), + agents, + } +} + +pub(crate) fn status_for_version(version: Option) -> Option { + version.map(|version| AgentRemoteStatus { + cached_version: Some(version), + attempted_version: None, + last_checked_unix: None, + last_result: "accepted_registry".into(), + last_error: None, + }) +} diff --git a/src/detect/manifest_update.rs b/src/detect/manifest_update.rs deleted file mode 100644 index 33493059cc..0000000000 --- a/src/detect/manifest_update.rs +++ /dev/null @@ -1,1012 +0,0 @@ -use std::{ - cmp::Ordering, - collections::{BTreeMap, BTreeSet}, - fmt, fs, - io::{Read, Write}, - path::{Path, PathBuf}, - process::Stdio, - time::{SystemTime, UNIX_EPOCH}, -}; - -use serde::{Deserialize, Serialize}; - -use super::{agent_label, parse_agent_label, Agent}; - -pub(crate) const MANIFEST_ENGINE_VERSION: u32 = 3; -const DEFAULT_CATALOG_URL: &str = "https://herdr.dev/agent-detection/index.toml"; -const CATALOG_URL_ENV: &str = "HERDR_AGENT_DETECTION_MANIFEST_CATALOG_URL"; -const MAX_FETCH_BYTES: usize = 256 * 1024; - -#[derive(Debug, Clone)] -pub(crate) struct ManifestVersion(String); - -impl ManifestVersion { - pub(crate) fn parse(value: &str) -> Result { - let trimmed = value.trim(); - if trimmed.is_empty() { - return Err("version must not be empty".to_string()); - } - for segment in trimmed.split('.') { - if segment.is_empty() { - return Err(format!("version {trimmed:?} contains an empty segment")); - } - if !segment.chars().all(|ch| ch.is_ascii_digit()) { - return Err(format!("version {trimmed:?} must be dotted numeric")); - } - segment - .parse::() - .map_err(|_| format!("version {trimmed:?} contains an oversized segment"))?; - } - Ok(Self(trimmed.to_string())) - } -} - -impl fmt::Display for ManifestVersion { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.0) - } -} - -impl<'de> Deserialize<'de> for ManifestVersion { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let value = String::deserialize(deserializer)?; - Self::parse(&value).map_err(serde::de::Error::custom) - } -} - -impl Serialize for ManifestVersion { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - serializer.serialize_str(&self.0) - } -} - -impl Ord for ManifestVersion { - fn cmp(&self, other: &Self) -> Ordering { - let mut left = self.0.split('.'); - let mut right = other.0.split('.'); - - loop { - match (left.next(), right.next()) { - (Some(left), Some(right)) => { - let left = left.parse::().unwrap_or(0); - let right = right.parse::().unwrap_or(0); - match left.cmp(&right) { - Ordering::Equal => {} - ordering => return ordering, - } - } - (Some(left), None) => { - let left = left.parse::().unwrap_or(0); - if left == 0 { - continue; - } - return Ordering::Greater; - } - (None, Some(right)) => { - let right = right.parse::().unwrap_or(0); - if right == 0 { - continue; - } - return Ordering::Less; - } - (None, None) => return Ordering::Equal, - } - } - } -} - -impl PartialOrd for ManifestVersion { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl PartialEq for ManifestVersion { - fn eq(&self, other: &Self) -> bool { - self.cmp(other) == Ordering::Equal - } -} - -impl Eq for ManifestVersion {} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct ManifestUpdateCommit { - pub(crate) agent: Agent, - pub(crate) version: ManifestVersion, -} - -#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] -pub(crate) struct ManifestUpdateStatus { - pub(crate) last_check_unix: Option, - pub(crate) last_result: Option, - #[serde(default)] - pub(crate) agents: BTreeMap, -} - -impl ManifestUpdateStatus { - pub(crate) fn agent_status(&self, agent: Agent) -> Option { - self.agents.get(agent_label(agent)).cloned() - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub(crate) struct AgentRemoteStatus { - pub(crate) cached_version: Option, - pub(crate) attempted_version: Option, - pub(crate) last_checked_unix: Option, - pub(crate) last_result: String, - pub(crate) last_error: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct ManifestCatalog { - schema_version: u32, - #[serde(default)] - agents: Vec, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct ManifestCatalogAgent { - id: String, - path: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct CatalogAgent { - agent: Agent, - path: String, -} - -pub(crate) fn auto_update(events: tokio::sync::mpsc::Sender) { - let result = check_and_update(); - let status = match result { - Ok(output) => { - let activated = agents_needing_cache_reload(&output); - if !activated.is_empty() { - super::manifest::reload_manifests_for_agents(&activated); - } - let _ = events.blocking_send(crate::events::AppEvent::AgentDetectionManifestsUpdated { - updated: output.updated, - activated, - status: output.status, - }); - return; - } - Err(err) => { - tracing::warn!("agent detection manifest update failed: {err}"); - let mut status = load_status(); - status.last_check_unix = Some(now_unix()); - status.last_result = Some(format!("failed: {err}")); - let _ = save_status(&status); - status - } - }; - let _ = events.blocking_send(crate::events::AppEvent::AgentDetectionManifestsUpdated { - updated: Vec::new(), - activated: Vec::new(), - status, - }); -} - -fn agents_needing_cache_reload(output: &ManifestUpdateOutput) -> Vec { - let loaded = super::manifest::manifest_summaries(); - let mut activated = output - .updated - .iter() - .map(|commit| commit.agent) - .collect::>(); - - for agent in &output.checked { - let agent = *agent; - let Some(status) = output.status.agent_status(agent) else { - continue; - }; - if status.last_result != "current" && status.last_result != "updated" { - continue; - } - let loaded_version = loaded - .iter() - .find(|summary| summary.agent == agent) - .and_then(|summary| summary.cached_remote_version.as_deref()); - let disk_version = cached_remote_version(agent).map(|version| version.to_string()); - if loaded_version != disk_version.as_deref() && !activated.contains(&agent) { - activated.push(agent); - } - } - - activated -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct ManifestUpdateOutput { - pub(crate) checked: Vec, - pub(crate) updated: Vec, - pub(crate) status: ManifestUpdateStatus, -} - -pub(crate) fn check_and_update() -> Result { - check_and_update_from_url(&catalog_url()) -} - -fn check_and_update_from_url(url: &str) -> Result { - let catalog = parse_catalog(&fetch_text(url)?)?; - let base_url = base_url(url)?; - let mut status = load_status(); - let check_time = now_unix(); - status.last_check_unix = Some(check_time); - status.last_result = Some("checked".to_string()); - - let checked = catalog.iter().map(|entry| entry.agent).collect::>(); - let mut updated = Vec::new(); - for entry in catalog { - let agent_id = agent_label(entry.agent).to_string(); - let manifest_url = join_url(&base_url, &entry.path)?; - match fetch_text(&manifest_url) - .map_err(|err| format!("fetch failed: {err}")) - .and_then(|content| process_agent_manifest(entry.agent, &content, check_time)) - { - Ok(Some(commit)) => { - status.agents.insert( - agent_id, - AgentRemoteStatus { - cached_version: Some(commit.version.to_string()), - attempted_version: Some(commit.version.to_string()), - last_checked_unix: Some(check_time), - last_result: "updated".to_string(), - last_error: None, - }, - ); - updated.push(commit); - } - Ok(None) => { - let cached_version = cached_remote_version(entry.agent); - status.agents.insert( - agent_id, - AgentRemoteStatus { - cached_version: cached_version.map(|version| version.to_string()), - attempted_version: None, - last_checked_unix: Some(check_time), - last_result: "current".to_string(), - last_error: None, - }, - ); - } - Err(err) => { - tracing::warn!( - agent = agent_label(entry.agent), - error = %err, - "agent detection manifest update failed for agent" - ); - let cached_version = cached_remote_version(entry.agent); - status.agents.insert( - agent_id, - AgentRemoteStatus { - cached_version: cached_version.map(|version| version.to_string()), - attempted_version: None, - last_checked_unix: Some(check_time), - last_result: "failed".to_string(), - last_error: Some(err), - }, - ); - } - } - } - - if let Err(err) = save_status(&status) { - tracing::warn!("failed to save agent detection manifest update status: {err}"); - status.last_result = Some(format!("failed_to_save_status: {err}")); - } - Ok(ManifestUpdateOutput { - checked, - updated, - status, - }) -} - -fn process_agent_manifest( - agent: Agent, - content: &str, - _check_time: u64, -) -> Result, String> { - let parsed = super::manifest::parse_remote_manifest_for_agent(agent, content)?; - if let Some(current) = cached_remote_version(agent) { - match parsed.version.cmp(¤t) { - Ordering::Less => { - return Err(format!( - "remote version {} is older than cached {current}", - parsed.version - )) - } - Ordering::Equal => { - let committed = fs::read_to_string(remote_manifest_path(agent)).unwrap_or_default(); - if committed != content { - return Err(format!( - "remote version {} changed content without a version bump", - parsed.version - )); - } - return Ok(None); - } - Ordering::Greater => {} - } - } - - commit_remote_manifest(agent, content)?; - Ok(Some(ManifestUpdateCommit { - agent, - version: parsed.version, - })) -} - -fn parse_catalog(content: &str) -> Result, String> { - let catalog: ManifestCatalog = - toml::from_str(content).map_err(|err| format!("failed to parse catalog TOML: {err}"))?; - if catalog.schema_version != 1 { - return Err(format!( - "unsupported catalog schema_version {}", - catalog.schema_version - )); - } - - let mut seen = BTreeSet::new(); - let mut agents = Vec::new(); - for entry in catalog.agents { - let Some(agent) = parse_agent_label(&entry.id) else { - tracing::warn!(agent = entry.id, "skipping unknown remote manifest agent"); - continue; - }; - if entry.path.trim().is_empty() { - return Err(format!("catalog entry {} has an empty path", entry.id)); - } - if entry.path.contains("://") - || entry.path.starts_with('/') - || entry.path.split('/').any(|part| part == "..") - { - return Err(format!( - "catalog entry {} has an unsafe path {}", - entry.id, entry.path - )); - } - if !seen.insert(agent_label(agent).to_string()) { - return Err(format!("catalog contains duplicate agent {}", entry.id)); - } - agents.push(CatalogAgent { - agent, - path: entry.path, - }); - } - Ok(agents) -} - -pub(crate) fn load_status() -> ManifestUpdateStatus { - let path = status_path(); - let Ok(content) = fs::read_to_string(&path) else { - return ManifestUpdateStatus::default(); - }; - toml::from_str(&content).unwrap_or_else(|err| { - tracing::warn!( - path = %path.display(), - "failed to parse agent detection manifest status: {err}" - ); - ManifestUpdateStatus::default() - }) -} - -fn save_status(status: &ManifestUpdateStatus) -> Result<(), String> { - let path = status_path(); - let parent = path - .parent() - .ok_or_else(|| format!("status path {} has no parent", path.display()))?; - fs::create_dir_all(parent).map_err(|err| err.to_string())?; - let content = toml::to_string_pretty(status).map_err(|err| err.to_string())?; - atomic_write(&path, content.as_bytes()) -} - -pub(crate) fn status_path() -> PathBuf { - state_root().join("status.toml") -} - -pub(crate) fn remote_manifest_path(agent: Agent) -> PathBuf { - state_root() - .join("remote") - .join(format!("{}.toml", agent_label(agent))) -} - -pub(crate) fn cached_remote_version(agent: Agent) -> Option { - let content = fs::read_to_string(remote_manifest_path(agent)).ok()?; - super::manifest::parse_remote_manifest_for_agent(agent, &content) - .ok() - .map(|parsed| parsed.version) -} - -fn commit_remote_manifest(agent: Agent, content: &str) -> Result<(), String> { - let path = remote_manifest_path(agent); - let parent = path - .parent() - .ok_or_else(|| format!("remote manifest path {} has no parent", path.display()))?; - fs::create_dir_all(parent).map_err(|err| err.to_string())?; - atomic_write(&path, content.as_bytes()) -} - -fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), String> { - let parent = path - .parent() - .ok_or_else(|| format!("path {} has no parent", path.display()))?; - fs::create_dir_all(parent).map_err(|err| err.to_string())?; - let tmp_path = parent.join(format!( - ".{}.{}.{}.tmp", - path.file_name() - .and_then(|name| name.to_str()) - .unwrap_or("manifest"), - std::process::id(), - now_nanos() - )); - { - let mut file = fs::File::create(&tmp_path).map_err(|err| err.to_string())?; - if let Err(err) = file.write_all(bytes).and_then(|_| file.sync_all()) { - let _ = fs::remove_file(&tmp_path); - return Err(err.to_string()); - } - } - fs::rename(&tmp_path, path).map_err(|err| { - let _ = fs::remove_file(&tmp_path); - err.to_string() - })?; - if let Err(err) = sync_parent_dir(parent) { - tracing::warn!( - path = %path.display(), - error = %err, - "agent detection manifest committed but parent directory sync failed" - ); - } - Ok(()) -} - -fn sync_parent_dir(parent: &Path) -> Result<(), String> { - let dir = match fs::File::open(parent) { - Ok(dir) => dir, - Err(err) if directory_sync_unsupported(&err) => return Ok(()), - Err(err) => return Err(format!("failed to open parent directory for sync: {err}")), - }; - match dir.sync_all() { - Ok(()) => Ok(()), - Err(err) if directory_sync_unsupported(&err) => Ok(()), - Err(err) => Err(format!("failed to sync parent directory: {err}")), - } -} - -fn directory_sync_unsupported(err: &std::io::Error) -> bool { - // PermissionDenied: Windows cannot open directories via `File::open` - // (os error 5), so directory sync is effectively unsupported there. - matches!( - err.kind(), - std::io::ErrorKind::Unsupported - | std::io::ErrorKind::InvalidInput - | std::io::ErrorKind::PermissionDenied - ) -} - -fn state_root() -> PathBuf { - crate::config::state_dir().join("agent-detection") -} - -fn catalog_url() -> String { - std::env::var(CATALOG_URL_ENV) - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - .unwrap_or_else(|| DEFAULT_CATALOG_URL.to_string()) -} - -fn fetch_text(url: &str) -> Result { - let max_fetch_bytes = MAX_FETCH_BYTES.to_string(); - let mut child = crate::noninteractive_process::curl_command() - .args([ - "-sfL", - "--retry", - "2", - "--connect-timeout", - "5", - "--max-time", - "15", - "--max-filesize", - &max_fetch_bytes, - url, - ]) - .stdout(Stdio::piped()) - .spawn() - .map_err(|err| format!("curl failed: {err}"))?; - - let mut bytes = Vec::new(); - let Some(stdout) = child.stdout.as_mut() else { - let _ = child.kill(); - let _ = child.wait(); - return Err("curl stdout was not captured".to_string()); - }; - stdout - .take((MAX_FETCH_BYTES + 1) as u64) - .read_to_end(&mut bytes) - .map_err(|err| { - let _ = child.kill(); - let _ = child.wait(); - format!("failed to read curl response: {err}") - })?; - if bytes.len() > MAX_FETCH_BYTES { - let _ = child.kill(); - let _ = child.wait(); - return Err(format!( - "response from {url} exceeded {MAX_FETCH_BYTES} bytes" - )); - } - - let status = child - .wait() - .map_err(|err| format!("curl wait failed: {err}"))?; - if !status.success() { - return Err(format!("failed to fetch {url}")); - } - String::from_utf8(bytes).map_err(|err| format!("response was not UTF-8: {err}")) -} - -fn base_url(url: &str) -> Result { - let Some((base, _)) = url.rsplit_once('/') else { - return Err(format!("catalog URL {url} has no base path")); - }; - Ok(base.to_string()) -} - -fn join_url(base: &str, path: &str) -> Result { - if path.contains("://") || path.starts_with('/') || path.split('/').any(|part| part == "..") { - return Err(format!("unsafe manifest path {path}")); - } - Ok(format!("{}/{}", base.trim_end_matches('/'), path)) -} - -fn now_unix() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .unwrap_or(0) -} - -fn now_nanos() -> u128 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_nanos()) - .unwrap_or(0) -} - -#[cfg(test)] -mod tests { - use super::*; - fn remote_manifest(version: &str, contains: &str) -> String { - remote_manifest_for("codex", version, contains) - } - - fn remote_manifest_for(agent: &str, version: &str, contains: &str) -> String { - format!( - r#" -id = "{agent}" -version = "{version}" -min_engine_version = 1 -updated_at = "2026-06-10T12:00:00Z" - -[[rules]] -id = "idle" -state = "idle" -contains = ["{contains}"] -"# - ) - } - - fn with_state_dir(name: &str, f: impl FnOnce() -> T) -> T { - let _guard = crate::config::test_config_env_lock().lock().unwrap(); - let old_config = std::env::var_os("XDG_CONFIG_HOME"); - let old_state = std::env::var_os("XDG_STATE_HOME"); - let dir = std::env::temp_dir().join(format!( - "herdr-manifest-update-{name}-{}", - std::process::id() - )); - let config_dir = dir.join("config"); - let state_dir = dir.join("state"); - let _ = fs::remove_dir_all(&dir); - std::env::set_var("XDG_CONFIG_HOME", &config_dir); - std::env::set_var("XDG_STATE_HOME", &state_dir); - crate::detect::manifest::reload_manifests(); - let result = f(); - match old_config { - Some(value) => std::env::set_var("XDG_CONFIG_HOME", value), - None => std::env::remove_var("XDG_CONFIG_HOME"), - } - match old_state { - Some(value) => std::env::set_var("XDG_STATE_HOME", value), - None => std::env::remove_var("XDG_STATE_HOME"), - } - crate::detect::manifest::reload_manifests(); - let _ = fs::remove_dir_all(&dir); - result - } - - #[test] - fn manifest_version_compares_dotted_numeric_segments() { - assert!( - ManifestVersion::parse("2026.6.10.1").unwrap() - > ManifestVersion::parse("2026.6.9.9").unwrap() - ); - assert!(ManifestVersion::parse("1.2.0").unwrap() == ManifestVersion::parse("1.2").unwrap()); - assert!(ManifestVersion::parse("1.2.1").unwrap() > ManifestVersion::parse("1.2").unwrap()); - } - - #[test] - fn manifest_version_rejects_non_numeric_segments() { - assert!(ManifestVersion::parse("").is_err()); - assert!(ManifestVersion::parse("2026.06.alpha").is_err()); - assert!(ManifestVersion::parse("2026..06").is_err()); - assert!(ManifestVersion::parse("2026.999999999999999999999999999999").is_err()); - } - - #[test] - fn process_agent_manifest_commits_newer_manifest_atomically() { - with_state_dir("commit-newer", || { - let content = remote_manifest("9999.01.01.1", "ready"); - let commit = process_agent_manifest(Agent::Codex, &content, 1) - .unwrap() - .unwrap(); - - assert_eq!(commit.agent, Agent::Codex); - assert_eq!( - commit.version, - ManifestVersion::parse("9999.01.01.1").unwrap() - ); - assert_eq!( - fs::read_to_string(remote_manifest_path(Agent::Codex)).unwrap(), - content - ); - }); - } - - #[test] - fn auto_update_reloads_manifest_cache_after_remote_commit() { - with_state_dir("auto-update-reloads-cache", || { - let old_catalog_url = std::env::var_os(CATALOG_URL_ENV); - let web_dir = std::env::temp_dir() - .join(format!("herdr-manifest-update-web-{}", std::process::id())); - let _ = fs::remove_dir_all(&web_dir); - fs::create_dir_all(&web_dir).unwrap(); - fs::write( - web_dir.join("index.toml"), - r#" -schema_version = 1 - -[[agents]] -id = "codex" -path = "codex.toml" -"#, - ) - .unwrap(); - fs::write( - web_dir.join("codex.toml"), - remote_manifest("9999.01.01.1", "auto-update-ready"), - ) - .unwrap(); - std::env::set_var( - CATALOG_URL_ENV, - format!( - "file:///{}", - web_dir - .join("index.toml") - .to_string_lossy() - .replace('\\', "/") - .trim_start_matches('/') - ), - ); - - let (tx, mut rx) = tokio::sync::mpsc::channel(1); - auto_update(tx); - - let event = rx.try_recv().expect("manifest update event"); - let crate::events::AppEvent::AgentDetectionManifestsUpdated { updated, .. } = event - else { - panic!("unexpected event"); - }; - assert_eq!(updated.len(), 1); - assert_eq!(updated[0].agent, Agent::Codex); - - let explain = crate::detect::manifest::explain(Agent::Codex, "auto-update-ready"); - assert_eq!(explain.state, crate::detect::AgentState::Idle); - assert!(matches!( - explain.source, - Some(crate::detect::manifest::ManifestSource::Remote { .. }) - )); - assert_eq!( - explain.matched_rule.as_ref().map(|rule| rule.id.as_str()), - Some("idle") - ); - - match old_catalog_url { - Some(value) => std::env::set_var(CATALOG_URL_ENV, value), - None => std::env::remove_var(CATALOG_URL_ENV), - } - let _ = fs::remove_dir_all(&web_dir); - }); - } - - #[test] - fn auto_update_reloads_manifest_cache_when_remote_is_already_current() { - with_state_dir("auto-update-reloads-current-cache", || { - let initial = remote_manifest("9999.01.01.1", "initial-ready"); - process_agent_manifest(Agent::Codex, &initial, 1).unwrap(); - crate::detect::manifest::reload_manifests(); - - let old_catalog_url = std::env::var_os(CATALOG_URL_ENV); - let web_dir = std::env::temp_dir().join(format!( - "herdr-manifest-update-current-web-{}", - std::process::id() - )); - let _ = fs::remove_dir_all(&web_dir); - fs::create_dir_all(&web_dir).unwrap(); - fs::write( - web_dir.join("index.toml"), - r#" -schema_version = 1 - -[[agents]] -id = "codex" -path = "codex.toml" -"#, - ) - .unwrap(); - let current = remote_manifest("9999.01.01.2", "current-ready"); - fs::write(web_dir.join("codex.toml"), ¤t).unwrap(); - fs::write(remote_manifest_path(Agent::Codex), current).unwrap(); - std::env::set_var( - CATALOG_URL_ENV, - format!( - "file:///{}", - web_dir - .join("index.toml") - .to_string_lossy() - .replace('\\', "/") - .trim_start_matches('/') - ), - ); - - let (tx, mut rx) = tokio::sync::mpsc::channel(1); - auto_update(tx); - - let event = rx.try_recv().expect("manifest update event"); - let crate::events::AppEvent::AgentDetectionManifestsUpdated { updated, .. } = event - else { - panic!("unexpected event"); - }; - assert!(updated.is_empty()); - - let explain = crate::detect::manifest::explain(Agent::Codex, "current-ready"); - assert_eq!(explain.state, crate::detect::AgentState::Idle); - assert_eq!(explain.manifest_version.as_deref(), Some("9999.01.01.2")); - assert_eq!( - explain.matched_rule.as_ref().map(|rule| rule.id.as_str()), - Some("idle") - ); - - match old_catalog_url { - Some(value) => std::env::set_var(CATALOG_URL_ENV, value), - None => std::env::remove_var(CATALOG_URL_ENV), - } - let _ = fs::remove_dir_all(&web_dir); - }); - } - - #[test] - fn auto_update_does_not_reload_agents_whose_check_failed() { - with_state_dir("auto-update-skips-failed-agent", || { - let initial_codex = remote_manifest("9999.01.01.1", "codex-initial-ready"); - process_agent_manifest(Agent::Codex, &initial_codex, 1).unwrap(); - let initial_cursor = - remote_manifest_for("cursor", "9999.01.01.1", "cursor-initial-ready"); - process_agent_manifest(Agent::Cursor, &initial_cursor, 1).unwrap(); - crate::detect::manifest::reload_manifests(); - - let old_catalog_url = std::env::var_os(CATALOG_URL_ENV); - let web_dir = std::env::temp_dir().join(format!( - "herdr-manifest-update-partial-web-{}", - std::process::id() - )); - let _ = fs::remove_dir_all(&web_dir); - fs::create_dir_all(&web_dir).unwrap(); - fs::write( - web_dir.join("index.toml"), - r#" -schema_version = 1 - -[[agents]] -id = "codex" -path = "codex.toml" - -[[agents]] -id = "cursor" -path = "missing-cursor.toml" -"#, - ) - .unwrap(); - let current_codex = remote_manifest("9999.01.01.2", "codex-current-ready"); - fs::write(web_dir.join("codex.toml"), ¤t_codex).unwrap(); - fs::write(remote_manifest_path(Agent::Codex), current_codex).unwrap(); - fs::write( - remote_manifest_path(Agent::Cursor), - remote_manifest_for("cursor", "9999.01.01.2", "cursor-current-ready"), - ) - .unwrap(); - std::env::set_var( - CATALOG_URL_ENV, - format!( - "file:///{}", - web_dir - .join("index.toml") - .to_string_lossy() - .replace('\\', "/") - .trim_start_matches('/') - ), - ); - - let (tx, mut rx) = tokio::sync::mpsc::channel(1); - auto_update(tx); - - let event = rx.try_recv().expect("manifest update event"); - let crate::events::AppEvent::AgentDetectionManifestsUpdated { - updated, activated, .. - } = event - else { - panic!("unexpected event"); - }; - assert!(updated.is_empty()); - assert_eq!(activated, vec![Agent::Codex]); - - let codex = crate::detect::manifest::explain(Agent::Codex, "codex-current-ready"); - assert_eq!(codex.manifest_version.as_deref(), Some("9999.01.01.2")); - assert_eq!( - codex.matched_rule.as_ref().map(|rule| rule.id.as_str()), - Some("idle") - ); - let cursor = crate::detect::manifest::explain(Agent::Cursor, "cursor-initial-ready"); - assert_eq!(cursor.manifest_version.as_deref(), Some("9999.01.01.1")); - assert_eq!( - cursor.matched_rule.as_ref().map(|rule| rule.id.as_str()), - Some("idle") - ); - - match old_catalog_url { - Some(value) => std::env::set_var(CATALOG_URL_ENV, value), - None => std::env::remove_var(CATALOG_URL_ENV), - } - let _ = fs::remove_dir_all(&web_dir); - }); - } - - #[test] - fn cache_reload_ignores_retained_status_for_unchecked_agent() { - with_state_dir("cache-reload-ignores-retained-status", || { - let initial = remote_manifest("9999.01.01.1", "initial-ready"); - process_agent_manifest(Agent::Codex, &initial, 1).unwrap(); - crate::detect::manifest::reload_manifests(); - fs::write( - remote_manifest_path(Agent::Codex), - remote_manifest("9999.01.01.2", "current-ready"), - ) - .unwrap(); - - let mut status = ManifestUpdateStatus::default(); - status.agents.insert( - "codex".to_string(), - AgentRemoteStatus { - cached_version: Some("9999.01.01.1".to_string()), - attempted_version: None, - last_checked_unix: Some(1), - last_result: "current".to_string(), - last_error: None, - }, - ); - let output = ManifestUpdateOutput { - checked: vec![Agent::Cursor], - updated: Vec::new(), - status, - }; - - assert!(agents_needing_cache_reload(&output).is_empty()); - }); - } - - #[test] - fn process_agent_manifest_rejects_downgrade_and_keeps_cached_manifest() { - with_state_dir("reject-downgrade", || { - let current = remote_manifest("9999.01.01.1", "current"); - process_agent_manifest(Agent::Codex, ¤t, 1).unwrap(); - - let older = remote_manifest("9999.01.01.0", "older"); - assert!(process_agent_manifest(Agent::Codex, &older, 2).is_err()); - assert_eq!( - fs::read_to_string(remote_manifest_path(Agent::Codex)).unwrap(), - current - ); - }); - } - - #[test] - fn process_agent_manifest_rejects_equal_version_content_change() { - with_state_dir("reject-equal-change", || { - let current = remote_manifest("9999.01.01.1", "current"); - process_agent_manifest(Agent::Codex, ¤t, 1).unwrap(); - - let changed = remote_manifest("9999.01.01.1", "changed"); - assert!(process_agent_manifest(Agent::Codex, &changed, 2).is_err()); - assert_eq!( - fs::read_to_string(remote_manifest_path(Agent::Codex)).unwrap(), - current - ); - }); - } - - #[test] - fn process_agent_manifest_skips_same_version_same_content() { - with_state_dir("skip-same", || { - let current = remote_manifest("9999.01.01.1", "current"); - process_agent_manifest(Agent::Codex, ¤t, 1).unwrap(); - - let result = process_agent_manifest(Agent::Codex, ¤t, 2).unwrap(); - assert!(result.is_none()); - }); - } - - #[test] - fn catalog_parses_known_agents_and_rejects_duplicates() { - let catalog = parse_catalog( - r#" -schema_version = 1 - -[[agents]] -id = "codex" -path = "codex.toml" -"#, - ) - .unwrap(); - assert_eq!(catalog[0].agent, Agent::Codex); - assert_eq!(catalog[0].path, "codex.toml"); - - assert!(parse_catalog( - r#" -schema_version = 1 - -[[agents]] -id = "codex" -path = "codex.toml" - -[[agents]] -id = "codex" -path = "codex-2.toml" -"# - ) - .is_err()); - } - - #[test] - fn catalog_rejects_unsafe_paths() { - assert!(parse_catalog( - r#" -schema_version = 1 - -[[agents]] -id = "codex" -path = "../codex.toml" -"# - ) - .is_err()); - } -} diff --git a/src/detect/manifest_version.rs b/src/detect/manifest_version.rs new file mode 100644 index 0000000000..2f72b5f251 --- /dev/null +++ b/src/detect/manifest_version.rs @@ -0,0 +1,128 @@ +//! Detection compiler compatibility and manifest version ordering. + +use std::{cmp::Ordering, fmt}; + +use serde::{Deserialize, Serialize}; + +pub(crate) const MANIFEST_ENGINE_VERSION: u32 = 3; + +#[derive(Debug, Clone)] +pub(crate) struct ManifestVersion(String); + +impl ManifestVersion { + pub(crate) fn parse(value: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err("version must not be empty".to_string()); + } + for segment in trimmed.split('.') { + if segment.is_empty() { + return Err(format!("version {trimmed:?} contains an empty segment")); + } + if !segment.chars().all(|ch| ch.is_ascii_digit()) { + return Err(format!("version {trimmed:?} must be dotted numeric")); + } + segment + .parse::() + .map_err(|_| format!("version {trimmed:?} contains an oversized segment"))?; + } + Ok(Self(trimmed.to_string())) + } +} + +impl fmt::Display for ManifestVersion { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl<'de> Deserialize<'de> for ManifestVersion { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(&value).map_err(serde::de::Error::custom) + } +} + +impl Serialize for ManifestVersion { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.0) + } +} + +impl Ord for ManifestVersion { + fn cmp(&self, other: &Self) -> Ordering { + let mut left = self.0.split('.'); + let mut right = other.0.split('.'); + + loop { + match (left.next(), right.next()) { + (Some(left), Some(right)) => { + let left = left.parse::().unwrap_or(0); + let right = right.parse::().unwrap_or(0); + match left.cmp(&right) { + Ordering::Equal => {} + ordering => return ordering, + } + } + (Some(left), None) => { + let left = left.parse::().unwrap_or(0); + if left == 0 { + continue; + } + return Ordering::Greater; + } + (None, Some(right)) => { + let right = right.parse::().unwrap_or(0); + if right == 0 { + continue; + } + return Ordering::Less; + } + (None, None) => return Ordering::Equal, + } + } + } +} + +impl PartialOrd for ManifestVersion { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl PartialEq for ManifestVersion { + fn eq(&self, other: &Self) -> bool { + self.cmp(other) == Ordering::Equal + } +} + +impl Eq for ManifestVersion {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn manifest_version_compares_dotted_numeric_segments() { + assert!( + ManifestVersion::parse("2026.6.10.1").unwrap() + > ManifestVersion::parse("2026.6.9.9").unwrap() + ); + assert!(ManifestVersion::parse("1.2.0").unwrap() == ManifestVersion::parse("1.2").unwrap()); + assert!(ManifestVersion::parse("1.2.1").unwrap() > ManifestVersion::parse("1.2").unwrap()); + } + + #[test] + fn manifest_version_rejects_non_numeric_segments() { + assert!(ManifestVersion::parse("").is_err()); + assert!(ManifestVersion::parse("2026.06.alpha").is_err()); + assert!(ManifestVersion::parse("2026..06").is_err()); + assert!(ManifestVersion::parse("2026.999999999999999999999999999999").is_err()); + } +} diff --git a/src/detect/manifests/opencode.toml b/src/detect/manifests/opencode.toml deleted file mode 100644 index 5245238371..0000000000 --- a/src/detect/manifests/opencode.toml +++ /dev/null @@ -1,37 +0,0 @@ -id = "opencode" -version = "2026.06.10.1" -min_engine_version = 1 -updated_at = "2026-06-10T00:00:00Z" -aliases = ["open-code", "herdr:opencode"] - -[[rules]] -id = "permission_required" -state = "blocked" -priority = 300 -region = "whole_recent" -visible_blocker = true -any = [ - { contains = ["△ Permission required"] }, - { contains = ["esc dismiss"], any = [{ contains = ["enter confirm"] }, { contains = ["enter submit"] }, { contains = ["enter toggle"] }], all = [{ any = [{ contains = ["↑↓ select"] }, { contains = ["⇆ tab"] }] }] }, -] - -[[rules]] -id = "interrupt_hint_working" -state = "working" -priority = 110 -region = "whole_recent" -visible_working = true -any = [ - { contains = ["esc to interrupt"] }, - { contains = ["ctrl+c to interrupt"] }, - { contains = ["press esc to interrupt"] }, - { line_regex = ['(?i).*opencode.*esc (again to )?interrupt'] }, -] - -[[rules]] -id = "progress_bar_working" -state = "working" -priority = 100 -region = "whole_recent" -visible_working = true -regex = ['(■|⬝){4,}'] diff --git a/src/detect/mod.rs b/src/detect/mod.rs index 0248d5cedb..ca0236390f 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -3,8 +3,11 @@ //! Each pane's live bottom-of-buffer text is read periodically and matched //! against known agent output patterns to determine state. +use crate::agents::AgentRegistry; + pub mod manifest; -pub mod manifest_update; +pub(crate) mod manifest_compat; +pub(crate) mod manifest_version; /// The detected state of a terminal pane. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -28,6 +31,8 @@ pub struct AgentDetection { pub skip_state_update: bool, /// True when the current screen visibly shows live idle chrome. pub visible_idle: bool, + /// Idle evidence from terminal cells, never an OSC title or progress report. + pub screen_visible_idle: bool, /// True when the current screen visibly shows live UI chrome that needs /// human input. This is stronger than arbitrary prompt-like text in the /// scrollback and may override a non-blocked integration state. @@ -39,224 +44,81 @@ pub struct AgentDetection { } /// Which agent we detected running in a pane. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Agent { - Pi, - Claude, - Codex, - Gemini, - Cursor, - Devin, - Antigravity, - Cline, - Omp, - Mastracode, - OpenCode, - GithubCopilot, - Kimi, - Kiro, - Droid, - Amp, - Grok, - Hermes, - Kilo, - Qodercli, - Qwen, - Maki, - Muse, -} - -impl Agent { - pub const ALL: [Self; 23] = [ - Self::Pi, - Self::Claude, - Self::Codex, - Self::Gemini, - Self::Cursor, - Self::Devin, - Self::Antigravity, - Self::Cline, - Self::Omp, - Self::Mastracode, - Self::OpenCode, - Self::GithubCopilot, - Self::Kimi, - Self::Kiro, - Self::Droid, - Self::Amp, - Self::Grok, - Self::Hermes, - Self::Kilo, - Self::Qodercli, - Self::Qwen, - Self::Maki, - Self::Muse, - ]; - - pub const SCREEN_MANIFEST_AGENTS: [Self; 21] = [ - Self::Pi, - Self::Claude, - Self::Codex, - Self::Gemini, - Self::Cursor, - Self::Devin, - Self::Antigravity, - Self::Cline, - Self::OpenCode, - Self::GithubCopilot, - Self::Kimi, - Self::Kiro, - Self::Droid, - Self::Amp, - Self::Grok, - Self::Hermes, - Self::Kilo, - Self::Qodercli, - Self::Qwen, - Self::Maki, - Self::Muse, - ]; -} +pub use crate::agents::id::AgentId as Agent; -pub fn agent_label(agent: Agent) -> &'static str { - match agent { - Agent::Pi => "pi", - Agent::Claude => "claude", - Agent::Codex => "codex", - Agent::Gemini => "gemini", - Agent::Cursor => "cursor", - Agent::Devin => "devin", - Agent::Antigravity => "agy", - Agent::Cline => "cline", - Agent::Omp => "omp", - Agent::Mastracode => "mastracode", - Agent::OpenCode => "opencode", - Agent::GithubCopilot => "copilot", - Agent::Kimi => "kimi", - Agent::Kiro => "kiro", - Agent::Droid => "droid", - Agent::Amp => "amp", - Agent::Grok => "grok", - Agent::Hermes => "hermes", - Agent::Kilo => "kilo", - Agent::Qodercli => "qodercli", - Agent::Qwen => "qwen", - Agent::Maki => "maki", - Agent::Muse => "muse", - } +pub fn agent_label(agent: &Agent) -> &str { + agent.as_str() } -pub fn interactive_agent_executable(agent: Agent) -> &'static str { - match agent { - Agent::Pi => "pi", - Agent::Claude => "claude", - Agent::Codex => "codex", - Agent::Gemini => "gemini", - Agent::Cursor => { - if cfg!(windows) { - "cursor-agent.cmd" - } else { - "cursor-agent" - } - } - Agent::Devin => "devin", - Agent::Antigravity => "agy", - Agent::Cline => "cline", - Agent::Omp => "omp", - Agent::Mastracode => "mastracode", - Agent::OpenCode => "opencode", - Agent::GithubCopilot => "copilot", - Agent::Kimi => "kimi", - Agent::Kiro => "kiro-cli", - Agent::Droid => "droid", - Agent::Amp => "amp", - Agent::Grok => "grok", - Agent::Hermes => "hermes", - Agent::Kilo => "kilo", - Agent::Qodercli => "qodercli", - Agent::Qwen => "qwen", - Agent::Maki => "maki", - Agent::Muse => "muse", - } +#[cfg(test)] +pub fn interactive_agent_executable(agent: Agent) -> String { + crate::agents::registry() + .profile_by_agent(agent) + .map(|profile| profile.launch().executable().to_owned()) + .unwrap_or_default() } pub fn parse_agent_label(agent: &str) -> Option { + let registry = crate::agents::registry(); let name = normalized_agent_lookup_name(agent); - parse_canonical_agent_label(&name).or_else(|| lookup_agent(&name)) + let name = path_basename(&name); + registry + .profile_by_normalized_alias(name) + .or_else(|| registry.profile_by_versioned_process_name(name)) + .map(|profile| profile.legacy_agent()) } +#[cfg(test)] pub(crate) fn parse_canonical_agent_label(label: &str) -> Option { - let agent = lookup_agent(label)?; - (agent_label(agent) == label).then_some(agent) -} - -fn lookup_agent(name: &str) -> Option { - let name = path_basename(name); - match name { - "pi" => Some(Agent::Pi), - "claude" | "claude-code" => Some(Agent::Claude), - "codex" => Some(Agent::Codex), - "gemini" => Some(Agent::Gemini), - "cursor" | "cursor-agent" => Some(Agent::Cursor), - "devin" | "devin-cli" | "devin cli" => Some(Agent::Devin), - "agy" | "antigravity" | "antigravity-cli" => Some(Agent::Antigravity), - "cline" | ".cline" => Some(Agent::Cline), - "omp" => Some(Agent::Omp), - "mastracode" | "mastra-code" | "mastra code" => Some(Agent::Mastracode), - "opencode" | "opencode2" | "open-code" => Some(Agent::OpenCode), - "copilot" | "github-copilot" | "ghcs" => Some(Agent::GithubCopilot), - "kimi" | "kimi-code" | "kimi code" => Some(Agent::Kimi), - "kiro" | "kiro-cli" => Some(Agent::Kiro), - "droid" => Some(Agent::Droid), - "amp" | "amp-local" => Some(Agent::Amp), - "grok" | "grok-build" => Some(Agent::Grok), - "hermes" | "hermes-agent" => Some(Agent::Hermes), - "kilo" | "kilo-code" | "kilo code" => Some(Agent::Kilo), - "qodercli" | "qoderclicn" | "qoder" | "qodercn" => Some(Agent::Qodercli), - "qwen" | "qwen-code" | "qwen code" => Some(Agent::Qwen), - "maki" => Some(Agent::Maki), - "muse" | "muse-code" | "muse-cli" => Some(Agent::Muse), - _ if is_muse_versioned_binary(name) => Some(Agent::Muse), - _ => None, - } -} - -/// Muse's install-dir launcher script resolves the active release and execs -/// `muse-bin-` (e.g. `muse-bin-0.1.0-R708.1`), so the running -/// process never carries a bare `muse`/`muse-bin` alias. Require a digit -/// immediately after the `muse-bin-` prefix so unrelated binaries such as -/// `muse-binary` or a bare `muse-bin` stay unmatched. -/// Accepts path-qualified `argv0` values by checking only the basename, since -/// the launcher may `exec` with an absolute install-dir path. -fn is_muse_versioned_binary(name: &str) -> bool { - path_basename(name) - .strip_prefix("muse-bin-") - .is_some_and(|rest| rest.starts_with(|c: char| c.is_ascii_digit())) + crate::agents::registry() + .profile_by_id(label) + .map(|profile| profile.legacy_agent()) } /// Identify which agent is running from the process name. /// Returns `None` for plain shells or unrecognized programs. +#[cfg(test)] pub fn identify_agent(process_name: &str) -> Option { - parse_agent_label(process_name) + let registry = crate::agents::registry(); + identify_agent_with_registry(®istry, process_name) +} + +/// Identify a process using only the caller's pinned registry. +pub fn identify_agent_with_registry(registry: &AgentRegistry, process_name: &str) -> Option { + let name = normalized_agent_lookup_name(process_name); + let name = path_basename(&name); + registry + .profile_by_normalized_process_name(name) + .map(|profile| profile.legacy_agent()) } pub fn identify_agent_in_job(job: &crate::platform::ForegroundJob) -> Option<(Agent, String)> { + let registry = crate::agents::registry(); + identify_agent_in_job_with_registry(®istry, job) +} + +/// Recognize every candidate in a foreground job against the same snapshot. +pub fn identify_agent_in_job_with_registry( + registry: &AgentRegistry, + job: &crate::platform::ForegroundJob, +) -> Option<(Agent, String)> { + let recognizer = ProcessRecognizer { registry }; if let Some(process) = job .processes .iter() .find(|process| process.pid == job.process_group_id) { - let candidate = normalized_process_name(process); - if let Some(agent) = identify_agent(&candidate) { - return Some((agent, candidate)); + if let Some(candidate) = recognizer.normalized_process_name(process) { + return Some(candidate); } } let mut best: Option<(u8, Agent, String)> = None; + // Preserve the leader-first, single best-candidate pass without collecting + // profiles or allocating registry state inside the process/path loops. for process in &job.processes { - let candidate = normalized_process_name(process); - let Some(agent) = identify_agent(&candidate) else { + let Some((agent, candidate)) = recognizer.normalized_process_name(process) else { continue; }; let score = process_priority(process, &candidate); @@ -295,6 +157,7 @@ pub fn detect_agent_with_osc( state: AgentState::Unknown, skip_state_update: false, visible_idle: false, + screen_visible_idle: false, visible_blocker: false, visible_working: false, }; @@ -309,27 +172,12 @@ pub fn detect_agent_with_osc( ) } -pub fn should_skip_state_update(agent: Option, screen_content: &str) -> bool { - agent.is_some_and(|agent| manifest::should_skip_state_update(agent, screen_content)) -} - pub(crate) fn full_lifecycle_hook_authority(source: &str, agent_label: &str) -> bool { - matches!( - (source, agent_label), - ("herdr:pi", "pi") - | ("herdr:omp", "omp") - | ("herdr:mastracode", "mastracode") - | ("herdr:opencode", "opencode") - | ("herdr:kilo", "kilo") - | ("herdr:kimi", "kimi") - ) + crate::agents::registry().has_full_lifecycle_report_authority(source, agent_label) } pub(crate) fn session_identity_only_integration(source: &str, agent_label: &str) -> bool { - matches!( - (source, agent_label), - ("herdr:hermes", "hermes") | ("herdr:qwen", "qwen") | ("herdr:antigravity_cli", "agy") - ) + crate::agents::registry().is_session_identity_only_integration(source, agent_label) } // --------------------------------------------------------------------------- @@ -350,161 +198,422 @@ pub fn foreground_group_leader_job( crate::platform::foreground_group_leader_job(process_group_id) } -/// Get the foreground process group for a pane shell PID. -/// This is cheaper than collecting every process in the foreground job. -pub fn foreground_process_group_id(child_pid: u32) -> Option { - crate::platform::foreground_process_group_id(child_pid) +/// Only a concrete executable/script argv can supply options, never shell command text. +pub(crate) fn structured_resume_args<'a>( + registry: &AgentRegistry, + process: &'a crate::platform::ForegroundProcess, + agent: Agent, +) -> Option<&'a [String]> { + let argv = process.argv.as_deref()?; + let (canonical, start) = ProcessRecognizer { registry }.structured_agent_entrypoint(argv)?; + (canonical == agent.as_str()).then(|| &argv[start..]) } -fn normalized_process_name(process: &crate::platform::ForegroundProcess) -> String { - let effective = process.argv0.as_deref().unwrap_or(&process.name); - let lower_effective = effective.to_lowercase(); +struct ProcessRecognizer<'a> { + registry: &'a AgentRegistry, +} - if is_generic_runtime_or_shell(&lower_effective) { - if let Some(wrapped_agent) = - wrapped_agent_name_from_runtime_argv(&lower_effective, process.argv.as_deref()) - { - return wrapped_agent; +impl ProcessRecognizer<'_> { + fn structured_agent_entrypoint(&self, argv: &[String]) -> Option<(String, usize)> { + let runtime = normalized_agent_lookup_name(path_basename(argv.first()?)); + if !is_generic_runtime_or_shell(&runtime) { + return self + .agent_name_from_path_token(argv.first()?) + .map(|agent| (agent, 1)); } - } - - if identify_agent(effective).is_some() { - return effective.to_string(); - } - - if let Some(runtime) = process.argv.as_deref().and_then(|argv| argv.first()) { - let runtime_name = normalized_agent_lookup_name(path_basename(runtime)); - if matches!(runtime_name.as_str(), "node" | "bun") { + let index = structured_runtime_script_index(&runtime, argv)?; + let agent = if runtime == "node" && index == 1 { + self.bundled_node_agent_name_from_argv(argv) + .or_else(|| self.agent_name_from_path_token(&argv[index])) + } else { + self.agent_name_from_path_token(&argv[index]) + }?; + Some((agent, index + 1)) + } + + fn normalized_process_name( + &self, + process: &crate::platform::ForegroundProcess, + ) -> Option<(Agent, String)> { + let effective = process.argv0.as_deref().unwrap_or(&process.name); + let lower_effective = effective.to_lowercase(); + + if is_generic_runtime_or_shell(&lower_effective) { if let Some(wrapped_agent) = - wrapped_agent_name_from_runtime_argv(runtime, process.argv.as_deref()) + self.wrapped_agent_name_from_runtime_argv(&lower_effective, process.argv.as_deref()) { - if matches!( - identify_agent(&wrapped_agent), - Some(Agent::Qwen | Agent::Cline) - ) { - return wrapped_agent; + return self.canonical_candidate(wrapped_agent); + } + } + + if let Some(agent) = identify_agent_with_registry(self.registry, effective) { + return Some((agent, effective.to_string())); + } + + if let Some(runtime) = process.argv.as_deref().and_then(|argv| argv.first()) { + let runtime_name = normalized_agent_lookup_name(path_basename(runtime)); + if matches!(runtime_name.as_str(), "node" | "bun") { + if let Some(wrapped_agent) = + self.wrapped_agent_name_from_runtime_argv(runtime, process.argv.as_deref()) + { + if self + .registry + .profile_by_id(&wrapped_agent) + .and_then(|profile| profile.process()) + .is_some_and(|profile| profile.uses_secondary_runtime_argv_fallback()) + { + return self.canonical_candidate(wrapped_agent); + } } } } + + self.argv0_agent_name(process.argv.as_deref()) + .or_else(|| { + self.cmdline_argv0_agent_name(process.cmdline.as_deref().unwrap_or_default()) + }) + .and_then(|name| self.canonical_candidate(name)) } - if let Some(wrapped_agent) = argv0_agent_name(process.argv.as_deref()) - .or_else(|| cmdline_argv0_agent_name(process.cmdline.as_deref().unwrap_or_default())) - { - return wrapped_agent; + // Path/runtime matchers return canonical IDs, not process names. Carry the + // resolved identity forward so a novel ID need not also be a process alias. + fn canonical_candidate(&self, name: String) -> Option<(Agent, String)> { + let agent = self.registry.profile_by_id(&name)?.legacy_agent(); + Some((agent, name)) } - effective.to_string() -} + fn wrapped_agent_name_from_runtime_argv( + &self, + runtime: &str, + argv: Option<&[String]>, + ) -> Option { + let argv = argv?; + let runtime_name = normalized_agent_lookup_name(path_basename(runtime)); -fn wrapped_agent_name_from_runtime_argv(runtime: &str, argv: Option<&[String]>) -> Option { - let argv = argv?; - let runtime_name = normalized_agent_lookup_name(path_basename(runtime)); - - match runtime_name.as_str() { - "node" => cursor_agent_name_from_bundled_node_argv(argv) - .or_else(|| script_arg_agent_name(argv, &["-e", "--eval", "-p", "--print"], &[])), - "bun" => script_arg_agent_name(argv, &["-e", "--eval", "-p", "--print"], &[]), - name if is_python_runtime(name) => script_arg_agent_name(argv, &["-c"], &["-m"]), - "sh" | "bash" | "zsh" | "fish" => script_arg_agent_name(argv, &["-c"], &[]), - "cmd" => windows_cmd_arg_agent_name(argv), - "powershell" | "pwsh" => powershell_arg_agent_name(argv), - "tmux" => None, - _ => None, + match runtime_name.as_str() { + "node" => self.bundled_node_agent_name_from_argv(argv).or_else(|| { + self.script_arg_agent_name(argv, &["-e", "--eval", "-p", "--print"], &[]) + }), + "bun" => self.script_arg_agent_name(argv, &["-e", "--eval", "-p", "--print"], &[]), + name if is_python_runtime(name) => self.script_arg_agent_name(argv, &["-c"], &["-m"]), + "sh" | "bash" | "zsh" | "fish" => self.script_arg_agent_name(argv, &["-c"], &[]), + "cmd" => self.windows_cmd_arg_agent_name(argv), + "powershell" | "pwsh" => self.powershell_arg_agent_name(argv), + "tmux" => None, + _ => None, + } } -} -fn cursor_agent_name_from_bundled_node_argv(argv: &[String]) -> Option { - let (runtime_parent, runtime_name) = path_parent_and_basename(argv.first()?)?; - let (script_parent, script_name) = path_parent_and_basename(argv.get(1)?)?; - if !runtime_name.eq_ignore_ascii_case("node.exe") - || !script_name.eq_ignore_ascii_case("index.js") - || !runtime_parent.eq_ignore_ascii_case(script_parent) - { - return None; - } + fn bundled_node_agent_name_from_argv(&self, argv: &[String]) -> Option { + let (runtime_parent, runtime_name) = path_parent_and_basename(argv.first()?)?; + let (script_parent, script_name) = path_parent_and_basename(argv.get(1)?)?; + if !runtime_parent.eq_ignore_ascii_case(script_parent) { + return None; + } - let mut tail = runtime_parent - .rsplit(['/', '\\']) - .filter(|component| !component.is_empty()); - let (Some(version), Some(versions), Some(package)) = (tail.next(), tail.next(), tail.next()) - else { - return None; - }; - (package.eq_ignore_ascii_case("cursor-agent") - && versions.eq_ignore_ascii_case("versions") - && !version.trim().is_empty()) - .then(|| agent_label(Agent::Cursor).to_string()) -} + for profile in self.registry.process_profiles_with_bundled_node_layout() { + let layout = profile.process()?.bundled_node_layout()?; + if !runtime_name.eq_ignore_ascii_case(layout.runtime_basename()) + || !script_name.eq_ignore_ascii_case(layout.entrypoint_basename()) + { + continue; + } -fn path_parent_and_basename(path: &str) -> Option<(&str, &str)> { - let split = path.rfind(['/', '\\'])?; - let parent = path[..split].trim_end_matches(['/', '\\']); - let basename = &path[split + 1..]; - (!parent.is_empty() && !basename.is_empty()).then_some((parent, basename)) -} + let mut tail = runtime_parent + .rsplit(['/', '\\']) + .filter(|component| !component.is_empty()); + let (Some(version), Some(versions), Some(package)) = + (tail.next(), tail.next(), tail.next()) + else { + continue; + }; + if package.eq_ignore_ascii_case(layout.package_directory()) + && versions.eq_ignore_ascii_case(layout.versions_directory()) + && !version.trim().is_empty() + { + return Some(profile.canonical_id().to_string()); + } + } -fn windows_cmd_arg_agent_name(argv: &[String]) -> Option { - let mut args = argv.iter().skip(1); - while let Some(arg) = args.next() { - let flag = arg.trim_matches('"').to_lowercase(); - match flag.as_str() { - "/c" | "/k" => { - return args - .next() - .and_then(|command| command_text_agent_name(command)) + None + } + + fn windows_cmd_arg_agent_name(&self, argv: &[String]) -> Option { + let mut args = argv.iter().skip(1); + while let Some(arg) = args.next() { + let flag = arg.trim_matches('"').to_lowercase(); + match flag.as_str() { + "/c" | "/k" => { + return args + .next() + .and_then(|command| self.command_text_agent_name(command)) + } + "/d" | "/s" | "/q" | "/a" | "/u" | "/e:on" | "/e:off" | "/f:on" | "/f:off" + | "/v:on" | "/v:off" => continue, + _ => {} + } + } + None + } + + fn powershell_arg_agent_name(&self, argv: &[String]) -> Option { + let mut args = argv.iter().skip(1); + while let Some(arg) = args.next() { + let flag = arg.trim_matches('"').to_lowercase(); + match flag.as_str() { + "-file" | "-f" | "/file" => { + return args + .next() + .and_then(|path| self.agent_name_from_path_token(path)); + } + "-command" | "-c" | "/command" | "/c" => { + return args + .next() + .and_then(|command| self.command_text_agent_name(command)); + } + "-encodedcommand" | "-enc" | "/encodedcommand" | "/enc" => return None, + "-configurationname" | "-executionpolicy" | "-outputformat" | "-psconsolefile" + | "-version" | "-windowstyle" | "-workingdirectory" => { + let _ = args.next(); + } + _ if flag.starts_with('-') || flag.starts_with('/') => {} + _ => return self.agent_name_from_path_token(arg), } - "/d" | "/s" | "/q" | "/a" | "/u" | "/e:on" | "/e:off" | "/f:on" | "/f:off" - | "/v:on" | "/v:off" => continue, - _ => {} } + None } - None -} -fn powershell_arg_agent_name(argv: &[String]) -> Option { - let mut args = argv.iter().skip(1); - while let Some(arg) = args.next() { - let flag = arg.trim_matches('"').to_lowercase(); - match flag.as_str() { - "-file" | "-f" | "/file" => { - return args - .next() - .and_then(|path| agent_name_from_path_token(path)); + fn command_text_agent_name(&self, command: &str) -> Option { + let mut rest = command; + while let Some((token, next)) = command_text_token(rest) { + let token = token.trim(); + if token.eq_ignore_ascii_case("&") + || token.eq_ignore_ascii_case(".") + || token.eq_ignore_ascii_case("call") + { + rest = next; + continue; } - "-command" | "-c" | "/command" | "/c" => { + return self.agent_name_from_path_token(token); + } + None + } + + fn script_arg_agent_name( + &self, + argv: &[String], + eval_flags: &[&str], + module_flags: &[&str], + ) -> Option { + let mut args = argv.iter().skip(1); + while let Some(arg) = args.next() { + if arg == "--" { return args .next() - .and_then(|command| command_text_agent_name(command)); + .and_then(|token| self.agent_name_from_path_token(token)); + } + + if flag_matches(arg, eval_flags) || flag_matches(arg, module_flags) { + return None; } - "-encodedcommand" | "-enc" | "/encodedcommand" | "/enc" => return None, - "-configurationname" | "-executionpolicy" | "-outputformat" | "-psconsolefile" - | "-version" | "-windowstyle" | "-workingdirectory" => { - let _ = args.next(); + + if arg.starts_with('-') { + if option_takes_value(arg) { + let _ = args.next(); + } + continue; } - _ if flag.starts_with('-') || flag.starts_with('/') => {} - _ => return agent_name_from_path_token(arg), + + return self.agent_name_from_path_token(arg); } + + None + } + + fn argv0_agent_name(&self, argv: Option<&[String]>) -> Option { + self.agent_name_from_path_token(argv?.first()?) + } + + fn cmdline_argv0_agent_name(&self, cmdline: &str) -> Option { + self.agent_name_from_path_token(cmdline.split_whitespace().next()?) + } + + fn agent_name_from_path_token(&self, token: &str) -> Option { + let trimmed = token.trim_matches(|c| matches!(c, '"' | '\'')); + if trimmed.is_empty() || trimmed.starts_with('-') { + return None; + } + + self.agent_name_from_basename(path_basename(trimmed)) + .or_else(|| self.agent_name_from_known_package_path(trimmed)) + .or_else(|| self.resolved_agent_name_from_path_token(trimmed)) + } + + fn agent_name_from_known_package_path(&self, path: &str) -> Option { + use crate::agents::process::KnownPackageMatch; + + let raw_components: Vec<&str> = path + .split(['/', '\\']) + .filter(|component| !component.is_empty()) + .collect(); + let ends_with = |suffix: &[String]| { + raw_components.len() >= suffix.len() + && raw_components[raw_components.len() - suffix.len()..] + .iter() + .zip(suffix) + .all(|(actual, expected)| actual.eq_ignore_ascii_case(expected)) + }; + // Exact suffixes take precedence over the legacy normalized layout search. + for profile in self.registry.process_profiles_with_package_layouts() { + for layout in profile.process()?.known_package_layouts() { + if layout.match_kind() == KnownPackageMatch::ExactSuffix + && ends_with(layout.components()) + { + return Some(profile.canonical_id().to_string()); + } + } + } + + let components: Vec = raw_components + .into_iter() + .map(normalized_agent_lookup_name) + .collect(); + + let mut best_match: Option<((usize, usize, usize), &crate::agents::AgentProfile)> = None; + for (profile_priority, profile) in self + .registry + .process_profiles_with_package_layouts() + .enumerate() + { + let process_profile = profile.process()?; + for layout in process_profile.known_package_layouts() { + if layout.match_kind() != KnownPackageMatch::NormalizedComponents { + continue; + } + let expected = layout.components(); + let Some(component_position) = components + .windows(expected.len()) + .position(|window| window == expected) + else { + continue; + }; + let rank = (expected.len(), component_position, profile_priority); + let replace = match &best_match { + None => true, + Some((best_rank, _)) => { + rank.0 > best_rank.0 + || (rank.0 == best_rank.0 && rank.1 < best_rank.1) + || (rank.0 == best_rank.0 + && rank.1 == best_rank.1 + && rank.2 < best_rank.2) + } + }; + if replace { + best_match = Some((rank, profile)); + } + } + } + + best_match.map(|(_, profile)| profile.canonical_id().to_string()) + } + + fn resolved_agent_name_from_path_token(&self, token: &str) -> Option { + let path = std::path::Path::new(token); + if path.components().count() < 2 { + return None; + } + + let resolved = std::fs::canonicalize(path).ok()?; + let basename = resolved.file_name()?.to_str()?; + self.agent_name_from_basename(basename) + } + + fn agent_name_from_basename(&self, basename: &str) -> Option { + let agent = identify_agent_with_registry(self.registry, basename)?; + Some(agent_label(&agent).to_string()) } - None } -fn command_text_agent_name(command: &str) -> Option { - let mut rest = command; - while let Some((token, next)) = command_text_token(rest) { - let token = token.trim(); - if token.eq_ignore_ascii_case("&") - || token.eq_ignore_ascii_case(".") - || token.eq_ignore_ascii_case("call") +// Capture needs stronger evidence than identity heuristics: an unknown runtime +// switch might consume the following agent-looking path as its own value. +fn structured_runtime_script_index(runtime: &str, argv: &[String]) -> Option { + let (flags, options): (&[&str], &[&str]) = match runtime { + "node" | "bun" => ( + &[ + "--no-warnings", + "--enable-source-maps", + "--preserve-symlinks", + "--preserve-symlinks-main", + "--experimental-strip-types", + ], + &[ + "-r", + "--require", + "--import", + "--loader", + "--experimental-loader", + "--conditions", + ], + ), + name if is_python_runtime(name) => (&["-u", "-B", "-E", "-I", "-s", "-S"], &["-W", "-X"]), + "sh" | "bash" | "zsh" | "fish" => ( + &["--noprofile", "--norc", "-l", "-i"], + &["--rcfile", "--init-file"], + ), + "powershell" | "pwsh" => ( + &["-noprofile", "-nologo", "-noninteractive", "-noexit"], + &[ + "-executionpolicy", + "-inputformat", + "-outputformat", + "-workingdirectory", + "-windowstyle", + "-version", + ], + ), + _ => return None, + }; + let powershell = matches!(runtime, "powershell" | "pwsh"); + let mut index = 1; + while let Some(arg) = argv.get(index) { + let lower; + let arg = if powershell { + lower = arg.to_ascii_lowercase(); + lower.as_str() + } else { + arg.as_str() + }; + if arg == "--" || (powershell && matches!(arg, "-file" | "-f" | "/file")) { + return argv.get(index + 1).map(|_| index + 1); + } + if !(arg.starts_with('-') || powershell && arg.starts_with('/')) { + return Some(index); + } + if flags.contains(&arg) { + index += 1; + } else if options.contains(&arg) { + argv.get(index + 1)?; + index += 2; + } else if !powershell + && arg + .split_once('=') + .is_some_and(|(name, value)| options.contains(&name) && !value.is_empty()) { - rest = next; - continue; + index += 1; + } else { + return None; } - return agent_name_from_path_token(token); } None } +fn path_parent_and_basename(path: &str) -> Option<(&str, &str)> { + let split = path.rfind(['/', '\\'])?; + let parent = path[..split].trim_end_matches(['/', '\\']); + let basename = &path[split + 1..]; + (!parent.is_empty() && !basename.is_empty()).then_some((parent, basename)) +} + fn command_text_token(input: &str) -> Option<(&str, &str)> { let input = input.trim_start(); let first = input.chars().next()?; @@ -521,36 +630,6 @@ fn command_text_token(input: &str) -> Option<(&str, &str)> { Some((&input[..end], &input[end..])) } -fn script_arg_agent_name( - argv: &[String], - eval_flags: &[&str], - module_flags: &[&str], -) -> Option { - let mut args = argv.iter().skip(1); - while let Some(arg) = args.next() { - if arg == "--" { - return args - .next() - .and_then(|token| agent_name_from_path_token(token)); - } - - if flag_matches(arg, eval_flags) || flag_matches(arg, module_flags) { - return None; - } - - if arg.starts_with('-') { - if option_takes_value(arg) { - let _ = args.next(); - } - continue; - } - - return agent_name_from_path_token(arg); - } - - None -} - fn flag_matches(arg: &str, flags: &[&str]) -> bool { flags .iter() @@ -587,87 +666,6 @@ fn option_takes_value(arg: &str) -> bool { ) } -fn argv0_agent_name(argv: Option<&[String]>) -> Option { - agent_name_from_path_token(argv?.first()?) -} - -fn cmdline_argv0_agent_name(cmdline: &str) -> Option { - agent_name_from_path_token(cmdline.split_whitespace().next()?) -} - -fn agent_name_from_path_token(token: &str) -> Option { - let trimmed = token.trim_matches(|c| matches!(c, '"' | '\'')); - if trimmed.is_empty() || trimmed.starts_with('-') { - return None; - } - - agent_name_from_basename(path_basename(trimmed)) - .or_else(|| agent_name_from_known_package_path(trimmed)) - .or_else(|| resolved_agent_name_from_path_token(trimmed)) -} - -fn agent_name_from_known_package_path(path: &str) -> Option { - let raw_components: Vec<&str> = path - .split(['/', '\\']) - .filter(|component| !component.is_empty()) - .collect(); - let ends_with = |suffix: &[&str]| { - raw_components.len() >= suffix.len() - && raw_components[raw_components.len() - suffix.len()..] - .iter() - .zip(suffix) - .all(|(actual, expected)| actual.eq_ignore_ascii_case(expected)) - }; - if ends_with(&[ - "node_modules", - "@earendil-works", - "pi-coding-agent", - "dist", - "cli.js", - ]) || ends_with(&[ - "node_modules", - "@earendil-works", - "pi-coding-agent", - "dist", - "bundle", - "cli.js", - ]) { - return Some(agent_label(Agent::Pi).to_string()); - } - - let components: Vec = raw_components - .into_iter() - .map(normalized_agent_lookup_name) - .collect(); - for window in components.windows(5) { - if window == ["node_modules", "@qwen-code", "qwen-code", "dist", "index"] { - return Some(agent_label(Agent::Qwen).to_string()); - } - } - for window in components.windows(4) { - if window == ["node_modules", "mastracode", "dist", "cli"] { - return Some(agent_label(Agent::Mastracode).to_string()); - } - } - None -} - -fn resolved_agent_name_from_path_token(token: &str) -> Option { - let path = std::path::Path::new(token); - if path.components().count() < 2 { - return None; - } - - let resolved = std::fs::canonicalize(path).ok()?; - let basename = resolved.file_name()?.to_str()?; - agent_name_from_basename(basename) -} - -fn agent_name_from_basename(basename: &str) -> Option { - let agent = parse_agent_label(basename)?; - Some(agent_label(agent).to_string()) -} - fn normalized_agent_lookup_name(name: &str) -> String { let mut name = name.trim().to_lowercase(); for suffix in [".exe", ".cmd", ".bat", ".ps1", ".js"] { @@ -745,6 +743,83 @@ mod tests { } } + #[test] + fn resume_args_require_a_structured_agent_entrypoint_not_a_later_incidental_token() { + let registry = crate::agents::registry(); + for argv in [ + vec!["claude", "--model", "model name"], + vec![ + "node", + "--require", + "preload.js", + "/bin/claude", + "--model", + "model name", + ], + vec!["bash", "/bin/claude", "--model", "model name"], + vec![ + "pwsh", + "-NoProfile", + "-File", + "/bin/claude", + "--model", + "model name", + ], + ] { + let process = foreground_process(1, argv[0], &argv); + assert_eq!( + structured_resume_args(®istry, &process, Agent::Claude), + Some(["--model".into(), "model name".into()].as_slice()), + "{argv:?}" + ); + } + for argv in [ + vec!["bash", "-c", "claude --model other"], + vec![ + "bash", + "--rcfile", + "/tmp/claude", + "-c", + ":", + "--model", + "other", + ], + vec![ + "node", + "--unknown-runtime-option", + "/bin/claude", + "--model", + "other", + ], + vec![ + "pwsh", + "-CommandWithArgs", + "/bin/claude", + "--model", + "other", + ], + vec!["cmd", "/C", "claude --model other"], + vec!["pwsh", "-Command", "claude --model other"], + vec!["pwsh", "-EncodedCommand", "encoded", "claude"], + vec!["node", "unrelated.js", "claude", "--model", "other"], + vec!["node", "--eval=claude", "claude", "--model", "other"], + vec!["other", "claude", "--model", "other"], + ] { + let process = foreground_process(1, "claude", &argv); + assert_eq!( + structured_resume_args(®istry, &process, Agent::Claude), + None, + "{argv:?}" + ); + } + let mut process = foreground_process(1, "claude", &["claude", "--model", "other"]); + process.argv = None; + assert_eq!( + structured_resume_args(®istry, &process, Agent::Claude), + None + ); + } + #[cfg(unix)] fn temp_detection_path(name: &str) -> std::path::PathBuf { let unique = format!( @@ -769,6 +844,138 @@ mod tests { // ---- Agent identification ---- + fn novel_process_registry() -> AgentRegistry { + let packages = crate::agents::source::load_packages(&[ + ( + "agents/opencode-lab/agent.toml", + r#"schema = 1 +id = "opencode-lab" +name = "OpenCode Lab" +aliases = ["lab-alias"] +startable = true +[launch] +unix = "opencode" +windows = "opencode.exe" +"#, + ), + ( + "agents/opencode-lab/process.toml", + r#"names = ["opencode"] +secondary_runtime_argv_fallback = true +[[package_paths]] +kind = "exact_suffix" +components = ["node_modules", "opencode-lab", "dist", "cli.js"] +[[package_paths]] +kind = "normalized_components" +components = ["node_modules", "opencode-lab", "dist", "main"] +[bundled_node] +runtime_basename = "node.exe" +entrypoint_basename = "index.js" +package_directory = "opencode-lab" +versions_directory = "versions" +"#, + ), + ]) + .expect("validated novel package"); + AgentRegistry::from_packages(packages).expect("owned registry") + } + + #[test] + fn pinned_process_recognition_uses_novel_process_names_not_identity_aliases() { + let registry = novel_process_registry(); + let agent = Agent::parse("opencode-lab").unwrap(); + for name in ["opencode", "/usr/bin/OpenCode", r"C:\bin\opencode.exe"] { + assert_eq!(identify_agent_with_registry(®istry, name), Some(agent)); + } + for name in ["opencode-lab", "lab-alias", "claude", "opencode-helper"] { + assert_eq!(identify_agent_with_registry(®istry, name), None); + let job = crate::platform::ForegroundJob { + process_group_id: 123, + processes: vec![foreground_process(123, name, &[name])], + }; + assert_eq!(identify_agent_in_job_with_registry(®istry, &job), None); + } + let empty = AgentRegistry::from_packages(Vec::new()).unwrap(); + assert_eq!(identify_agent_with_registry(&empty, "opencode"), None); + assert_eq!( + identify_agent_with_registry(®istry, "opencode"), + Some(agent) + ); + assert!(!registry.has_full_lifecycle_report_authority("herdr:opencode-lab", "opencode-lab")); + assert!( + !registry.is_session_identity_only_integration("herdr:opencode-lab", "opencode-lab") + ); + assert!(registry + .profile_by_id("opencode-lab") + .unwrap() + .integration() + .is_none()); + } + + #[test] + fn pinned_job_recognition_preserves_novel_identity_through_runtime_and_paths() { + let registry = novel_process_registry(); + let recognizer = ProcessRecognizer { + registry: ®istry, + }; + let agent = Agent::parse("opencode-lab").unwrap(); + assert_eq!( + recognizer.agent_name_from_basename("opencode.exe"), + Some("opencode-lab".into()) + ); + for path in [ + "/opt/node_modules/opencode-lab/dist/cli.js", + r"C:\opt\node_modules\opencode-lab\dist\main.js", + ] { + assert_eq!( + recognizer.agent_name_from_known_package_path(path), + Some("opencode-lab".into()) + ); + } + for (name, argv) in [ + ("opencode", vec!["opencode"]), + ("node", vec!["node", "/bin/opencode"]), + ("bun", vec!["bun", "/bin/opencode"]), + ("python3", vec!["python3", "/bin/opencode"]), + ("bash", vec!["bash", "/bin/opencode"]), + ("cmd.exe", vec!["cmd.exe", "/c", "opencode"]), + ("pwsh", vec!["pwsh", "-file", "opencode.ps1"]), + ("MainThread", vec!["node", "/bin/opencode"]), + ( + "node", + vec!["node", "/opt/node_modules/opencode-lab/dist/cli.js"], + ), + ( + "node", + vec!["node", "/opt/node_modules/opencode-lab/dist/main.js"], + ), + ( + "node.exe", + vec![ + "/opt/opencode-lab/versions/1/node.exe", + "/opt/opencode-lab/versions/1/index.js", + ], + ), + ] { + for pid in [123, 124] { + let job = crate::platform::ForegroundJob { + process_group_id: 123, + processes: vec![foreground_process(pid, name, &argv)], + }; + let expected_name = if name == "opencode" { + "opencode" + } else { + "opencode-lab" + }; + assert_eq!( + identify_agent_in_job_with_registry(®istry, &job), + Some((agent, expected_name.to_string())), + "{name} {argv:?} pid={pid}", + ); + } + } + } + #[test] fn identify_known_agents() { assert_eq!(identify_agent("pi"), Some(Agent::Pi)); @@ -850,7 +1057,7 @@ mod tests { #[test] fn every_agent_label_round_trips_through_canonical_and_alias_parsers() { for agent in Agent::ALL { - let label = agent_label(agent); + let label = agent_label(&agent); assert_eq!(parse_canonical_agent_label(label), Some(agent)); assert_eq!(parse_agent_label(label), Some(agent)); } @@ -910,7 +1117,9 @@ mod tests { "herdr:mastracode", "mastracode" )); - assert!(!Agent::SCREEN_MANIFEST_AGENTS.contains(&Agent::Mastracode)); + assert!(!crate::agents::registry() + .screen_detectable_profiles() + .any(|profile| profile.legacy_agent() == Agent::Mastracode)); } #[test] @@ -922,7 +1131,9 @@ mod tests { ] { assert!(!full_lifecycle_hook_authority(source, label)); assert!(session_identity_only_integration(source, label)); - assert!(Agent::SCREEN_MANIFEST_AGENTS.contains(&agent)); + assert!(crate::agents::registry() + .screen_detectable_profiles() + .any(|profile| profile.legacy_agent() == agent)); } } @@ -949,6 +1160,30 @@ mod tests { assert_eq!(identify_agent("Devin"), Some(Agent::Devin)); } + #[test] + fn identify_agent_in_job_preserves_absolute_argv0_recognition() { + for (path, expected) in [ + ("/usr/bin/claude", Agent::Claude), + (r"C:\Users\user\bin\claude.exe", Agent::Claude), + ("/home/user/.local/bin/muse-bin-1.2.3", Agent::Muse), + (r"C:\Users\user\bin\muse-bin-1.2.3.exe", Agent::Muse), + ] { + for pid in [123, 124] { + let mut process = foreground_process(pid, "worker", &[path]); + process.argv0 = Some(path.to_string()); + let job = crate::platform::ForegroundJob { + process_group_id: 123, + processes: vec![process], + }; + assert_eq!( + identify_agent_in_job(&job), + Some((expected, path.to_string())), + "{path} pid={pid}" + ); + } + } + } + #[test] fn identify_agent_in_job_prefers_wrapped_codex() { let job = crate::platform::ForegroundJob { @@ -1444,8 +1679,13 @@ mod tests { #[test] fn wrapped_agent_name_from_runtime_argv_ignores_plain_shell_flags() { + let registry = crate::agents::registry(); + let recognizer = ProcessRecognizer { + registry: ®istry, + }; assert_eq!( - wrapped_agent_name_from_runtime_argv("bash", Some(&["bash".into(), "-lc".into()])), + recognizer + .wrapped_agent_name_from_runtime_argv("bash", Some(&["bash".into(), "-lc".into()])), None ); } @@ -1511,15 +1751,26 @@ mod tests { #[test] fn cmdline_argv0_agent_name_canonicalizes_known_aliases() { + let registry = crate::agents::registry(); + let recognizer = ProcessRecognizer { + registry: ®istry, + }; assert_eq!( - cmdline_argv0_agent_name("/nix/store/example/bin/ghcs"), + recognizer.cmdline_argv0_agent_name("/nix/store/example/bin/ghcs"), Some("copilot".to_string()) ); } #[test] fn cmdline_argv0_agent_name_requires_exact_agent_basename() { - assert_eq!(cmdline_argv0_agent_name("/tmp/my-codex-helper"), None); + let registry = crate::agents::registry(); + let recognizer = ProcessRecognizer { + registry: ®istry, + }; + assert_eq!( + recognizer.cmdline_argv0_agent_name("/tmp/my-codex-helper"), + None + ); } #[cfg(unix)] diff --git a/src/events.rs b/src/events.rs index 956389912a..2c5bfc1839 100644 --- a/src/events.rs +++ b/src/events.rs @@ -55,6 +55,17 @@ pub struct WorktreeRemoveResult { /// An event from a background task to the main loop. #[derive(Debug)] pub enum AppEvent { + /// Process lifetime evidence must survive a registry reload while queued. + AgentResumeProcessBound { + pane_id: PaneId, + binding: Box, + }, + /// A fallback observation evaluated against one immutable registry generation. + /// Dispatchers must reject stale observations before applying or forwarding them. + AgentDetection { + registry_generation: u64, + observation: Box, + }, /// A pane's child process exited. PaneDied { pane_id: PaneId, @@ -73,6 +84,7 @@ pub enum AppEvent { pane_id: PaneId, agent: Option, state: AgentState, + visible_idle: bool, visible_blocker: bool, visible_working: bool, process_exited: bool, @@ -131,12 +143,6 @@ pub enum AppEvent { version: String, install_command: String, }, - /// Remote agent detection manifest update check finished. - AgentDetectionManifestsUpdated { - updated: Vec, - activated: Vec, - status: crate::detect::manifest_update::ManifestUpdateStatus, - }, /// A pane child emitted one or more executable BEL characters. /// The host-facing process forwards them to its outer terminal. TerminalBell { pane_id: PaneId, count: u16 }, @@ -174,3 +180,51 @@ pub enum AppEvent { /// Background `git worktree remove` completed. WorktreeRemoveFinished(Box), } + +impl AppEvent { + pub(crate) fn into_current_detection(self, generation: u64) -> Option { + match self { + Self::AgentDetection { + registry_generation, + observation, + } => (registry_generation == generation).then_some(*observation), + event => Some(event), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stale_detection_observations_are_rejected_but_non_detection_events_are_unchanged() { + let observation = || AppEvent::AgentProcessDetected { + pane_id: PaneId::from_raw(42), + agent: Agent::Pi, + observed_at: Instant::now(), + }; + assert!(AppEvent::AgentDetection { + registry_generation: 1, + observation: Box::new(observation()), + } + .into_current_detection(2) + .is_none()); + assert!(matches!( + AppEvent::AgentDetection { + registry_generation: 2, + observation: Box::new(observation()), + } + .into_current_detection(2), + Some(AppEvent::AgentProcessDetected { .. }) + )); + assert!(matches!( + AppEvent::PaneDied { + pane_id: PaneId::from_raw(42), + exit_reason: crate::platform::ChildExitReason::Exited, + } + .into_current_detection(2), + Some(AppEvent::PaneDied { .. }) + )); + } +} diff --git a/src/integration/actions.rs b/src/integration/actions.rs index e8b3ab6375..11c4bd7f03 100644 --- a/src/integration/actions.rs +++ b/src/integration/actions.rs @@ -1,268 +1,43 @@ use std::io; -use super::registry::{integration_target_label, integration_target_supported}; -use super::targets::{ - install_antigravity_cli, install_claude, install_codex, install_copilot, install_cursor, - install_devin, install_droid, install_grok, install_hermes, install_kilo, install_kimi, - install_mastracode, install_omp, install_opencode, install_pi, install_qodercli, install_qwen, - uninstall_antigravity_cli, uninstall_claude, uninstall_codex, uninstall_copilot, - uninstall_cursor, uninstall_devin, uninstall_droid, uninstall_grok, uninstall_hermes, - uninstall_kilo, uninstall_kimi, uninstall_mastracode, uninstall_omp, uninstall_opencode, - uninstall_pi, uninstall_qodercli, uninstall_qwen, -}; -use super::version::{agent_version_requirement, enforce_agent_version}; -use super::{KIMI_MIN_VERSION, PI_EXTENSION_INSTALL_NAME}; +use super::registry::registered_integration_profile; +use super::version::enforce_agent_version; pub(crate) fn install_target( target: crate::api::schema::IntegrationTarget, ) -> io::Result> { - let result = install_target_inner(target); + let profile = registered_integration_profile(target); + let result = match &profile { + Ok(profile) => install_target_inner(profile), + Err(error) => Err(io::Error::other(error.to_string())), + }; let outcome = if result.is_ok() { "ok" } else { "error" }; - crate::logging::integration_action("install", integration_target_label(target), outcome); + let label = profile + .as_ref() + .map(|profile| profile.cli_label()) + .unwrap_or("unknown"); + crate::logging::integration_action("install", label, outcome); result } -fn install_target_inner(target: crate::api::schema::IntegrationTarget) -> io::Result> { - if !integration_target_supported(target) { +fn install_target_inner( + profile: &crate::agents::integration::IntegrationProfile, +) -> io::Result> { + let adapter = profile.adapter(); + + if !profile.supported() { return Err(io::Error::other(format!( "{} integration is not supported on Windows", - integration_target_label(target) + profile.cli_label() ))); } - let version_warning = match agent_version_requirement(target) { - Some(requirement) => enforce_agent_version(&requirement)?, + let version_warning = match adapter.agent_version_requirement() { + Some(requirement) => enforce_agent_version(requirement)?, None => None, }; - let mut messages = match target { - crate::api::schema::IntegrationTarget::Pi => { - let path = install_pi()?; - vec![format!("installed pi integration to {}", path.display())] - } - crate::api::schema::IntegrationTarget::Omp => { - let installed = install_omp()?; - let mut messages = Vec::new(); - if installed.removed_legacy_pi_extension { - messages.push(format!( - "removed legacy pi integration from omp extension directory at {}", - installed - .extension_path - .with_file_name(PI_EXTENSION_INSTALL_NAME) - .display() - )); - } - messages.push(format!( - "installed omp integration to {}", - installed.extension_path.display() - )); - messages - } - crate::api::schema::IntegrationTarget::Claude => { - let installed = install_claude()?; - vec![ - format!( - "installed claude integration hook to {}", - installed.hook_path.display() - ), - format!( - "ensured claude settings at {}", - installed.settings_path.display() - ), - ] - } - crate::api::schema::IntegrationTarget::Codex => { - let installed = install_codex()?; - vec![ - format!( - "installed codex integration hook to {}", - installed.hook_path.display() - ), - format!("ensured codex hooks at {}", installed.hooks_path.display()), - format!( - "ensured codex config at {}", - installed.config_path.display() - ), - ] - } - crate::api::schema::IntegrationTarget::Copilot => { - let installed = install_copilot()?; - vec![ - format!( - "installed copilot integration hook to {}", - installed.hook_path.display() - ), - format!( - "ensured copilot settings at {}", - installed.settings_path.display() - ), - ] - } - crate::api::schema::IntegrationTarget::Devin => { - let installed = install_devin()?; - vec![ - format!( - "installed devin integration hook to {}", - installed.hook_path.display() - ), - format!( - "ensured devin settings at {}", - installed.settings_path.display() - ), - ] - } - crate::api::schema::IntegrationTarget::Kimi => { - let installed = install_kimi()?; - vec![ - format!( - "installed kimi integration hook to {}", - installed.hook_path.display() - ), - format!("ensured kimi config at {}", installed.config_path.display()), - format!("requires kimi code {KIMI_MIN_VERSION} or newer"), - ] - } - crate::api::schema::IntegrationTarget::Droid => { - let installed = install_droid()?; - let mut messages = vec![ - format!( - "installed droid integration hook to {}", - installed.hook_path.display() - ), - format!( - "ensured droid hooks at {}", - installed.settings_path.display() - ), - ]; - if installed.updated_legacy_hooks { - messages.push(format!( - "removed legacy herdr droid hook entries from {}", - installed.hooks_path.display() - )); - } - messages - } - crate::api::schema::IntegrationTarget::Opencode => { - let installed = install_opencode()?; - let mut messages = vec![ - format!( - "installed opencode integration plugin to {}", - installed.plugin_path.display() - ), - format!( - "installed opencode tui integration plugin to {}", - installed.tui_plugin_path.display() - ), - format!( - "ensured opencode tui plugin config at {}", - installed.tui_config_path.display() - ), - ]; - if installed.cli_config_path.is_none() { - messages.push( - "to enable OpenCode V2, start opencode2 once, then reinstall this integration" - .to_string(), - ); - } - messages - } - crate::api::schema::IntegrationTarget::Kilo => { - let installed = install_kilo()?; - vec![format!( - "installed kilo integration plugin to {}", - installed.plugin_path.display() - )] - } - crate::api::schema::IntegrationTarget::Hermes => { - let installed = install_hermes()?; - vec![ - format!( - "installed hermes integration plugin to {}", - installed.plugin_dir.display() - ), - format!( - "enabled hermes plugin in {}", - installed.config_path.display() - ), - ] - } - crate::api::schema::IntegrationTarget::Qodercli => { - let installed = install_qodercli()?; - vec![ - format!( - "installed qodercli integration hook to {}", - installed.hook_path.display() - ), - format!( - "ensured qodercli settings at {}", - installed.settings_path.display() - ), - ] - } - crate::api::schema::IntegrationTarget::Qwen => { - let installed = install_qwen()?; - vec![ - format!( - "installed qwen integration hook to {}", - installed.hook_path.display() - ), - format!( - "ensured qwen settings at {}", - installed.settings_path.display() - ), - ] - } - crate::api::schema::IntegrationTarget::Cursor => { - let installed = install_cursor()?; - vec![ - format!( - "installed cursor integration hook to {}", - installed.hook_path.display() - ), - format!("updated cursor hooks at {}", installed.hooks_path.display()), - ] - } - crate::api::schema::IntegrationTarget::Mastracode => { - let installed = install_mastracode()?; - vec![ - format!( - "installed mastracode integration hook to {}", - installed.hook_path.display() - ), - format!( - "ensured mastracode hooks at {}", - installed.hooks_path.display() - ), - ] - } - crate::api::schema::IntegrationTarget::AntigravityCli => { - let installed = install_antigravity_cli()?; - vec![ - format!( - "installed antigravity-cli integration hook to {}", - installed.hook_path.display() - ), - format!( - "ensured antigravity-cli hooks at {}", - installed.hooks_path.display() - ), - ] - } - crate::api::schema::IntegrationTarget::Grok => { - let installed = install_grok()?; - vec![ - format!( - "installed grok integration hook to {}", - installed.hook_path.display() - ), - format!( - "registered grok hook config at {}", - installed.config_path.display() - ), - ] - } - }; - + let mut messages = adapter.install(profile)?; if let Some(warning) = version_warning { messages.push(warning); } @@ -273,449 +48,9 @@ fn install_target_inner(target: crate::api::schema::IntegrationTarget) -> io::Re pub(crate) fn uninstall_target( target: crate::api::schema::IntegrationTarget, ) -> io::Result> { - let messages = match target { - crate::api::schema::IntegrationTarget::Pi => { - let result = uninstall_pi()?; - if result.removed_extension { - vec![format!( - "removed pi integration extension at {}", - result.extension_path.display() - )] - } else { - vec![format!( - "no pi integration extension found at {}", - result.extension_path.display() - )] - } - } - crate::api::schema::IntegrationTarget::Omp => { - let result = uninstall_omp()?; - if result.removed_extension { - vec![format!( - "removed omp integration extension at {}", - result.extension_path.display() - )] - } else { - vec![format!( - "no omp integration extension found at {}", - result.extension_path.display() - )] - } - } - crate::api::schema::IntegrationTarget::Claude => { - let result = uninstall_claude()?; - let mut messages = Vec::new(); - if result.removed_hook_file { - messages.push(format!( - "removed claude hook at {}", - result.hook_path.display() - )); - } else { - messages.push(format!( - "no claude hook found at {}", - result.hook_path.display() - )); - } - if result.updated_settings { - messages.push(format!( - "removed herdr claude hook entries from {}", - result.settings_path.display() - )); - } else { - messages.push(format!( - "no herdr claude hook entries found in {}", - result.settings_path.display() - )); - } - messages - } - crate::api::schema::IntegrationTarget::Codex => { - let result = uninstall_codex()?; - let mut messages = Vec::new(); - if result.removed_hook_file { - messages.push(format!( - "removed codex hook at {}", - result.hook_path.display() - )); - } else { - messages.push(format!( - "no codex hook found at {}", - result.hook_path.display() - )); - } - if result.updated_hooks { - messages.push(format!( - "removed herdr codex hook entries from {}", - result.hooks_path.display() - )); - } else { - messages.push(format!( - "no herdr codex hook entries found in {}", - result.hooks_path.display() - )); - } - messages.push(format!( - "left codex config unchanged at {}", - result.config_path.display() - )); - messages - } - crate::api::schema::IntegrationTarget::Copilot => { - let result = uninstall_copilot()?; - let mut messages = Vec::new(); - if result.removed_hook_file { - messages.push(format!( - "removed copilot hook at {}", - result.hook_path.display() - )); - } else { - messages.push(format!( - "no copilot hook found at {}", - result.hook_path.display() - )); - } - if result.updated_settings { - messages.push(format!( - "removed herdr copilot hook entries from {}", - result.settings_path.display() - )); - } else { - messages.push(format!( - "no herdr copilot hook entries found in {}", - result.settings_path.display() - )); - } - messages - } - crate::api::schema::IntegrationTarget::Devin => { - let result = uninstall_devin()?; - let mut messages = Vec::new(); - if result.removed_hook_file { - messages.push(format!( - "removed devin hook at {}", - result.hook_path.display() - )); - } else { - messages.push(format!( - "no devin hook found at {}", - result.hook_path.display() - )); - } - if result.updated_settings { - messages.push(format!( - "removed herdr devin hook entries from {}", - result.settings_path.display() - )); - } else { - messages.push(format!( - "no herdr devin hook entries found in {}", - result.settings_path.display() - )); - } - messages - } - crate::api::schema::IntegrationTarget::Kimi => { - let result = uninstall_kimi()?; - let mut messages = Vec::new(); - if result.removed_hook_file { - messages.push(format!( - "removed kimi hook at {}", - result.hook_path.display() - )); - } else { - messages.push(format!( - "no kimi hook found at {}", - result.hook_path.display() - )); - } - if result.updated_config { - messages.push(format!( - "removed herdr kimi hook entries from {}", - result.config_path.display() - )); - } else { - messages.push(format!( - "no herdr kimi hook entries found in {}", - result.config_path.display() - )); - } - messages - } - crate::api::schema::IntegrationTarget::Droid => { - let result = uninstall_droid()?; - let mut messages = Vec::new(); - if result.removed_hook_file { - messages.push(format!( - "removed droid hook at {}", - result.hook_path.display() - )); - } else { - messages.push(format!( - "no droid hook found at {}", - result.hook_path.display() - )); - } - if result.updated_hooks { - messages.push(format!( - "removed legacy herdr droid hook entries from {}", - result.hooks_path.display() - )); - } else { - messages.push(format!( - "no legacy herdr droid hook entries found in {}", - result.hooks_path.display() - )); - } - if result.updated_settings { - messages.push(format!( - "removed herdr droid hook entries from {}", - result.settings_path.display() - )); - } else { - messages.push(format!( - "no herdr droid hook entries found in {}", - result.settings_path.display() - )); - } - messages - } - crate::api::schema::IntegrationTarget::Opencode => { - let result = uninstall_opencode()?; - let mut messages = vec![if result.removed_plugin { - format!( - "removed opencode integration plugin at {}", - result.plugin_path.display() - ) - } else { - format!( - "no opencode integration plugin found at {}", - result.plugin_path.display() - ) - }]; - messages.push(if result.removed_tui_plugin { - format!( - "removed opencode tui integration plugin at {}", - result.tui_plugin_path.display() - ) - } else { - format!( - "no opencode tui integration plugin found at {}", - result.tui_plugin_path.display() - ) - }); - if result.updated_tui_config { - messages.push(format!( - "removed herdr opencode plugin entry from {}", - result.tui_config_path.display() - )); - } - messages - } - crate::api::schema::IntegrationTarget::Kilo => { - let result = uninstall_kilo()?; - if result.removed_plugin { - vec![format!( - "removed kilo integration plugin at {}", - result.plugin_path.display() - )] - } else { - vec![format!( - "no kilo integration plugin found at {}", - result.plugin_path.display() - )] - } - } - crate::api::schema::IntegrationTarget::Hermes => { - let result = uninstall_hermes()?; - let mut messages = Vec::new(); - if result.removed_plugin_dir { - messages.push(format!( - "removed hermes integration plugin at {}", - result.plugin_dir.display() - )); - } else { - messages.push(format!( - "no hermes integration plugin found at {}", - result.plugin_dir.display() - )); - } - if result.updated_config { - messages.push(format!( - "disabled hermes plugin in {}", - result.config_path.display() - )); - } else { - messages.push(format!( - "no hermes plugin entry found in {}", - result.config_path.display() - )); - } - messages - } - crate::api::schema::IntegrationTarget::Qodercli => { - let result = uninstall_qodercli()?; - let mut messages = Vec::new(); - if result.removed_hook_file { - messages.push(format!( - "removed qodercli hook at {}", - result.hook_path.display() - )); - } else { - messages.push(format!( - "no qodercli hook found at {}", - result.hook_path.display() - )); - } - if result.updated_settings { - messages.push(format!( - "removed herdr qodercli hook entries from {}", - result.settings_path.display() - )); - } else { - messages.push(format!( - "no herdr qodercli hook entries found in {}", - result.settings_path.display() - )); - } - messages - } - crate::api::schema::IntegrationTarget::Qwen => { - let result = uninstall_qwen()?; - let mut messages = Vec::new(); - if result.removed_hook_file { - messages.push(format!( - "removed qwen hook at {}", - result.hook_path.display() - )); - } else { - messages.push(format!( - "no qwen hook found at {}", - result.hook_path.display() - )); - } - if result.updated_settings { - messages.push(format!( - "removed herdr qwen hook entries from {}", - result.settings_path.display() - )); - } else { - messages.push(format!( - "no herdr qwen hook entries found in {}", - result.settings_path.display() - )); - } - messages - } - crate::api::schema::IntegrationTarget::Cursor => { - let result = uninstall_cursor()?; - let mut messages = Vec::new(); - if result.removed_hook_file { - messages.push(format!( - "removed cursor hook at {}", - result.hook_path.display() - )); - } else { - messages.push(format!( - "no cursor hook found at {}", - result.hook_path.display() - )); - } - if result.updated_hooks { - messages.push(format!( - "removed herdr cursor hook entries from {}", - result.hooks_path.display() - )); - } else { - messages.push(format!( - "no herdr cursor hook entries found in {}", - result.hooks_path.display() - )); - } - messages - } - crate::api::schema::IntegrationTarget::Mastracode => { - let result = uninstall_mastracode()?; - let mut messages = Vec::new(); - if result.removed_hook_file { - messages.push(format!( - "removed mastracode hook at {}", - result.hook_path.display() - )); - } else { - messages.push(format!( - "no mastracode hook found at {}", - result.hook_path.display() - )); - } - if result.updated_hooks { - messages.push(format!( - "removed herdr mastracode hook entries from {}", - result.hooks_path.display() - )); - } else { - messages.push(format!( - "no herdr mastracode hook entries found in {}", - result.hooks_path.display() - )); - } - messages - } - crate::api::schema::IntegrationTarget::AntigravityCli => { - let result = uninstall_antigravity_cli()?; - let mut messages = Vec::new(); - if result.removed_hook_file { - messages.push(format!( - "removed antigravity-cli hook at {}", - result.hook_path.display() - )); - } else { - messages.push(format!( - "no antigravity-cli hook found at {}", - result.hook_path.display() - )); - } - if result.updated_hooks { - messages.push(format!( - "removed herdr antigravity-cli hook entries from {}", - result.hooks_path.display() - )); - } else { - messages.push(format!( - "no herdr antigravity-cli hook entries found in {}", - result.hooks_path.display() - )); - } - messages - } - crate::api::schema::IntegrationTarget::Grok => { - let result = uninstall_grok()?; - let mut messages = Vec::new(); - if result.removed_hook_file { - messages.push(format!( - "removed grok hook at {}", - result.hook_path.display() - )); - } else { - messages.push(format!( - "no grok hook found at {}", - result.hook_path.display() - )); - } - if result.removed_config_file { - messages.push(format!( - "removed grok hook config at {}", - result.config_path.display() - )); - } else { - messages.push(format!( - "no grok hook config found at {}", - result.config_path.display() - )); - } - messages - } - }; + let profile = registered_integration_profile(target)?; + let messages = profile.adapter().uninstall()?; - crate::logging::integration_action("uninstall", integration_target_label(target), "ok"); + crate::logging::integration_action("uninstall", profile.cli_label(), "ok"); Ok(messages) } diff --git a/src/integration/assets/herdr-agent-state.test.ts b/src/integration/assets/herdr-agent-state.test.ts index 48cc8708ee..aca5fd13b1 100644 --- a/src/integration/assets/herdr-agent-state.test.ts +++ b/src/integration/assets/herdr-agent-state.test.ts @@ -44,17 +44,17 @@ afterEach(async () => { }); const integrations = [ - { name: "Pi", modulePath: "./pi/herdr-agent-state.ts" }, - { name: "Oh My Pi", modulePath: "./omp/herdr-agent-state.ts" }, + { name: "Pi", modulePath: "../../../vendor/agent-registry/agents/pi/assets/herdr-agent-state.ts" }, + { name: "Oh My Pi", modulePath: "../../../vendor/agent-registry/agents/omp/assets/herdr-agent-state.ts" }, ] as const; const socketPlugins = [ { name: "OpenCode", - modulePath: "./opencode/herdr-agent-state.js", + modulePath: "../../../vendor/agent-registry/agents/opencode/assets/herdr-agent-state.js", sessionID: "opencode-session", }, - { name: "Kilo", modulePath: "./kilo/herdr-agent-state.js", sessionID: "kilo-session" }, + { name: "Kilo", modulePath: "../../../vendor/agent-registry/agents/kilo/assets/herdr-agent-state.js", sessionID: "kilo-session" }, ] as const; function importFresh(modulePath: string) { @@ -152,7 +152,7 @@ test("OpenCode stays disabled without the Herdr socket environment", async () => process.env.HERDR_PANE_ID = "test:p1"; delete process.env.HERDR_SOCKET_PATH; - const { HerdrAgentStatePlugin } = await importFresh("./opencode/herdr-agent-state.js"); + const { HerdrAgentStatePlugin } = await importFresh("../../../vendor/agent-registry/agents/opencode/assets/herdr-agent-state.js"); expect(await HerdrAgentStatePlugin()).toEqual({}); }); @@ -230,7 +230,7 @@ for (const integration of integrations) { } test("OMP accepts POSIX and Windows session paths", async () => { - const { isAbsoluteSessionPath } = await importFresh("./omp/herdr-agent-state.ts"); + const { isAbsoluteSessionPath } = await importFresh("../../../vendor/agent-registry/agents/omp/assets/herdr-agent-state.ts"); expect(isAbsoluteSessionPath("/tmp/omp-session.jsonl")).toBe(true); expect(isAbsoluteSessionPath("C:\\Users\\User\\.omp\\agent\\sessions\\omp-session.jsonl")).toBe( @@ -243,7 +243,7 @@ test("OMP accepts POSIX and Windows session paths", async () => { test("Pi reports a Windows session path", async () => { const requests = await startRecordingServer("pi-windows-session-path"); const { handlers, pi } = createExtensionHarness(); - const { default: install } = await importFresh("./pi/herdr-agent-state.ts"); + const { default: install } = await importFresh("../../../vendor/agent-registry/agents/pi/assets/herdr-agent-state.ts"); install(pi); const sessionPath = "C:\\Users\\User\\.pi\\agent\\sessions\\pi-session.jsonl"; @@ -265,7 +265,7 @@ test("Pi reports a Windows session path", async () => { test("Pi reports idle only after the agent settles", async () => { const requests = await startRecordingServer("pi-settled"); const { handlers, pi } = createExtensionHarness(); - const { default: install } = await importFresh("./pi/herdr-agent-state.ts"); + const { default: install } = await importFresh("../../../vendor/agent-registry/agents/pi/assets/herdr-agent-state.ts"); install(pi); expect(completionHandlers(handlers)).toEqual(["agent_settled"]); @@ -295,7 +295,7 @@ test("Pi reports idle only after the agent settles", async () => { test("Pi ignores RPC sessions even when UI APIs are available", async () => { const requests = await startRecordingServer("pi-rpc"); const { handlers, pi } = createExtensionHarness(); - const { default: install } = await importFresh("./pi/herdr-agent-state.ts"); + const { default: install } = await importFresh("../../../vendor/agent-registry/agents/pi/assets/herdr-agent-state.ts"); install(pi); const context = { @@ -314,7 +314,7 @@ test("Pi ignores RPC sessions even when UI APIs are available", async () => { test("Pi settlement preserves explicit blocked-state precedence", async () => { const requests = await startRecordingServer("pi-settled-blocked"); const { eventHandlers, handlers, pi } = createExtensionHarness(); - const { default: install } = await importFresh("./pi/herdr-agent-state.ts"); + const { default: install } = await importFresh("../../../vendor/agent-registry/agents/pi/assets/herdr-agent-state.ts"); install(pi); let idle = true; @@ -341,7 +341,7 @@ test("Pi reports the session replacement source", async () => { const requests = await startRecordingServer("pi-session-source"); const { handlers, pi } = createExtensionHarness(); - const { default: install } = await importFresh("./pi/herdr-agent-state.ts"); + const { default: install } = await importFresh("../../../vendor/agent-registry/agents/pi/assets/herdr-agent-state.ts"); install(pi); const sessionStart = handlers.get("session_start"); @@ -405,7 +405,7 @@ test("Pi waits for a replacement session report before publishing state", async configureIntegrationEnvironment(recordingSocketPath); const { handlers, pi } = createExtensionHarness(); - const { default: install } = await importFresh("./pi/herdr-agent-state.ts"); + const { default: install } = await importFresh("../../../vendor/agent-registry/agents/pi/assets/herdr-agent-state.ts"); install(pi); const sessionStart = handlers.get("session_start"); @@ -495,7 +495,7 @@ test("Oh My Pi retries working before a queued idle state", async () => { process.env.HERDR_OMP_IDLE_DEBOUNCE_MS = "0"; const { handlers, pi } = createExtensionHarness(); - const { default: install } = await importFresh("./omp/herdr-agent-state.ts"); + const { default: install } = await importFresh("../../../vendor/agent-registry/agents/omp/assets/herdr-agent-state.ts"); install(pi); const context = { @@ -525,7 +525,7 @@ test("Oh My Pi keeps working when a turn ends with a scheduled continuation", as process.env.HERDR_OMP_IDLE_DEBOUNCE_MS = "0"; const { handlers, pi } = createExtensionHarness(); - const { default: install } = await importFresh("./omp/herdr-agent-state.ts"); + const { default: install } = await importFresh("../../../vendor/agent-registry/agents/omp/assets/herdr-agent-state.ts"); install(pi); let idle = true; @@ -564,7 +564,7 @@ test("Pi retries working state after an unanswered socket attempt", async () => await startDroppedFirstResponseServer("pi-retry"); const { handlers, pi } = createExtensionHarness(); - const { default: install } = await importFresh("./pi/herdr-agent-state.ts"); + const { default: install } = await importFresh("../../../vendor/agent-registry/agents/pi/assets/herdr-agent-state.ts"); install(pi); const sessionStart = handlers.get("session_start"); diff --git a/src/integration/assets/opencode/herdr-agent-state.test.ts b/src/integration/assets/opencode-agent-state.test.ts similarity index 96% rename from src/integration/assets/opencode/herdr-agent-state.test.ts rename to src/integration/assets/opencode-agent-state.test.ts index 38c1f05c89..d6990529a2 100644 --- a/src/integration/assets/opencode/herdr-agent-state.test.ts +++ b/src/integration/assets/opencode-agent-state.test.ts @@ -50,7 +50,7 @@ beforeEach(() => { async function loadPlugin() { importCounter += 1; - const { HerdrAgentStatePlugin } = await import(`./herdr-agent-state.js?test=${importCounter}`); + const { HerdrAgentStatePlugin } = await import(`../../../vendor/agent-registry/agents/opencode/assets/herdr-agent-state.js?test=${importCounter}`); return HerdrAgentStatePlugin(); } @@ -228,7 +228,7 @@ function requestMethod(request: unknown): unknown { } test("dual server entrypoint keeps V1 hooks and never reports from the V2 shared server", async () => { - const module = await import(`./herdr-agent-state.js?test=${++importCounter}`); + const module = await import(`../../../vendor/agent-registry/agents/opencode/assets/herdr-agent-state.js?test=${++importCounter}`); expect(module.default.server).toBe(module.HerdrAgentStatePlugin); expect(await module.default.setup({})).toBeUndefined(); expect(requests).toHaveLength(0); diff --git a/src/integration/assets/opencode/herdr-tui-session.test.ts b/src/integration/assets/opencode-tui-session.test.ts similarity index 99% rename from src/integration/assets/opencode/herdr-tui-session.test.ts rename to src/integration/assets/opencode-tui-session.test.ts index dd03152737..40ec96511f 100644 --- a/src/integration/assets/opencode/herdr-tui-session.test.ts +++ b/src/integration/assets/opencode-tui-session.test.ts @@ -64,7 +64,7 @@ afterEach(() => { async function loadPlugin() { importCounter += 1; - const module = await import(`./herdr-tui-session.js?test=${importCounter}`); + const module = await import(`../../../vendor/agent-registry/agents/opencode/assets/herdr-tui-session.js?test=${importCounter}`); return module.default; } diff --git a/src/integration/builtin/agy.rs b/src/integration/builtin/agy.rs new file mode 100644 index 0000000000..f3b2b966ec --- /dev/null +++ b/src/integration/builtin/agy.rs @@ -0,0 +1,87 @@ +use std::io; +use std::path::PathBuf; + +use crate::agents::integration::{IntegrationAdapter, IntegrationProfile}; +use crate::integration::antigravity_cli_dir; +use crate::integration::{install_antigravity_cli, uninstall_antigravity_cli}; + +pub(super) const HOOK_INSTALL_NAME_UNIX: &str = "herdr-agent-state.sh"; +pub(super) const HOOK_INSTALL_NAME_WINDOWS: &str = "herdr-agent-state.ps1"; +pub(crate) const HOOK_INSTALL_NAME: &str = if cfg!(windows) { + HOOK_INSTALL_NAME_WINDOWS +} else { + HOOK_INSTALL_NAME_UNIX +}; +#[cfg(all(test, windows))] +pub(crate) const HOOK_ASSET: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/vendor/agent-registry/agents/agy/assets/herdr-agent-state.ps1" +)); +#[cfg(all(test, not(windows)))] +pub(crate) const HOOK_ASSET: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/vendor/agent-registry/agents/agy/assets/herdr-agent-state.sh" +)); +/// Antigravity CLI keys `hooks.json` by hook name, so every Herdr entry lives +/// under one Herdr-owned block that install rewrites and uninstall removes. +pub(crate) const HOOK_BLOCK_NAME: &str = "herdr"; +pub(crate) const HOOK_TIMEOUT_SEC: u64 = 10; +/// `(event, reported action)`. Session-only: `PreInvocation` is the only event +/// we need because it carries `conversationId`. The others cannot express +/// lifecycle safely — Antigravity CLI has no blocked event, `PostInvocation` is +/// skipped on interruption, and `Stop` is end-of-turn rather than process exit. +/// Screen detection owns agent state instead. +/// +/// `PreInvocation` takes a flat handler list; only the `PreToolUse`/`PostToolUse` +/// events accept a `matcher`/`hooks` wrapper, and sending one here would +/// invalidate the whole file. +pub(crate) const HOOK_EVENTS: [(&str, &str); 1] = [("PreInvocation", "session")]; + +pub(super) const ADAPTER: IntegrationAdapter = + IntegrationAdapter::new(install_adapter, uninstall_adapter, integration_path); + +fn install_adapter(profile: &IntegrationProfile) -> io::Result> { + let installed = install_antigravity_cli(profile)?; + Ok(vec![ + format!( + "installed antigravity-cli integration hook to {}", + installed.hook_path.display() + ), + format!( + "ensured antigravity-cli hooks at {}", + installed.hooks_path.display() + ), + ]) +} + +fn uninstall_adapter() -> io::Result> { + let result = uninstall_antigravity_cli()?; + let mut messages = Vec::new(); + if result.removed_hook_file { + messages.push(format!( + "removed antigravity-cli hook at {}", + result.hook_path.display() + )); + } else { + messages.push(format!( + "no antigravity-cli hook found at {}", + result.hook_path.display() + )); + } + if result.updated_hooks { + messages.push(format!( + "removed herdr antigravity-cli hook entries from {}", + result.hooks_path.display() + )); + } else { + messages.push(format!( + "no herdr antigravity-cli hook entries found in {}", + result.hooks_path.display() + )); + } + Ok(messages) +} + +fn integration_path() -> io::Result { + antigravity_cli_dir().map(|dir| dir.join("hooks").join(HOOK_INSTALL_NAME)) +} diff --git a/src/integration/builtin/claude.rs b/src/integration/builtin/claude.rs new file mode 100644 index 0000000000..4b8d7db35a --- /dev/null +++ b/src/integration/builtin/claude.rs @@ -0,0 +1,75 @@ +use std::io; +use std::path::PathBuf; + +use crate::agents::integration::{IntegrationAdapter, IntegrationProfile}; +use crate::integration::claude_dir; +use crate::integration::{install_claude, uninstall_claude}; + +pub(super) const HOOK_INSTALL_NAME_UNIX: &str = "herdr-agent-state.sh"; +pub(super) const HOOK_INSTALL_NAME_WINDOWS: &str = "herdr-agent-state.ps1"; +pub(crate) const HOOK_INSTALL_NAME: &str = if cfg!(windows) { + HOOK_INSTALL_NAME_WINDOWS +} else { + HOOK_INSTALL_NAME_UNIX +}; +#[cfg(test)] +pub(crate) const HOOK_ASSET: &str = if cfg!(windows) { + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/vendor/agent-registry/agents/claude/assets/herdr-agent-state.ps1" + )) +} else { + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/vendor/agent-registry/agents/claude/assets/herdr-agent-state.sh" + )) +}; + +pub(super) const ADAPTER: IntegrationAdapter = + IntegrationAdapter::new(install_adapter, uninstall_adapter, integration_path); + +fn install_adapter(profile: &IntegrationProfile) -> io::Result> { + let installed = install_claude(profile)?; + Ok(vec![ + format!( + "installed claude integration hook to {}", + installed.hook_path.display() + ), + format!( + "ensured claude settings at {}", + installed.settings_path.display() + ), + ]) +} + +fn uninstall_adapter() -> io::Result> { + let result = uninstall_claude()?; + let mut messages = Vec::new(); + if result.removed_hook_file { + messages.push(format!( + "removed claude hook at {}", + result.hook_path.display() + )); + } else { + messages.push(format!( + "no claude hook found at {}", + result.hook_path.display() + )); + } + if result.updated_settings { + messages.push(format!( + "removed herdr claude hook entries from {}", + result.settings_path.display() + )); + } else { + messages.push(format!( + "no herdr claude hook entries found in {}", + result.settings_path.display() + )); + } + Ok(messages) +} + +fn integration_path() -> io::Result { + claude_dir().map(|dir| dir.join("hooks").join(HOOK_INSTALL_NAME)) +} diff --git a/src/integration/builtin/codex.rs b/src/integration/builtin/codex.rs new file mode 100644 index 0000000000..6883e121cd --- /dev/null +++ b/src/integration/builtin/codex.rs @@ -0,0 +1,106 @@ +use std::fs; +use std::io; +use std::path::PathBuf; + +use crate::agents::integration::{IntegrationAdapter, IntegrationProfile}; +use crate::integration::codex_dir; +use crate::integration::executable_file_exists; +use crate::integration::{install_codex, uninstall_codex}; + +pub(super) const HOOK_INSTALL_NAME_UNIX: &str = "herdr-agent-state.sh"; +pub(super) const HOOK_INSTALL_NAME_WINDOWS: &str = "herdr-agent-state.ps1"; +pub(crate) const HOOK_INSTALL_NAME: &str = if cfg!(windows) { + HOOK_INSTALL_NAME_WINDOWS +} else { + HOOK_INSTALL_NAME_UNIX +}; +#[cfg(test)] +pub(crate) const HOOK_ASSET: &str = if cfg!(windows) { + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/vendor/agent-registry/agents/codex/assets/herdr-agent-state.ps1" + )) +} else { + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/vendor/agent-registry/agents/codex/assets/herdr-agent-state.sh" + )) +}; + +pub(super) const ADAPTER: IntegrationAdapter = + IntegrationAdapter::new(install_adapter, uninstall_adapter, integration_path) + .with_install_layout_probe(standalone_binary_available); + +fn install_adapter(profile: &IntegrationProfile) -> io::Result> { + let installed = install_codex(profile)?; + Ok(vec![ + format!( + "installed codex integration hook to {}", + installed.hook_path.display() + ), + format!("ensured codex hooks at {}", installed.hooks_path.display()), + format!( + "ensured codex config at {}", + installed.config_path.display() + ), + ]) +} + +fn uninstall_adapter() -> io::Result> { + let result = uninstall_codex()?; + let mut messages = Vec::new(); + if result.removed_hook_file { + messages.push(format!( + "removed codex hook at {}", + result.hook_path.display() + )); + } else { + messages.push(format!( + "no codex hook found at {}", + result.hook_path.display() + )); + } + if result.updated_hooks { + messages.push(format!( + "removed herdr codex hook entries from {}", + result.hooks_path.display() + )); + } else { + messages.push(format!( + "no herdr codex hook entries found in {}", + result.hooks_path.display() + )); + } + messages.push(format!( + "left codex config unchanged at {}", + result.config_path.display() + )); + Ok(messages) +} + +fn integration_path() -> io::Result { + codex_dir().map(|dir| dir.join(HOOK_INSTALL_NAME)) +} + +fn standalone_binary_available() -> bool { + let Ok(releases_dir) = + codex_dir().map(|dir| dir.join("packages").join("standalone").join("releases")) + else { + return false; + }; + let Ok(entries) = fs::read_dir(releases_dir) else { + return false; + }; + + entries + .filter_map(Result::ok) + .any(|entry| executable_file_exists(&entry.path().join("bin").join(executable_name()))) +} + +pub(crate) fn executable_name() -> &'static str { + if cfg!(windows) { + "codex.exe" + } else { + "codex" + } +} diff --git a/src/integration/builtin/contract.rs b/src/integration/builtin/contract.rs new file mode 100644 index 0000000000..fb54ae745e --- /dev/null +++ b/src/integration/builtin/contract.rs @@ -0,0 +1,171 @@ +//! Compatibility boundary: metadata cannot redirect fixed installers. +//! Only installer assumptions live here, not CLI metadata, versions, or asset bytes. + +use crate::agents::source::Package; + +use super::{ + agy, claude, codex, copilot, cursor, devin, droid, grok, hermes, kilo, kimi, mastracode, omp, + opencode, pi, qodercli, qwen, +}; + +#[derive(Clone, Copy)] +struct AssetContract { + path: &'static str, + install_name: &'static str, + platform: &'static str, + role: &'static str, +} + +impl AssetContract { + const fn new( + path: &'static str, + install_name: &'static str, + platform: &'static str, + role: &'static str, + ) -> Self { + Self { + path, + install_name, + platform, + role, + } + } +} + +// Source paths and installed names stay fixed while versioned contents can update. +fn shell_hook( + unix_install_name: &'static str, + windows_install_name: &'static str, + session: bool, +) -> Vec { + let (unix_path, windows_path) = if session { + ( + "assets/herdr-agent-session.sh", + "assets/herdr-agent-session.ps1", + ) + } else { + ( + "assets/herdr-agent-state.sh", + "assets/herdr-agent-state.ps1", + ) + }; + vec![ + AssetContract::new(unix_path, unix_install_name, "unix", "reporter"), + AssetContract::new(windows_path, windows_install_name, "windows", "reporter"), + ] +} + +/// Validate both platforms without reading files, comparing bytes, or running probes. +/// Unknown packages remain inert; source validation owns their structural validity. +pub(crate) fn validate_package(package: &Package) -> Result<(), String> { + let Some(integration) = &package.integration else { + return Ok(()); + }; + macro_rules! hook { + ($module:ident) => { + shell_hook( + $module::HOOK_INSTALL_NAME_UNIX, + $module::HOOK_INSTALL_NAME_WINDOWS, + false, + ) + }; + } + let expected = match package.identity.id.as_str() { + "agy" => hook!(agy), + "claude" => hook!(claude), + "codex" => hook!(codex), + "copilot" => hook!(copilot), + "cursor" => hook!(cursor), + "devin" => hook!(devin), + "droid" => hook!(droid), + "grok" => hook!(grok), + "kimi" => hook!(kimi), + "mastracode" => hook!(mastracode), + "qodercli" => hook!(qodercli), + "qwen" => shell_hook( + qwen::HOOK_INSTALL_NAME_UNIX, + qwen::HOOK_INSTALL_NAME_WINDOWS, + true, + ), + "pi" => vec![AssetContract::new( + "assets/herdr-agent-state.ts", + pi::EXTENSION_INSTALL_NAME, + "all", + "reporter", + )], + "omp" => vec![AssetContract::new( + "assets/herdr-agent-state.ts", + omp::EXTENSION_INSTALL_NAME, + "all", + "reporter", + )], + "kilo" => vec![AssetContract::new( + "assets/herdr-agent-state.js", + kilo::PLUGIN_INSTALL_NAME, + "all", + "reporter", + )], + "opencode" => vec![ + AssetContract::new( + "assets/herdr-agent-state.js", + opencode::PLUGIN_INSTALL_NAME, + "all", + "reporter", + ), + AssetContract::new( + "assets/herdr-tui-session.js", + opencode::TUI_PLUGIN_INSTALL_NAME, + "all", + "tui", + ), + ], + "hermes" => vec![ + AssetContract::new( + "assets/__init__.py", + hermes::PLUGIN_INIT_INSTALL_NAME, + "all", + "reporter", + ), + AssetContract::new( + "assets/plugin.yaml", + hermes::PLUGIN_MANIFEST_INSTALL_NAME, + "all", + "manifest", + ), + ], + _ => return Ok(()), + }; + let error = |detail: &str| { + format!( + "{}: built-in integration asset contract mismatch: {detail}", + package.identity.id + ) + }; + if !integration.supported.unix || !integration.supported.windows { + return Err(error("fixed assets require both unix and windows support")); + } + if integration.assets.len() != expected.len() { + return Err(error("unexpected asset count")); + } + // Exact coverage (rather than zip/order comparison) rejects missing, extra, + // duplicate, and remapped assets while allowing metadata entry reordering. + for contract in expected { + let count = integration + .assets + .iter() + .filter(|asset| { + asset.path == contract.path + && asset.install_name == contract.install_name + && asset.platform == contract.platform + && asset.role == contract.role + }) + .count(); + if count != 1 { + return Err(error(&format!( + "expected {} installed as {} with role {} on {}", + contract.path, contract.install_name, contract.role, contract.platform + ))); + } + } + Ok(()) +} diff --git a/src/integration/builtin/copilot.rs b/src/integration/builtin/copilot.rs new file mode 100644 index 0000000000..6e1fa36915 --- /dev/null +++ b/src/integration/builtin/copilot.rs @@ -0,0 +1,87 @@ +use std::io; +use std::path::PathBuf; + +use crate::agents::integration::{IntegrationAdapter, IntegrationProfile}; +use crate::integration::copilot_dir; +use crate::integration::{install_copilot, uninstall_copilot}; + +pub(super) const HOOK_INSTALL_NAME_UNIX: &str = "herdr-agent-state.sh"; +pub(super) const HOOK_INSTALL_NAME_WINDOWS: &str = "herdr-agent-state.ps1"; +pub(crate) const HOOK_INSTALL_NAME: &str = if cfg!(windows) { + HOOK_INSTALL_NAME_WINDOWS +} else { + HOOK_INSTALL_NAME_UNIX +}; +#[cfg(test)] +pub(crate) const HOOK_ASSET: &str = if cfg!(windows) { + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/vendor/agent-registry/agents/copilot/assets/herdr-agent-state.ps1" + )) +} else { + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/vendor/agent-registry/agents/copilot/assets/herdr-agent-state.sh" + )) +}; +pub(crate) const HOOK_EVENTS: [&str; 1] = ["SessionStart"]; +pub(crate) const REMOVED_LIFECYCLE_HOOK_EVENTS: [&str; 9] = [ + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "Stop", + "agentStop", + "SessionEnd", + "notification", + "sessionStart", +]; + +pub(super) const ADAPTER: IntegrationAdapter = + IntegrationAdapter::new(install_adapter, uninstall_adapter, integration_path); + +fn install_adapter(profile: &IntegrationProfile) -> io::Result> { + let installed = install_copilot(profile)?; + Ok(vec![ + format!( + "installed copilot integration hook to {}", + installed.hook_path.display() + ), + format!( + "ensured copilot settings at {}", + installed.settings_path.display() + ), + ]) +} + +fn uninstall_adapter() -> io::Result> { + let result = uninstall_copilot()?; + let mut messages = Vec::new(); + if result.removed_hook_file { + messages.push(format!( + "removed copilot hook at {}", + result.hook_path.display() + )); + } else { + messages.push(format!( + "no copilot hook found at {}", + result.hook_path.display() + )); + } + if result.updated_settings { + messages.push(format!( + "removed herdr copilot hook entries from {}", + result.settings_path.display() + )); + } else { + messages.push(format!( + "no herdr copilot hook entries found in {}", + result.settings_path.display() + )); + } + Ok(messages) +} + +fn integration_path() -> io::Result { + copilot_dir().map(|dir| dir.join("hooks").join(HOOK_INSTALL_NAME)) +} diff --git a/src/integration/builtin/cursor.rs b/src/integration/builtin/cursor.rs new file mode 100644 index 0000000000..41284fb619 --- /dev/null +++ b/src/integration/builtin/cursor.rs @@ -0,0 +1,72 @@ +use std::io; +use std::path::PathBuf; + +use crate::agents::integration::{IntegrationAdapter, IntegrationProfile}; +use crate::integration::cursor_dir; +use crate::integration::{install_cursor, uninstall_cursor}; + +pub(super) const HOOK_INSTALL_NAME_UNIX: &str = "herdr-agent-state.sh"; +pub(super) const HOOK_INSTALL_NAME_WINDOWS: &str = "herdr-agent-state.ps1"; +pub(crate) const HOOK_INSTALL_NAME: &str = if cfg!(windows) { + HOOK_INSTALL_NAME_WINDOWS +} else { + HOOK_INSTALL_NAME_UNIX +}; +#[cfg(test)] +pub(crate) const HOOK_ASSET: &str = if cfg!(windows) { + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/vendor/agent-registry/agents/cursor/assets/herdr-agent-state.ps1" + )) +} else { + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/vendor/agent-registry/agents/cursor/assets/herdr-agent-state.sh" + )) +}; + +pub(super) const ADAPTER: IntegrationAdapter = + IntegrationAdapter::new(install_adapter, uninstall_adapter, integration_path); + +fn install_adapter(profile: &IntegrationProfile) -> io::Result> { + let installed = install_cursor(profile)?; + Ok(vec![ + format!( + "installed cursor integration hook to {}", + installed.hook_path.display() + ), + format!("updated cursor hooks at {}", installed.hooks_path.display()), + ]) +} + +fn uninstall_adapter() -> io::Result> { + let result = uninstall_cursor()?; + let mut messages = Vec::new(); + if result.removed_hook_file { + messages.push(format!( + "removed cursor hook at {}", + result.hook_path.display() + )); + } else { + messages.push(format!( + "no cursor hook found at {}", + result.hook_path.display() + )); + } + if result.updated_hooks { + messages.push(format!( + "removed herdr cursor hook entries from {}", + result.hooks_path.display() + )); + } else { + messages.push(format!( + "no herdr cursor hook entries found in {}", + result.hooks_path.display() + )); + } + Ok(messages) +} + +fn integration_path() -> io::Result { + cursor_dir().map(|dir| dir.join(HOOK_INSTALL_NAME)) +} diff --git a/src/integration/builtin/devin.rs b/src/integration/builtin/devin.rs new file mode 100644 index 0000000000..1f93833595 --- /dev/null +++ b/src/integration/builtin/devin.rs @@ -0,0 +1,91 @@ +use std::io; +use std::path::PathBuf; + +use crate::agents::integration::{IntegrationAdapter, IntegrationProfile}; +use crate::integration::devin_dir; +use crate::integration::{install_devin, uninstall_devin}; + +pub(super) const HOOK_INSTALL_NAME_UNIX: &str = "herdr-agent-state.sh"; +pub(super) const HOOK_INSTALL_NAME_WINDOWS: &str = "herdr-agent-state.ps1"; +pub(crate) const HOOK_INSTALL_NAME: &str = if cfg!(windows) { + HOOK_INSTALL_NAME_WINDOWS +} else { + HOOK_INSTALL_NAME_UNIX +}; +#[cfg(test)] +pub(crate) const HOOK_ASSET: &str = if cfg!(windows) { + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/vendor/agent-registry/agents/devin/assets/herdr-agent-state.ps1" + )) +} else { + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/vendor/agent-registry/agents/devin/assets/herdr-agent-state.sh" + )) +}; +pub(crate) const HOOK_EVENTS: [(&str, &str); 6] = [ + ("SessionStart", "session"), + ("UserPromptSubmit", "session"), + ("PreToolUse", "session"), + ("PostToolUse", "session"), + ("PermissionRequest", "session"), + ("Stop", "session"), +]; +pub(crate) const REMOVED_LIFECYCLE_HOOK_EVENTS: [(&str, &str); 6] = [ + ("UserPromptSubmit", "working"), + ("PreToolUse", "working"), + ("PostToolUse", "working"), + ("PermissionRequest", "blocked"), + ("Stop", "idle"), + ("SessionEnd", "release"), +]; + +pub(super) const ADAPTER: IntegrationAdapter = + IntegrationAdapter::new(install_adapter, uninstall_adapter, integration_path); + +fn install_adapter(profile: &IntegrationProfile) -> io::Result> { + let installed = install_devin(profile)?; + Ok(vec![ + format!( + "installed devin integration hook to {}", + installed.hook_path.display() + ), + format!( + "ensured devin settings at {}", + installed.settings_path.display() + ), + ]) +} + +fn uninstall_adapter() -> io::Result> { + let result = uninstall_devin()?; + let mut messages = Vec::new(); + if result.removed_hook_file { + messages.push(format!( + "removed devin hook at {}", + result.hook_path.display() + )); + } else { + messages.push(format!( + "no devin hook found at {}", + result.hook_path.display() + )); + } + if result.updated_settings { + messages.push(format!( + "removed herdr devin hook entries from {}", + result.settings_path.display() + )); + } else { + messages.push(format!( + "no herdr devin hook entries found in {}", + result.settings_path.display() + )); + } + Ok(messages) +} + +fn integration_path() -> io::Result { + devin_dir().map(|dir| dir.join(HOOK_INSTALL_NAME)) +} diff --git a/src/integration/builtin/droid.rs b/src/integration/builtin/droid.rs new file mode 100644 index 0000000000..a8c056a85e --- /dev/null +++ b/src/integration/builtin/droid.rs @@ -0,0 +1,105 @@ +use std::io; +use std::path::PathBuf; + +use crate::agents::integration::{IntegrationAdapter, IntegrationProfile}; +use crate::integration::droid_dir; +use crate::integration::{install_droid, uninstall_droid}; + +pub(super) const HOOK_INSTALL_NAME_UNIX: &str = "herdr-agent-state.sh"; +pub(super) const HOOK_INSTALL_NAME_WINDOWS: &str = "herdr-agent-state.ps1"; +pub(crate) const HOOK_INSTALL_NAME: &str = if cfg!(windows) { + HOOK_INSTALL_NAME_WINDOWS +} else { + HOOK_INSTALL_NAME_UNIX +}; +#[cfg(test)] +pub(crate) const HOOK_ASSET: &str = if cfg!(windows) { + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/vendor/agent-registry/agents/droid/assets/herdr-agent-state.ps1" + )) +} else { + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/vendor/agent-registry/agents/droid/assets/herdr-agent-state.sh" + )) +}; +pub(crate) const HOOK_EVENTS: [(&str, &str); 1] = [("SessionStart", "session")]; +pub(crate) const REMOVED_LIFECYCLE_HOOK_EVENTS: [(&str, &str); 9] = [ + ("SessionStart", "idle"), + ("UserPromptSubmit", "working"), + ("PreToolUse", "working"), + ("PostToolUse", "working"), + ("Notification", "blocked"), + ("Stop", "idle"), + ("SubagentStop", "working"), + ("PreCompact", "working"), + ("SessionEnd", "release"), +]; + +pub(super) const ADAPTER: IntegrationAdapter = + IntegrationAdapter::new(install_adapter, uninstall_adapter, integration_path); + +fn install_adapter(profile: &IntegrationProfile) -> io::Result> { + let installed = install_droid(profile)?; + let mut messages = vec![ + format!( + "installed droid integration hook to {}", + installed.hook_path.display() + ), + format!( + "ensured droid hooks at {}", + installed.settings_path.display() + ), + ]; + if installed.updated_legacy_hooks { + messages.push(format!( + "removed legacy herdr droid hook entries from {}", + installed.hooks_path.display() + )); + } + Ok(messages) +} + +fn uninstall_adapter() -> io::Result> { + let result = uninstall_droid()?; + let mut messages = Vec::new(); + if result.removed_hook_file { + messages.push(format!( + "removed droid hook at {}", + result.hook_path.display() + )); + } else { + messages.push(format!( + "no droid hook found at {}", + result.hook_path.display() + )); + } + if result.updated_hooks { + messages.push(format!( + "removed legacy herdr droid hook entries from {}", + result.hooks_path.display() + )); + } else { + messages.push(format!( + "no legacy herdr droid hook entries found in {}", + result.hooks_path.display() + )); + } + if result.updated_settings { + messages.push(format!( + "removed herdr droid hook entries from {}", + result.settings_path.display() + )); + } else { + messages.push(format!( + "no herdr droid hook entries found in {}", + result.settings_path.display() + )); + } + Ok(messages) +} + +fn integration_path() -> io::Result { + droid_dir().map(|dir| dir.join("hooks").join(HOOK_INSTALL_NAME)) +} diff --git a/src/integration/builtin/grok.rs b/src/integration/builtin/grok.rs new file mode 100644 index 0000000000..ad0920e78d --- /dev/null +++ b/src/integration/builtin/grok.rs @@ -0,0 +1,89 @@ +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +use crate::agents::integration::{IntegrationAdapter, IntegrationProfile}; +use crate::integration::grok_dir; +use crate::integration::{grok_hook_config, install_grok, uninstall_grok}; + +pub(super) const HOOK_INSTALL_NAME_UNIX: &str = "herdr-agent-state.sh"; +pub(super) const HOOK_INSTALL_NAME_WINDOWS: &str = "herdr-agent-state.ps1"; +pub(crate) const HOOK_INSTALL_NAME: &str = if cfg!(windows) { + HOOK_INSTALL_NAME_WINDOWS +} else { + HOOK_INSTALL_NAME_UNIX +}; +pub(crate) const HOOK_CONFIG_INSTALL_NAME: &str = "herdr.json"; +#[cfg(test)] +pub(crate) const HOOK_ASSET: &str = if cfg!(windows) { + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/vendor/agent-registry/agents/grok/assets/herdr-agent-state.ps1" + )) +} else { + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/vendor/agent-registry/agents/grok/assets/herdr-agent-state.sh" + )) +}; + +pub(super) const ADAPTER: IntegrationAdapter = + IntegrationAdapter::new(install_adapter, uninstall_adapter, integration_path) + .with_current_install_extra_validator(hook_config_is_valid); + +fn install_adapter(profile: &IntegrationProfile) -> io::Result> { + let installed = install_grok(profile)?; + Ok(vec![ + format!( + "installed grok integration hook to {}", + installed.hook_path.display() + ), + format!( + "registered grok hook config at {}", + installed.config_path.display() + ), + ]) +} + +fn uninstall_adapter() -> io::Result> { + let result = uninstall_grok()?; + let mut messages = Vec::new(); + if result.removed_hook_file { + messages.push(format!( + "removed grok hook at {}", + result.hook_path.display() + )); + } else { + messages.push(format!( + "no grok hook found at {}", + result.hook_path.display() + )); + } + if result.removed_config_file { + messages.push(format!( + "removed grok hook config at {}", + result.config_path.display() + )); + } else { + messages.push(format!( + "no grok hook config found at {}", + result.config_path.display() + )); + } + Ok(messages) +} + +fn integration_path() -> io::Result { + grok_dir().map(|dir| dir.join("hooks").join(HOOK_INSTALL_NAME)) +} + +fn hook_config_is_valid(hook_path: &Path, _expected_version: u32) -> bool { + let Some(hooks_dir) = hook_path.parent() else { + return false; + }; + let config_path = hooks_dir.join(HOOK_CONFIG_INSTALL_NAME); + fs::read_to_string(config_path) + .ok() + .and_then(|content| serde_json::from_str::(&content).ok()) + .is_some_and(|config| config == grok_hook_config(hook_path)) +} diff --git a/src/integration/builtin/hermes.rs b/src/integration/builtin/hermes.rs new file mode 100644 index 0000000000..b26312923e --- /dev/null +++ b/src/integration/builtin/hermes.rs @@ -0,0 +1,93 @@ +use std::io; +use std::path::PathBuf; + +use crate::agents::integration::{IntegrationAdapter, IntegrationProfile}; +use crate::integration::hermes_plugin_dir; +#[cfg(windows)] +use crate::integration::{executable_file_exists, hermes_dir}; +use crate::integration::{install_hermes, uninstall_hermes}; + +pub(crate) const PLUGIN_INSTALL_NAME: &str = "herdr-agent-state"; +pub(crate) const PLUGIN_MANIFEST_INSTALL_NAME: &str = "plugin.yaml"; +pub(crate) const PLUGIN_INIT_INSTALL_NAME: &str = "__init__.py"; +#[cfg(test)] +pub(crate) const PLUGIN_MANIFEST_ASSET: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/vendor/agent-registry/agents/hermes/assets/plugin.yaml" +)); +#[cfg(test)] +pub(crate) const PLUGIN_INIT_ASSET: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/vendor/agent-registry/agents/hermes/assets/__init__.py" +)); + +pub(super) const ADAPTER: IntegrationAdapter = + IntegrationAdapter::new(install_adapter, uninstall_adapter, integration_path) + .with_install_layout_probe(install_layout_available); + +fn install_adapter(profile: &IntegrationProfile) -> io::Result> { + let installed = install_hermes(profile)?; + Ok(vec![ + format!( + "installed hermes integration plugin to {}", + installed.plugin_dir.display() + ), + format!( + "enabled hermes plugin in {}", + installed.config_path.display() + ), + ]) +} + +fn uninstall_adapter() -> io::Result> { + let result = uninstall_hermes()?; + let mut messages = Vec::new(); + if result.removed_plugin_dir { + messages.push(format!( + "removed hermes integration plugin at {}", + result.plugin_dir.display() + )); + } else { + messages.push(format!( + "no hermes integration plugin found at {}", + result.plugin_dir.display() + )); + } + if result.updated_config { + messages.push(format!( + "disabled hermes plugin in {}", + result.config_path.display() + )); + } else { + messages.push(format!( + "no hermes plugin entry found in {}", + result.config_path.display() + )); + } + Ok(messages) +} + +fn integration_path() -> io::Result { + hermes_plugin_dir().map(|dir| dir.join(PLUGIN_INIT_INSTALL_NAME)) +} + +pub(crate) fn install_layout_available() -> bool { + #[cfg(windows)] + { + let Ok(dir) = hermes_dir() else { + return false; + }; + [ + dir.join("hermes.exe"), + dir.join("bin").join("hermes.exe"), + dir.join("Scripts").join("hermes.exe"), + ] + .into_iter() + .any(|path| executable_file_exists(&path)) + } + + #[cfg(not(windows))] + { + false + } +} diff --git a/src/integration/builtin/kilo.rs b/src/integration/builtin/kilo.rs new file mode 100644 index 0000000000..dfb2011be0 --- /dev/null +++ b/src/integration/builtin/kilo.rs @@ -0,0 +1,43 @@ +use std::io; +use std::path::PathBuf; + +use crate::agents::integration::{IntegrationAdapter, IntegrationProfile}; +use crate::integration::kilo_dir; +use crate::integration::{install_kilo, uninstall_kilo}; + +pub(crate) const PLUGIN_INSTALL_NAME: &str = "herdr-agent-state.js"; +#[cfg(test)] +pub(crate) const PLUGIN_ASSET: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/vendor/agent-registry/agents/kilo/assets/herdr-agent-state.js" +)); + +pub(super) const ADAPTER: IntegrationAdapter = + IntegrationAdapter::new(install_adapter, uninstall_adapter, integration_path); + +fn install_adapter(profile: &IntegrationProfile) -> io::Result> { + let installed = install_kilo(profile)?; + Ok(vec![format!( + "installed kilo integration plugin to {}", + installed.plugin_path.display() + )]) +} + +fn uninstall_adapter() -> io::Result> { + let result = uninstall_kilo()?; + Ok(if result.removed_plugin { + vec![format!( + "removed kilo integration plugin at {}", + result.plugin_path.display() + )] + } else { + vec![format!( + "no kilo integration plugin found at {}", + result.plugin_path.display() + )] + }) +} + +fn integration_path() -> io::Result { + kilo_dir().map(|dir| dir.join("plugin").join(PLUGIN_INSTALL_NAME)) +} diff --git a/src/integration/builtin/kimi.rs b/src/integration/builtin/kimi.rs new file mode 100644 index 0000000000..0075c6fff9 --- /dev/null +++ b/src/integration/builtin/kimi.rs @@ -0,0 +1,105 @@ +use std::io; +use std::path::PathBuf; + +use crate::agents::integration::AgentVersionRequirement; +use crate::agents::integration::{IntegrationAdapter, IntegrationProfile}; +use crate::integration::kimi_dir; +use crate::integration::{install_kimi, uninstall_kimi}; + +pub(super) const HOOK_INSTALL_NAME_UNIX: &str = "herdr-agent-state.sh"; +pub(super) const HOOK_INSTALL_NAME_WINDOWS: &str = "herdr-agent-state.ps1"; +pub(crate) const HOOK_INSTALL_NAME: &str = if cfg!(windows) { + HOOK_INSTALL_NAME_WINDOWS +} else { + HOOK_INSTALL_NAME_UNIX +}; +#[cfg(test)] +pub(crate) const HOOK_ASSET: &str = if cfg!(windows) { + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/vendor/agent-registry/agents/kimi/assets/herdr-agent-state.ps1" + )) +} else { + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/vendor/agent-registry/agents/kimi/assets/herdr-agent-state.sh" + )) +}; +pub(crate) const CONFIG_BLOCK_BEGIN: &str = "# >>> herdr kimi integration"; +pub(crate) const CONFIG_BLOCK_END: &str = "# <<< herdr kimi integration"; +pub(crate) const MIN_VERSION: &str = "0.14.0"; +pub(crate) const ASK_USER_QUESTION_MATCHER: &str = "^AskUserQuestion$"; +pub(crate) const OTHER_TOOL_MATCHER: &str = "^(?!AskUserQuestion$).*$"; +pub(crate) const HOOK_EVENTS: [(&str, Option<&str>, &str); 12] = [ + ("SessionStart", None, "session"), + ("UserPromptSubmit", None, "working"), + ("PreToolUse", Some(OTHER_TOOL_MATCHER), "working"), + ("PreToolUse", Some(ASK_USER_QUESTION_MATCHER), "blocked"), + ("PostToolUse", Some(ASK_USER_QUESTION_MATCHER), "working"), + ( + "PostToolUseFailure", + Some(ASK_USER_QUESTION_MATCHER), + "working", + ), + ("SubagentStart", None, "working"), + ("PreCompact", None, "working"), + ("PermissionRequest", None, "blocked"), + ("PermissionResult", None, "working"), + ("Stop", None, "idle"), + ("Interrupt", None, "idle"), +]; + +static AGENT_VERSION_REQUIREMENT: AgentVersionRequirement = AgentVersionRequirement { + label: "kimi code", + binary: "kimi", + args: &["--version"], + min_version: MIN_VERSION, +}; + +pub(super) const ADAPTER: IntegrationAdapter = + IntegrationAdapter::new(install_adapter, uninstall_adapter, integration_path) + .with_version_requirement(&AGENT_VERSION_REQUIREMENT); + +fn install_adapter(profile: &IntegrationProfile) -> io::Result> { + let installed = install_kimi(profile)?; + Ok(vec![ + format!( + "installed kimi integration hook to {}", + installed.hook_path.display() + ), + format!("ensured kimi config at {}", installed.config_path.display()), + format!("requires kimi code {MIN_VERSION} or newer"), + ]) +} + +fn uninstall_adapter() -> io::Result> { + let result = uninstall_kimi()?; + let mut messages = Vec::new(); + if result.removed_hook_file { + messages.push(format!( + "removed kimi hook at {}", + result.hook_path.display() + )); + } else { + messages.push(format!( + "no kimi hook found at {}", + result.hook_path.display() + )); + } + if result.updated_config { + messages.push(format!( + "removed herdr kimi hook entries from {}", + result.config_path.display() + )); + } else { + messages.push(format!( + "no herdr kimi hook entries found in {}", + result.config_path.display() + )); + } + Ok(messages) +} + +fn integration_path() -> io::Result { + kimi_dir().map(|dir| dir.join("hooks").join(HOOK_INSTALL_NAME)) +} diff --git a/src/integration/builtin/mastracode.rs b/src/integration/builtin/mastracode.rs new file mode 100644 index 0000000000..28d063ed0a --- /dev/null +++ b/src/integration/builtin/mastracode.rs @@ -0,0 +1,91 @@ +use std::io; +use std::path::PathBuf; + +use crate::agents::integration::{IntegrationAdapter, IntegrationProfile}; +use crate::integration::mastracode_dir; +use crate::integration::{install_mastracode, uninstall_mastracode}; + +pub(super) const HOOK_INSTALL_NAME_UNIX: &str = "herdr-agent-state.sh"; +pub(super) const HOOK_INSTALL_NAME_WINDOWS: &str = "herdr-agent-state.ps1"; +pub(crate) const HOOK_INSTALL_NAME: &str = if cfg!(windows) { + HOOK_INSTALL_NAME_WINDOWS +} else { + HOOK_INSTALL_NAME_UNIX +}; +#[cfg(test)] +pub(crate) const HOOK_ASSET: &str = if cfg!(windows) { + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/vendor/agent-registry/agents/mastracode/assets/herdr-agent-state.ps1" + )) +} else { + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/vendor/agent-registry/agents/mastracode/assets/herdr-agent-state.sh" + )) +}; +pub(crate) const HOOK_TIMEOUT_MS: u64 = 10_000; +pub(crate) const REMOVED_HOOK_EVENTS: [(&str, &str); 2] = + [("SessionStart", "idle"), ("SessionEnd", "release")]; +pub(crate) const HOOK_EVENTS: [(&str, &str); 11] = [ + ("SessionStart", "session"), + ("UserPromptSubmit", "working"), + ("AgentStart", "working"), + ("PreToolUse", "working"), + ("PermissionRequest", "blocked"), + ("PermissionResult", "working"), + ("SubagentStart", "working"), + ("SubagentEnd", "working"), + ("Interrupt", "idle"), + ("AgentEnd", "idle"), + ("Stop", "idle"), +]; + +pub(super) const ADAPTER: IntegrationAdapter = + IntegrationAdapter::new(install_adapter, uninstall_adapter, integration_path); + +fn install_adapter(profile: &IntegrationProfile) -> io::Result> { + let installed = install_mastracode(profile)?; + Ok(vec![ + format!( + "installed mastracode integration hook to {}", + installed.hook_path.display() + ), + format!( + "ensured mastracode hooks at {}", + installed.hooks_path.display() + ), + ]) +} + +fn uninstall_adapter() -> io::Result> { + let result = uninstall_mastracode()?; + let mut messages = Vec::new(); + if result.removed_hook_file { + messages.push(format!( + "removed mastracode hook at {}", + result.hook_path.display() + )); + } else { + messages.push(format!( + "no mastracode hook found at {}", + result.hook_path.display() + )); + } + if result.updated_hooks { + messages.push(format!( + "removed herdr mastracode hook entries from {}", + result.hooks_path.display() + )); + } else { + messages.push(format!( + "no herdr mastracode hook entries found in {}", + result.hooks_path.display() + )); + } + Ok(messages) +} + +fn integration_path() -> io::Result { + mastracode_dir().map(|dir| dir.join("hooks").join(HOOK_INSTALL_NAME)) +} diff --git a/src/integration/builtin/mod.rs b/src/integration/builtin/mod.rs new file mode 100644 index 0000000000..b194db6334 --- /dev/null +++ b/src/integration/builtin/mod.rs @@ -0,0 +1,56 @@ +//! Trusted built-in installers and validators for registry integration profiles. + +mod contract; + +pub(crate) use contract::validate_package; + +pub(crate) mod agy; +pub(crate) mod claude; +pub(crate) mod codex; +pub(crate) mod copilot; +pub(crate) mod cursor; +pub(crate) mod devin; +pub(crate) mod droid; +pub(crate) mod grok; +pub(crate) mod hermes; +pub(crate) mod kilo; +pub(crate) mod kimi; +pub(crate) mod mastracode; +pub(crate) mod omp; +pub(crate) mod opencode; +pub(crate) mod pi; +pub(crate) mod qodercli; +pub(crate) mod qwen; + +use crate::agents::integration::IntegrationAdapter; +use crate::api::schema::IntegrationTarget; +use crate::detect::Agent; + +#[cfg(test)] +pub(crate) fn adapter(agent: Agent) -> Option { + binding(agent).map(|(_, adapter)| adapter) +} + +/// Bind registry metadata to a trusted target and its installer implementation. +pub(crate) fn binding(agent: Agent) -> Option<(IntegrationTarget, IntegrationAdapter)> { + match agent { + Agent::Antigravity => Some((IntegrationTarget::AntigravityCli, agy::ADAPTER)), + Agent::Claude => Some((IntegrationTarget::Claude, claude::ADAPTER)), + Agent::Codex => Some((IntegrationTarget::Codex, codex::ADAPTER)), + Agent::GithubCopilot => Some((IntegrationTarget::Copilot, copilot::ADAPTER)), + Agent::Cursor => Some((IntegrationTarget::Cursor, cursor::ADAPTER)), + Agent::Devin => Some((IntegrationTarget::Devin, devin::ADAPTER)), + Agent::Droid => Some((IntegrationTarget::Droid, droid::ADAPTER)), + Agent::Grok => Some((IntegrationTarget::Grok, grok::ADAPTER)), + Agent::Hermes => Some((IntegrationTarget::Hermes, hermes::ADAPTER)), + Agent::Kilo => Some((IntegrationTarget::Kilo, kilo::ADAPTER)), + Agent::Kimi => Some((IntegrationTarget::Kimi, kimi::ADAPTER)), + Agent::Mastracode => Some((IntegrationTarget::Mastracode, mastracode::ADAPTER)), + Agent::Omp => Some((IntegrationTarget::Omp, omp::ADAPTER)), + Agent::OpenCode => Some((IntegrationTarget::Opencode, opencode::ADAPTER)), + Agent::Pi => Some((IntegrationTarget::Pi, pi::ADAPTER)), + Agent::Qodercli => Some((IntegrationTarget::Qodercli, qodercli::ADAPTER)), + Agent::Qwen => Some((IntegrationTarget::Qwen, qwen::ADAPTER)), + _ => None, + } +} diff --git a/src/integration/builtin/omp.rs b/src/integration/builtin/omp.rs new file mode 100644 index 0000000000..0b376f5c6f --- /dev/null +++ b/src/integration/builtin/omp.rs @@ -0,0 +1,54 @@ +use std::io; +use std::path::PathBuf; + +use crate::agents::integration::{IntegrationAdapter, IntegrationProfile}; +use crate::integration::omp_extension_dir; +use crate::integration::{install_omp, uninstall_omp}; + +pub(crate) const EXTENSION_INSTALL_NAME: &str = "herdr-omp-agent-state.ts"; +#[cfg(test)] +pub(crate) const EXTENSION_ASSET: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/vendor/agent-registry/agents/omp/assets/herdr-agent-state.ts" +)); + +pub(super) const ADAPTER: IntegrationAdapter = + IntegrationAdapter::new(install_adapter, uninstall_adapter, integration_path); + +fn install_adapter(profile: &IntegrationProfile) -> io::Result> { + let installed = install_omp(profile)?; + let mut messages = Vec::new(); + if installed.removed_legacy_pi_extension { + messages.push(format!( + "removed legacy pi integration from omp extension directory at {}", + installed + .extension_path + .with_file_name(crate::integration::builtin::pi::EXTENSION_INSTALL_NAME) + .display() + )); + } + messages.push(format!( + "installed omp integration to {}", + installed.extension_path.display() + )); + Ok(messages) +} + +fn uninstall_adapter() -> io::Result> { + let result = uninstall_omp()?; + Ok(if result.removed_extension { + vec![format!( + "removed omp integration extension at {}", + result.extension_path.display() + )] + } else { + vec![format!( + "no omp integration extension found at {}", + result.extension_path.display() + )] + }) +} + +fn integration_path() -> io::Result { + omp_extension_dir().map(|dir| dir.join(EXTENSION_INSTALL_NAME)) +} diff --git a/src/integration/builtin/opencode.rs b/src/integration/builtin/opencode.rs new file mode 100644 index 0000000000..80521140d6 --- /dev/null +++ b/src/integration/builtin/opencode.rs @@ -0,0 +1,119 @@ +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +use crate::agents::integration::{IntegrationAdapter, IntegrationProfile}; +use crate::integration::opencode_dir; +use crate::integration::parse_integration_version; +use crate::integration::{install_opencode, uninstall_opencode}; + +pub(crate) const PLUGIN_INSTALL_NAME: &str = "herdr-agent-state.js"; +#[cfg(test)] +pub(crate) const PLUGIN_ASSET: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/vendor/agent-registry/agents/opencode/assets/herdr-agent-state.js" +)); +pub(crate) const TUI_PLUGIN_INSTALL_NAME: &str = "herdr-tui-session.js"; +pub(crate) const TUI_PLUGIN_SPEC: &str = "./herdr-tui-session.js"; +#[cfg(test)] +pub(crate) const TUI_PLUGIN_ASSET: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/vendor/agent-registry/agents/opencode/assets/herdr-tui-session.js" +)); + +pub(crate) const V2_TUI_PLUGIN_DIR: &str = "herdr-opencode"; +pub(crate) const V2_TUI_PLUGIN_SPEC: &str = "./herdr-opencode"; + +pub(crate) fn v2_tui_entrypoint(version: u32) -> String { + // The installer owns this fixed layout bridge; both clients load the registry's TUI asset. + format!( + "// installed by herdr\n// HERDR_INTEGRATION_ID=opencode-tui-v2\n// HERDR_INTEGRATION_VERSION={version}\n// V2 resolves the directory's tui entrypoint; V1 uses the original file.\nexport {{ default }} from \"../{TUI_PLUGIN_INSTALL_NAME}\";\n" + ) +} + +pub(super) const ADAPTER: IntegrationAdapter = + IntegrationAdapter::new(install_adapter, uninstall_adapter, integration_path) + .with_current_install_extra_validator(tui_integration_is_valid); + +fn install_adapter(profile: &IntegrationProfile) -> io::Result> { + let installed = install_opencode(profile)?; + let mut messages = vec![ + format!( + "installed opencode integration plugin to {}", + installed.plugin_path.display() + ), + format!( + "installed opencode tui integration plugin to {}", + installed.tui_plugin_path.display() + ), + format!( + "ensured opencode tui plugin config at {}", + installed.tui_config_path.display() + ), + ]; + if installed.cli_config_path.is_none() { + messages.push( + "to enable OpenCode V2, start opencode2 once, then reinstall this integration" + .to_string(), + ); + } + Ok(messages) +} + +fn uninstall_adapter() -> io::Result> { + let result = uninstall_opencode()?; + let mut messages = vec![if result.removed_plugin { + format!( + "removed opencode integration plugin at {}", + result.plugin_path.display() + ) + } else { + format!( + "no opencode integration plugin found at {}", + result.plugin_path.display() + ) + }]; + messages.push(if result.removed_tui_plugin { + format!( + "removed opencode tui integration plugin at {}", + result.tui_plugin_path.display() + ) + } else { + format!( + "no opencode tui integration plugin found at {}", + result.tui_plugin_path.display() + ) + }); + if result.updated_tui_config { + messages.push(format!( + "removed herdr opencode plugin entry from {}", + result.tui_config_path.display() + )); + } + Ok(messages) +} + +fn integration_path() -> io::Result { + opencode_dir().map(|dir| dir.join("plugins").join(PLUGIN_INSTALL_NAME)) +} + +fn tui_integration_is_valid(plugin_path: &Path, expected_version: u32) -> bool { + let Some(config_dir) = plugin_path.parent().and_then(Path::parent) else { + return false; + }; + let tui_plugin_path = config_dir.join(TUI_PLUGIN_INSTALL_NAME); + let tui_plugin_current = fs::read_to_string(tui_plugin_path) + .ok() + .and_then(|content| parse_integration_version(&content)) + .is_some_and(|version| version >= expected_version); + tui_plugin_current + && crate::integration::tui_plugin_is_configured(config_dir, TUI_PLUGIN_SPEC) + && (!config_dir.join("cli.json").exists() + || (crate::integration::opencode_config::cli_plugin_is_configured( + config_dir, + V2_TUI_PLUGIN_SPEC, + ) && fs::read_to_string(config_dir.join(V2_TUI_PLUGIN_DIR).join("tui.js")) + .ok() + .and_then(|content| parse_integration_version(&content)) + .is_some_and(|version| version >= expected_version))) +} diff --git a/src/integration/builtin/pi.rs b/src/integration/builtin/pi.rs new file mode 100644 index 0000000000..61fda918ac --- /dev/null +++ b/src/integration/builtin/pi.rs @@ -0,0 +1,43 @@ +use std::io; +use std::path::PathBuf; + +use crate::agents::integration::{IntegrationAdapter, IntegrationProfile}; +use crate::integration::pi_extension_dir; +use crate::integration::{install_pi, uninstall_pi}; + +pub(crate) const EXTENSION_INSTALL_NAME: &str = "herdr-agent-state.ts"; +#[cfg(test)] +pub(crate) const EXTENSION_ASSET: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/vendor/agent-registry/agents/pi/assets/herdr-agent-state.ts" +)); + +pub(super) const ADAPTER: IntegrationAdapter = + IntegrationAdapter::new(install_adapter, uninstall_adapter, integration_path); + +fn install_adapter(profile: &IntegrationProfile) -> io::Result> { + let path = install_pi(profile)?; + Ok(vec![format!( + "installed pi integration to {}", + path.display() + )]) +} + +fn uninstall_adapter() -> io::Result> { + let result = uninstall_pi()?; + Ok(if result.removed_extension { + vec![format!( + "removed pi integration extension at {}", + result.extension_path.display() + )] + } else { + vec![format!( + "no pi integration extension found at {}", + result.extension_path.display() + )] + }) +} + +fn integration_path() -> io::Result { + pi_extension_dir().map(|dir| dir.join(EXTENSION_INSTALL_NAME)) +} diff --git a/src/integration/builtin/qodercli.rs b/src/integration/builtin/qodercli.rs new file mode 100644 index 0000000000..698c799c27 --- /dev/null +++ b/src/integration/builtin/qodercli.rs @@ -0,0 +1,90 @@ +use std::io; +use std::path::PathBuf; + +use crate::agents::integration::{IntegrationAdapter, IntegrationProfile}; +use crate::integration::qodercli_dir; +use crate::integration::{install_qodercli, uninstall_qodercli}; + +pub(super) const HOOK_INSTALL_NAME_UNIX: &str = "herdr-agent-state.sh"; +pub(super) const HOOK_INSTALL_NAME_WINDOWS: &str = "herdr-agent-state.ps1"; +pub(crate) const HOOK_INSTALL_NAME: &str = if cfg!(windows) { + HOOK_INSTALL_NAME_WINDOWS +} else { + HOOK_INSTALL_NAME_UNIX +}; +#[cfg(test)] +pub(crate) const HOOK_ASSET: &str = if cfg!(windows) { + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/vendor/agent-registry/agents/qodercli/assets/herdr-agent-state.ps1" + )) +} else { + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/vendor/agent-registry/agents/qodercli/assets/herdr-agent-state.sh" + )) +}; +pub(crate) const HOOK_EVENTS: [(&str, &str); 1] = [("SessionStart", "session")]; +pub(crate) const REMOVED_LIFECYCLE_HOOK_EVENTS: [(&str, &str); 12] = [ + ("SessionStart", "idle"), + ("UserPromptSubmit", "working"), + ("PreToolUse", "working"), + ("PostToolUse", "working"), + ("PostToolUseFailure", "working"), + ("SubagentStart", "working"), + ("SubagentStop", "working"), + ("PreCompact", "working"), + ("Notification", "blocked"), + ("PermissionRequest", "blocked"), + ("Stop", "idle"), + ("SessionEnd", "release"), +]; + +pub(super) const ADAPTER: IntegrationAdapter = + IntegrationAdapter::new(install_adapter, uninstall_adapter, integration_path); + +fn install_adapter(profile: &IntegrationProfile) -> io::Result> { + let installed = install_qodercli(profile)?; + Ok(vec![ + format!( + "installed qodercli integration hook to {}", + installed.hook_path.display() + ), + format!( + "ensured qodercli settings at {}", + installed.settings_path.display() + ), + ]) +} + +fn uninstall_adapter() -> io::Result> { + let result = uninstall_qodercli()?; + let mut messages = Vec::new(); + if result.removed_hook_file { + messages.push(format!( + "removed qodercli hook at {}", + result.hook_path.display() + )); + } else { + messages.push(format!( + "no qodercli hook found at {}", + result.hook_path.display() + )); + } + if result.updated_settings { + messages.push(format!( + "removed herdr qodercli hook entries from {}", + result.settings_path.display() + )); + } else { + messages.push(format!( + "no herdr qodercli hook entries found in {}", + result.settings_path.display() + )); + } + Ok(messages) +} + +fn integration_path() -> io::Result { + qodercli_dir().map(|dir| dir.join("hooks").join(HOOK_INSTALL_NAME)) +} diff --git a/src/integration/builtin/qwen.rs b/src/integration/builtin/qwen.rs new file mode 100644 index 0000000000..ef4aa7295a --- /dev/null +++ b/src/integration/builtin/qwen.rs @@ -0,0 +1,76 @@ +use std::io; +use std::path::PathBuf; + +use crate::agents::integration::{IntegrationAdapter, IntegrationProfile}; +use crate::integration::qwen_dir; +use crate::integration::{install_qwen, uninstall_qwen}; + +pub(super) const HOOK_INSTALL_NAME_UNIX: &str = "herdr-agent-session.sh"; +pub(super) const HOOK_INSTALL_NAME_WINDOWS: &str = "herdr-agent-session.ps1"; +pub(crate) const HOOK_INSTALL_NAME: &str = if cfg!(windows) { + HOOK_INSTALL_NAME_WINDOWS +} else { + HOOK_INSTALL_NAME_UNIX +}; +#[cfg(test)] +pub(crate) const HOOK_ASSET: &str = if cfg!(windows) { + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/vendor/agent-registry/agents/qwen/assets/herdr-agent-session.ps1" + )) +} else { + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/vendor/agent-registry/agents/qwen/assets/herdr-agent-session.sh" + )) +}; +pub(crate) const HOOK_EVENTS: [(&str, &str); 1] = [("SessionStart", "session")]; + +pub(super) const ADAPTER: IntegrationAdapter = + IntegrationAdapter::new(install_adapter, uninstall_adapter, integration_path); + +fn install_adapter(profile: &IntegrationProfile) -> io::Result> { + let installed = install_qwen(profile)?; + Ok(vec![ + format!( + "installed qwen integration hook to {}", + installed.hook_path.display() + ), + format!( + "ensured qwen settings at {}", + installed.settings_path.display() + ), + ]) +} + +fn uninstall_adapter() -> io::Result> { + let result = uninstall_qwen()?; + let mut messages = Vec::new(); + if result.removed_hook_file { + messages.push(format!( + "removed qwen hook at {}", + result.hook_path.display() + )); + } else { + messages.push(format!( + "no qwen hook found at {}", + result.hook_path.display() + )); + } + if result.updated_settings { + messages.push(format!( + "removed herdr qwen hook entries from {}", + result.settings_path.display() + )); + } else { + messages.push(format!( + "no herdr qwen hook entries found in {}", + result.settings_path.display() + )); + } + Ok(messages) +} + +fn integration_path() -> io::Result { + qwen_dir().map(|dir| dir.join("hooks").join(HOOK_INSTALL_NAME)) +} diff --git a/src/integration/env.rs b/src/integration/env.rs index 48320c72cf..488e1108e1 100644 --- a/src/integration/env.rs +++ b/src/integration/env.rs @@ -230,6 +230,7 @@ pub(crate) fn home_dir() -> io::Result { #[cfg(test)] pub(crate) struct IntegrationEnvLock { _guard: MutexGuard<'static, ()>, + hermes_home: Option, #[cfg(windows)] appdata: Option, } @@ -237,6 +238,11 @@ pub(crate) struct IntegrationEnvLock { #[cfg(test)] impl Drop for IntegrationEnvLock { fn drop(&mut self) { + if let Some(home) = self.hermes_home.take() { + std::env::set_var(HERMES_HOME_ENV_VAR, home); + } else { + std::env::remove_var(HERMES_HOME_ENV_VAR); + } #[cfg(windows)] if let Some(appdata) = self.appdata.take() { std::env::set_var("APPDATA", appdata); @@ -250,8 +256,12 @@ impl Drop for IntegrationEnvLock { pub(crate) fn integration_env_lock() -> IntegrationEnvLock { static LOCK: OnceLock> = OnceLock::new(); let guard = LOCK.get_or_init(|| Mutex::new(())).lock().unwrap(); + let hermes_home = std::env::var_os(HERMES_HOME_ENV_VAR); + // Installer fixtures must not target an inherited real Hermes installation. + std::env::remove_var(HERMES_HOME_ENV_VAR); IntegrationEnvLock { _guard: guard, + hermes_home, #[cfg(windows)] appdata: std::env::var_os("APPDATA"), } diff --git a/src/integration/file_ops.rs b/src/integration/file_ops.rs index 2e345759b3..d77e95a478 100644 --- a/src/integration/file_ops.rs +++ b/src/integration/file_ops.rs @@ -1,6 +1,61 @@ use std::fs; -use std::io; +use std::io::{self, Write}; use std::path::Path; +use std::sync::atomic::{AtomicU64, Ordering}; + +pub(crate) fn atomic_replace_asset(path: &Path, text: &str, executable: bool) -> io::Result<()> { + replace_asset(path, executable, |file| file.write_all(text.as_bytes())) +} + +fn replace_asset( + path: &Path, + executable: bool, + write: impl FnOnce(&mut fs::File) -> io::Result<()>, +) -> io::Result<()> { + let parent = path + .parent() + .ok_or_else(|| io::Error::other("asset has no parent directory"))?; + let previous = match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + return Err(io::Error::other(format!( + "integration asset {} is not a regular file", + path.display() + ))); + } + Ok(metadata) => Some(metadata.permissions()), + Err(error) if error.kind() == io::ErrorKind::NotFound => None, + Err(error) => return Err(error), + }; + static NEXT: AtomicU64 = AtomicU64::new(0); + let temporary = parent.join(format!( + ".herdr-asset.{}.{}.tmp", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary)?; + let prepared = (|| { + write(&mut file)?; + if let Some(permissions) = previous { + file.set_permissions(permissions)?; + } + if executable { + make_executable(&temporary)?; + } + file.sync_all() + })(); + drop(file); + if let Err(error) = prepared.and_then(|_| fs::rename(&temporary, path)) { + let _ = fs::remove_file(&temporary); + return Err(error); + } + if let Err(error) = crate::platform::sync_directory_after_replace(parent) { + tracing::warn!(%error, path = %path.display(), "integration asset replaced but directory sync failed"); + } + Ok(()) +} pub(crate) fn remove_file_if_exists(path: &Path) -> io::Result { match fs::remove_file(path) { @@ -57,3 +112,100 @@ pub(crate) fn make_executable(_path: &Path) -> io::Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + struct Fixture(std::path::PathBuf); + + impl Fixture { + fn new() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let path = std::env::temp_dir().join(format!( + "herdr-asset-test-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&path).unwrap(); + Self(path) + } + } + + impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + #[test] + fn failed_asset_write_preserves_previous_bytes_and_cleans_temporary_file() { + let fixture = Fixture::new(); + let path = fixture.0.join("hook"); + fs::write(&path, "previous").unwrap(); + let error = replace_asset(&path, true, |file| { + file.write_all(b"partial")?; + Err(io::Error::other("injected write failure")) + }) + .unwrap_err(); + assert_eq!(error.to_string(), "injected write failure"); + assert_eq!(fs::read_to_string(&path).unwrap(), "previous"); + assert_eq!(fs::read_dir(&fixture.0).unwrap().count(), 1); + atomic_replace_asset(&path, "complete", true).unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), "complete"); + assert_eq!(fs::read_dir(&fixture.0).unwrap().count(), 1); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o755 + ); + } + } + + #[cfg(unix)] + #[test] + fn new_plugin_permissions_match_ordinary_install_writes() { + use std::os::unix::fs::PermissionsExt; + let fixture = Fixture::new(); + let ordinary = fixture.0.join("ordinary"); + let atomic = fixture.0.join("atomic"); + fs::write(&ordinary, "plugin").unwrap(); + atomic_replace_asset(&atomic, "plugin", false).unwrap(); + assert_eq!( + fs::metadata(&ordinary).unwrap().permissions().mode() & 0o777, + fs::metadata(&atomic).unwrap().permissions().mode() & 0o777 + ); + } + + #[test] + fn failed_asset_rename_cleans_temporary_file() { + let fixture = Fixture::new(); + let path = fixture.0.join("hook"); + let error = replace_asset(&path, false, |file| { + file.write_all(b"complete")?; + fs::create_dir(&path)?; + fs::write(path.join("user-data"), "untouched") + }); + assert!(error.is_err()); + assert_eq!( + fs::read_to_string(path.join("user-data")).unwrap(), + "untouched" + ); + assert_eq!(fs::read_dir(&fixture.0).unwrap().count(), 1); + } + + #[cfg(unix)] + #[test] + fn asset_install_rejects_symlinks_without_touching_their_targets() { + let fixture = Fixture::new(); + let target = fixture.0.join("user-file"); + let path = fixture.0.join("hook"); + fs::write(&target, "untouched").unwrap(); + std::os::unix::fs::symlink(&target, &path).unwrap(); + assert!(atomic_replace_asset(&path, "replacement", true).is_err()); + assert_eq!(fs::read_to_string(&target).unwrap(), "untouched"); + assert_eq!(fs::read_dir(&fixture.0).unwrap().count(), 2); + } +} diff --git a/src/integration/mod.rs b/src/integration/mod.rs index d956c7bcd7..a0d0082a36 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -1,4 +1,5 @@ mod actions; +pub(crate) mod builtin; mod claude_settings; mod command; mod config_edit; @@ -7,297 +8,51 @@ mod file_ops; mod opencode_config; mod registry; mod targets; -mod types; +pub(crate) mod types; mod version; pub(crate) use actions::{install_target, uninstall_target}; +#[cfg(windows)] +pub(crate) use env::hermes_dir; #[cfg(test)] pub(crate) use env::integration_env_lock; pub(crate) use env::{ - apply_pane_base_env, HERDR_PANE_ID_ENV_VAR, HERDR_TAB_ID_ENV_VAR, HERDR_WORKSPACE_ID_ENV_VAR, + antigravity_cli_dir, apply_pane_base_env, claude_dir, codex_dir, copilot_dir, cursor_dir, + devin_dir, droid_dir, grok_dir, hermes_plugin_dir, kilo_dir, kimi_dir, mastracode_dir, + omp_extension_dir, opencode_dir, pi_extension_dir, qodercli_dir, qwen_dir, + HERDR_PANE_ID_ENV_VAR, HERDR_TAB_ID_ENV_VAR, HERDR_WORKSPACE_ID_ENV_VAR, }; +pub(crate) use opencode_config::tui_plugin_is_configured; pub(crate) use registry::{ - installed_integration_statuses, integration_recommendations, integration_target_label, + executable_file_exists, installed_integration_statuses, integration_recommendations, + integration_recommendations_with_registry, integration_target_label, parse_integration_version, print_outdated_update_notice, }; +pub(crate) use targets::{ + grok_hook_config, install_antigravity_cli, install_claude, install_codex, install_copilot, + install_cursor, install_devin, install_droid, install_grok, install_hermes, install_kilo, + install_kimi, install_mastracode, install_omp, install_opencode, install_pi, install_qodercli, + install_qwen, uninstall_antigravity_cli, uninstall_claude, uninstall_codex, uninstall_copilot, + uninstall_cursor, uninstall_devin, uninstall_droid, uninstall_grok, uninstall_hermes, + uninstall_kilo, uninstall_kimi, uninstall_mastracode, uninstall_omp, uninstall_opencode, + uninstall_pi, uninstall_qodercli, uninstall_qwen, +}; pub(crate) use types::{IntegrationRecommendation, IntegrationStatus, IntegrationStatusKind}; -const PI_EXTENSION_INSTALL_NAME: &str = "herdr-agent-state.ts"; -const PI_EXTENSION_ASSET: &str = include_str!("assets/pi/herdr-agent-state.ts"); -const PI_INTEGRATION_VERSION: u32 = 9; -const OMP_EXTENSION_INSTALL_NAME: &str = "herdr-omp-agent-state.ts"; -const OMP_EXTENSION_ASSET: &str = include_str!("assets/omp/herdr-agent-state.ts"); -const OMP_INTEGRATION_VERSION: u32 = 9; -const CLAUDE_HOOK_INSTALL_NAME: &str = if cfg!(windows) { - "herdr-agent-state.ps1" -} else { - "herdr-agent-state.sh" -}; -const CLAUDE_HOOK_ASSET: &str = if cfg!(windows) { - include_str!("assets/claude/herdr-agent-state.ps1") -} else { - include_str!("assets/claude/herdr-agent-state.sh") -}; -const CLAUDE_INTEGRATION_VERSION: u32 = 9; -const CODEX_HOOK_INSTALL_NAME: &str = if cfg!(windows) { - "herdr-agent-state.ps1" -} else { - "herdr-agent-state.sh" -}; -const CODEX_HOOK_ASSET: &str = if cfg!(windows) { - include_str!("assets/codex/herdr-agent-state.ps1") -} else { - include_str!("assets/codex/herdr-agent-state.sh") -}; -const CODEX_INTEGRATION_VERSION: u32 = 8; -const KIMI_HOOK_INSTALL_NAME: &str = if cfg!(windows) { - "herdr-agent-state.ps1" -} else { - "herdr-agent-state.sh" -}; -const KIMI_HOOK_ASSET: &str = if cfg!(windows) { - include_str!("assets/kimi/herdr-agent-state.ps1") -} else { - include_str!("assets/kimi/herdr-agent-state.sh") -}; -const KIMI_INTEGRATION_VERSION: u32 = 7; -const KIMI_CONFIG_BLOCK_BEGIN: &str = "# >>> herdr kimi integration"; -const KIMI_CONFIG_BLOCK_END: &str = "# <<< herdr kimi integration"; -const KIMI_MIN_VERSION: &str = "0.14.0"; -const KIMI_ASK_USER_QUESTION_MATCHER: &str = "^AskUserQuestion$"; -const KIMI_OTHER_TOOL_MATCHER: &str = "^(?!AskUserQuestion$).*$"; -const KIMI_HOOK_EVENTS: [(&str, Option<&str>, &str); 12] = [ - ("SessionStart", None, "session"), - ("UserPromptSubmit", None, "working"), - ("PreToolUse", Some(KIMI_OTHER_TOOL_MATCHER), "working"), - ( - "PreToolUse", - Some(KIMI_ASK_USER_QUESTION_MATCHER), - "blocked", - ), - ( - "PostToolUse", - Some(KIMI_ASK_USER_QUESTION_MATCHER), - "working", - ), - ( - "PostToolUseFailure", - Some(KIMI_ASK_USER_QUESTION_MATCHER), - "working", - ), - ("SubagentStart", None, "working"), - ("PreCompact", None, "working"), - ("PermissionRequest", None, "blocked"), - ("PermissionResult", None, "working"), - ("Stop", None, "idle"), - ("Interrupt", None, "idle"), -]; -const COPILOT_HOOK_INSTALL_NAME: &str = if cfg!(windows) { - "herdr-agent-state.ps1" -} else { - "herdr-agent-state.sh" -}; -const COPILOT_HOOK_ASSET: &str = if cfg!(windows) { - include_str!("assets/copilot/herdr-agent-state.ps1") -} else { - include_str!("assets/copilot/herdr-agent-state.sh") -}; -const COPILOT_INTEGRATION_VERSION: u32 = 3; -const COPILOT_HOOK_EVENTS: [&str; 1] = ["SessionStart"]; -const COPILOT_REMOVED_LIFECYCLE_HOOK_EVENTS: [&str; 9] = [ - "UserPromptSubmit", - "PreToolUse", - "PostToolUse", - "PostToolUseFailure", - "Stop", - "agentStop", - "SessionEnd", - "notification", - "sessionStart", -]; -const DEVIN_HOOK_INSTALL_NAME: &str = if cfg!(windows) { - "herdr-agent-state.ps1" -} else { - "herdr-agent-state.sh" -}; -const DEVIN_HOOK_ASSET: &str = if cfg!(windows) { - include_str!("assets/devin/herdr-agent-state.ps1") -} else { - include_str!("assets/devin/herdr-agent-state.sh") -}; -const DEVIN_INTEGRATION_VERSION: u32 = 2; -const DEVIN_HOOK_EVENTS: [(&str, &str); 6] = [ - ("SessionStart", "session"), - ("UserPromptSubmit", "session"), - ("PreToolUse", "session"), - ("PostToolUse", "session"), - ("PermissionRequest", "session"), - ("Stop", "session"), -]; -const DEVIN_REMOVED_LIFECYCLE_HOOK_EVENTS: [(&str, &str); 6] = [ - ("UserPromptSubmit", "working"), - ("PreToolUse", "working"), - ("PostToolUse", "working"), - ("PermissionRequest", "blocked"), - ("Stop", "idle"), - ("SessionEnd", "release"), -]; -const DROID_HOOK_INSTALL_NAME: &str = if cfg!(windows) { - "herdr-agent-state.ps1" -} else { - "herdr-agent-state.sh" -}; -const DROID_HOOK_ASSET: &str = if cfg!(windows) { - include_str!("assets/droid/herdr-agent-state.ps1") -} else { - include_str!("assets/droid/herdr-agent-state.sh") -}; -const DROID_INTEGRATION_VERSION: u32 = 3; -const DROID_HOOK_EVENTS: [(&str, &str); 1] = [("SessionStart", "session")]; -const DROID_REMOVED_LIFECYCLE_HOOK_EVENTS: [(&str, &str); 9] = [ - ("SessionStart", "idle"), - ("UserPromptSubmit", "working"), - ("PreToolUse", "working"), - ("PostToolUse", "working"), - ("Notification", "blocked"), - ("Stop", "idle"), - ("SubagentStop", "working"), - ("PreCompact", "working"), - ("SessionEnd", "release"), -]; -const OPENCODE_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state.js"; -const OPENCODE_PLUGIN_ASSET: &str = include_str!("assets/opencode/herdr-agent-state.js"); -const OPENCODE_TUI_PLUGIN_INSTALL_NAME: &str = "herdr-tui-session.js"; -const OPENCODE_TUI_PLUGIN_SPEC: &str = "./herdr-tui-session.js"; -const OPENCODE_TUI_PLUGIN_ASSET: &str = include_str!("assets/opencode/herdr-tui-session.js"); -const OPENCODE_V2_TUI_PLUGIN_DIR: &str = "herdr-opencode"; -const OPENCODE_V2_TUI_PLUGIN_SPEC: &str = "./herdr-opencode"; -const OPENCODE_V2_TUI_PLUGIN_ASSET: &str = include_str!("assets/opencode/tui.js"); -const OPENCODE_INTEGRATION_VERSION: u32 = 12; -const KILO_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state.js"; -const KILO_PLUGIN_ASSET: &str = include_str!("assets/kilo/herdr-agent-state.js"); -const KILO_INTEGRATION_VERSION: u32 = 4; -const HERMES_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state"; -const HERMES_PLUGIN_MANIFEST_INSTALL_NAME: &str = "plugin.yaml"; -const HERMES_PLUGIN_INIT_INSTALL_NAME: &str = "__init__.py"; -const HERMES_PLUGIN_MANIFEST_ASSET: &str = include_str!("assets/hermes/plugin.yaml"); -const HERMES_PLUGIN_INIT_ASSET: &str = include_str!("assets/hermes/__init__.py"); -const HERMES_INTEGRATION_VERSION: u32 = 5; -const QODERCLI_HOOK_INSTALL_NAME: &str = if cfg!(windows) { - "herdr-agent-state.ps1" -} else { - "herdr-agent-state.sh" +// Narrow compatibility imports for shared config/environment internals that +// consume trusted built-in installer values. +use crate::integration::builtin::hermes::PLUGIN_INSTALL_NAME as HERMES_PLUGIN_INSTALL_NAME; +use crate::integration::builtin::kimi::{ + CONFIG_BLOCK_BEGIN as KIMI_CONFIG_BLOCK_BEGIN, CONFIG_BLOCK_END as KIMI_CONFIG_BLOCK_END, + HOOK_EVENTS as KIMI_HOOK_EVENTS, }; -const QODERCLI_HOOK_ASSET: &str = if cfg!(windows) { - include_str!("assets/qodercli/herdr-agent-state.ps1") -} else { - include_str!("assets/qodercli/herdr-agent-state.sh") -}; -const QODERCLI_INTEGRATION_VERSION: u32 = 3; -const QODERCLI_HOOK_EVENTS: [(&str, &str); 1] = [("SessionStart", "session")]; -const QWEN_HOOK_INSTALL_NAME: &str = if cfg!(windows) { - "herdr-agent-session.ps1" -} else { - "herdr-agent-session.sh" -}; -const QWEN_HOOK_ASSET: &str = if cfg!(windows) { - include_str!("assets/qwen/herdr-agent-session.ps1") -} else { - include_str!("assets/qwen/herdr-agent-session.sh") -}; -const QWEN_INTEGRATION_VERSION: u32 = 1; -const QWEN_HOOK_EVENTS: [(&str, &str); 1] = [("SessionStart", "session")]; -const QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS: [(&str, &str); 12] = [ - ("SessionStart", "idle"), - ("UserPromptSubmit", "working"), - ("PreToolUse", "working"), - ("PostToolUse", "working"), - ("PostToolUseFailure", "working"), - ("SubagentStart", "working"), - ("SubagentStop", "working"), - ("PreCompact", "working"), - ("Notification", "blocked"), - ("PermissionRequest", "blocked"), - ("Stop", "idle"), - ("SessionEnd", "release"), -]; -const CURSOR_HOOK_INSTALL_NAME: &str = if cfg!(windows) { - "herdr-agent-state.ps1" -} else { - "herdr-agent-state.sh" -}; -const CURSOR_HOOK_ASSET: &str = if cfg!(windows) { - include_str!("assets/cursor/herdr-agent-state.ps1") -} else { - include_str!("assets/cursor/herdr-agent-state.sh") -}; -const CURSOR_INTEGRATION_VERSION: u32 = 1; -#[cfg(windows)] -const ANTIGRAVITY_CLI_HOOK_INSTALL_NAME: &str = "herdr-agent-state.ps1"; -#[cfg(not(windows))] -const ANTIGRAVITY_CLI_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; -#[cfg(windows)] -const ANTIGRAVITY_CLI_HOOK_ASSET: &str = - include_str!("assets/antigravity_cli/herdr-agent-state.ps1"); -#[cfg(not(windows))] -const ANTIGRAVITY_CLI_HOOK_ASSET: &str = - include_str!("assets/antigravity_cli/herdr-agent-state.sh"); -const ANTIGRAVITY_CLI_INTEGRATION_VERSION: u32 = 3; -/// Antigravity CLI keys `hooks.json` by hook name, so every Herdr entry lives -/// under one Herdr-owned block that install rewrites and uninstall removes. -const ANTIGRAVITY_CLI_HOOK_BLOCK_NAME: &str = "herdr"; -const ANTIGRAVITY_CLI_HOOK_TIMEOUT_SEC: u64 = 10; -/// `(event, reported action)`. Session-only: `PreInvocation` is the only event -/// we need because it carries `conversationId`. The others cannot express -/// lifecycle safely — Antigravity CLI has no blocked event, `PostInvocation` is -/// skipped on interruption, and `Stop` is end-of-turn rather than process exit. -/// Screen detection owns agent state instead. -/// -/// `PreInvocation` takes a flat handler list; only the `PreToolUse`/`PostToolUse` -/// events accept a `matcher`/`hooks` wrapper, and sending one here would -/// invalidate the whole file. -const ANTIGRAVITY_CLI_HOOK_EVENTS: [(&str, &str); 1] = [("PreInvocation", "session")]; -const INTEGRATION_VERSION_MARKER: &str = "HERDR_INTEGRATION_VERSION="; -const MASTRACODE_HOOK_INSTALL_NAME: &str = if cfg!(windows) { - "herdr-agent-state.ps1" -} else { - "herdr-agent-state.sh" -}; -const MASTRACODE_HOOK_ASSET: &str = if cfg!(windows) { - include_str!("assets/mastracode/herdr-agent-state.ps1") -} else { - include_str!("assets/mastracode/herdr-agent-state.sh") -}; -const MASTRACODE_INTEGRATION_VERSION: u32 = 2; -const MASTRACODE_HOOK_TIMEOUT_MS: u64 = 10_000; -const MASTRACODE_REMOVED_HOOK_EVENTS: [(&str, &str); 2] = - [("SessionStart", "idle"), ("SessionEnd", "release")]; -const MASTRACODE_HOOK_EVENTS: [(&str, &str); 11] = [ - ("SessionStart", "session"), - ("UserPromptSubmit", "working"), - ("AgentStart", "working"), - ("PreToolUse", "working"), - ("PermissionRequest", "blocked"), - ("PermissionResult", "working"), - ("SubagentStart", "working"), - ("SubagentEnd", "working"), - ("Interrupt", "idle"), - ("AgentEnd", "idle"), - ("Stop", "idle"), -]; -const GROK_HOOK_INSTALL_NAME: &str = if cfg!(windows) { - "herdr-agent-state.ps1" -} else { - "herdr-agent-state.sh" -}; -const GROK_HOOK_CONFIG_INSTALL_NAME: &str = "herdr.json"; -const GROK_HOOK_ASSET: &str = if cfg!(windows) { - include_str!("assets/grok/herdr-agent-state.ps1") -} else { - include_str!("assets/grok/herdr-agent-state.sh") + +use crate::integration::builtin::opencode::{ + V2_TUI_PLUGIN_DIR as OPENCODE_V2_TUI_PLUGIN_DIR, + V2_TUI_PLUGIN_SPEC as OPENCODE_V2_TUI_PLUGIN_SPEC, }; -const GROK_INTEGRATION_VERSION: u32 = 1; +const INTEGRATION_VERSION_MARKER: &str = "HERDR_INTEGRATION_VERSION="; pub(crate) const INSTALL_WARNING_PREFIX: &str = "warning:"; #[cfg(test)] diff --git a/src/integration/registry.rs b/src/integration/registry.rs index f3bb8c0487..136758d939 100644 --- a/src/integration/registry.rs +++ b/src/integration/registry.rs @@ -1,128 +1,60 @@ use std::fs; use std::io; use std::path::{Path, PathBuf}; +use std::sync::Arc; -use super::env::*; +use crate::agents::integration::{IntegrationAdapter, IntegrationProfile}; -pub(crate) fn integration_target_label( +pub(super) fn registered_integration_profile( target: crate::api::schema::IntegrationTarget, -) -> &'static str { - match target { - crate::api::schema::IntegrationTarget::Pi => "pi", - crate::api::schema::IntegrationTarget::Omp => "omp", - crate::api::schema::IntegrationTarget::Claude => "claude", - crate::api::schema::IntegrationTarget::Codex => "codex", - crate::api::schema::IntegrationTarget::Copilot => "copilot", - crate::api::schema::IntegrationTarget::Devin => "devin", - crate::api::schema::IntegrationTarget::Droid => "droid", - crate::api::schema::IntegrationTarget::Kimi => "kimi", - crate::api::schema::IntegrationTarget::Opencode => "opencode", - crate::api::schema::IntegrationTarget::Kilo => "kilo", - crate::api::schema::IntegrationTarget::Hermes => "hermes", - crate::api::schema::IntegrationTarget::Qodercli => "qodercli", - crate::api::schema::IntegrationTarget::Qwen => "qwen", - crate::api::schema::IntegrationTarget::Cursor => "cursor", - crate::api::schema::IntegrationTarget::Mastracode => "mastracode", - crate::api::schema::IntegrationTarget::AntigravityCli => "antigravity-cli", - crate::api::schema::IntegrationTarget::Grok => "grok", - } +) -> io::Result> { + crate::agents::registry() + .profile_by_integration_target(target) + .and_then(|profile| profile.integration()) + .cloned() + .ok_or_else(|| io::Error::other("integration target has no active registry profile")) } -pub(crate) fn integration_target_command( - target: crate::api::schema::IntegrationTarget, -) -> &'static str { - integration_target_command_names(target)[0] +pub(crate) fn integration_target_label(target: crate::api::schema::IntegrationTarget) -> String { + registered_integration_profile(target) + .map(|profile| profile.cli_label().to_owned()) + .unwrap_or_else(|_| "unknown".to_owned()) } -pub(crate) fn integration_target_command_names( - target: crate::api::schema::IntegrationTarget, -) -> &'static [&'static str] { - match target { - crate::api::schema::IntegrationTarget::Pi => &["pi"], - crate::api::schema::IntegrationTarget::Omp => &["omp"], - crate::api::schema::IntegrationTarget::Claude => &["claude"], - crate::api::schema::IntegrationTarget::Codex => &["codex"], - crate::api::schema::IntegrationTarget::Copilot => &["copilot"], - crate::api::schema::IntegrationTarget::Devin => &["devin"], - crate::api::schema::IntegrationTarget::Droid => &["droid"], - crate::api::schema::IntegrationTarget::Kimi => &["kimi"], - crate::api::schema::IntegrationTarget::Opencode => &["opencode"], - crate::api::schema::IntegrationTarget::Kilo => &["kilo", "kilo-code"], - crate::api::schema::IntegrationTarget::Hermes => &["hermes"], - crate::api::schema::IntegrationTarget::Qodercli => qodercli_command_names(), - crate::api::schema::IntegrationTarget::Qwen => &["qwen"], - crate::api::schema::IntegrationTarget::Cursor => cursor_command_names(), - crate::api::schema::IntegrationTarget::Mastracode => &["mastracode"], - crate::api::schema::IntegrationTarget::AntigravityCli => &["agy"], - crate::api::schema::IntegrationTarget::Grok => &["grok"], - } +#[cfg(test)] +pub(crate) fn integration_target_command(target: crate::api::schema::IntegrationTarget) -> String { + registered_integration_profile(target) + .ok() + .and_then(|profile| profile.command_names().first().cloned()) + .unwrap_or_default() } -pub(crate) fn cursor_command_names() -> &'static [&'static str] { - &["cursor-agent"] +#[cfg(test)] +pub(crate) fn integration_target_command_names( + target: crate::api::schema::IntegrationTarget, +) -> Vec { + registered_integration_profile(target) + .map(|profile| profile.command_names().to_vec()) + .unwrap_or_default() } +#[cfg(test)] pub(crate) fn integration_target_supported(target: crate::api::schema::IntegrationTarget) -> bool { - #[cfg(windows)] - { - matches!( - target, - crate::api::schema::IntegrationTarget::Pi - | crate::api::schema::IntegrationTarget::Omp - | crate::api::schema::IntegrationTarget::Claude - | crate::api::schema::IntegrationTarget::Codex - | crate::api::schema::IntegrationTarget::Copilot - | crate::api::schema::IntegrationTarget::Opencode - | crate::api::schema::IntegrationTarget::Kilo - | crate::api::schema::IntegrationTarget::Droid - | crate::api::schema::IntegrationTarget::Kimi - | crate::api::schema::IntegrationTarget::Qodercli - | crate::api::schema::IntegrationTarget::Qwen - | crate::api::schema::IntegrationTarget::AntigravityCli - | crate::api::schema::IntegrationTarget::Devin - | crate::api::schema::IntegrationTarget::Hermes - | crate::api::schema::IntegrationTarget::Cursor - | crate::api::schema::IntegrationTarget::Mastracode - | crate::api::schema::IntegrationTarget::Grok - ) - } - - #[cfg(not(windows))] - { - let _ = target; - true - } + registered_integration_profile(target).is_ok_and(|profile| profile.supported()) } +#[cfg(test)] pub(crate) fn integration_target_available(target: crate::api::schema::IntegrationTarget) -> bool { - if !integration_target_supported(target) { - return false; - } - - integration_target_command_names(target) - .iter() - .any(|command| command_available(command)) - || integration_target_install_layout_available(target) + registered_integration_profile(target).is_ok_and(|profile| integration_available(&profile)) } -#[cfg(windows)] -pub(crate) fn qodercli_command_names() -> &'static [&'static str] { - &["qodercli", "qoder", "qoderclicn", "qodercn"] -} - -#[cfg(not(windows))] -pub(crate) fn qodercli_command_names() -> &'static [&'static str] { - &["qodercli"] -} - -pub(crate) fn integration_target_install_layout_available( - target: crate::api::schema::IntegrationTarget, -) -> bool { - match target { - crate::api::schema::IntegrationTarget::Codex => codex_standalone_binary_available(), - crate::api::schema::IntegrationTarget::Hermes => hermes_install_layout_available(), - _ => false, - } +fn integration_available(profile: &IntegrationProfile) -> bool { + profile.supported() + && (profile + .command_names() + .iter() + .any(|command| command_available(command)) + || profile.adapter().install_layout_available()) } pub(crate) fn command_available(command: &str) -> bool { @@ -178,76 +110,48 @@ pub(crate) fn executable_file_exists(path: &Path) -> bool { } } -pub(crate) fn codex_standalone_binary_available() -> bool { - let Ok(releases_dir) = - codex_dir().map(|dir| dir.join("packages").join("standalone").join("releases")) - else { - return false; - }; - let Ok(entries) = fs::read_dir(releases_dir) else { - return false; - }; - - entries.filter_map(Result::ok).any(|entry| { - executable_file_exists(&entry.path().join("bin").join(codex_executable_name())) - }) -} - -pub(crate) fn codex_executable_name() -> &'static str { - if cfg!(windows) { - "codex.exe" - } else { - "codex" - } -} - -pub(crate) fn hermes_install_layout_available() -> bool { - #[cfg(windows)] - { - let Ok(dir) = hermes_dir() else { - return false; - }; - [ - dir.join("hermes.exe"), - dir.join("bin").join("hermes.exe"), - dir.join("Scripts").join("hermes.exe"), - ] - .into_iter() - .any(|path| executable_file_exists(&path)) - } - - #[cfg(not(windows))] - { - false - } -} - pub(crate) fn installed_integration_statuses() -> Vec { integration_specs() - .into_iter() - .filter_map(|(target, path, expected_version)| { - if !integration_target_supported(target) { - return None; - } - Some(integration_status_at(target, path.ok()?, expected_version)) + .filter(|profile| profile.supported()) + .filter_map(|profile| { + let path = profile.adapter().primary_installed_artifact_path().ok()?; + Some(integration_status_with_adapter( + profile.target(), + path, + profile.expected_version(), + Some(profile.adapter()), + )) }) .collect() } pub(crate) fn integration_recommendations() -> Vec { - integration_specs() - .into_iter() - .filter_map(|(target, path, expected_version)| { - if !integration_target_supported(target) { - return None; - } - let path = path.ok()?; - let status = integration_status_at(target, path.clone(), expected_version); + let snapshot = crate::agents::registry(); + integration_recommendations_with_registry(&snapshot) +} + +/// Probe recommendations against one pinned registry, including its labels and +/// adapters. Callers tracking publication generations must use this same snapshot. +pub(crate) fn integration_recommendations_with_registry( + registry: &crate::agents::AgentRegistry, +) -> Vec { + registry + .integration_capable_profiles() + .filter_map(|profile| profile.integration()) + .filter(|profile| profile.supported()) + .filter_map(|profile| { + let path = profile.adapter().primary_installed_artifact_path().ok()?; + let status = integration_status_with_adapter( + profile.target(), + path.clone(), + profile.expected_version(), + Some(profile.adapter()), + ); Some(super::IntegrationRecommendation { - target, - label: integration_target_label(target), - command: integration_target_command(target), - available: integration_target_available(target) + target: profile.target(), + label: profile.cli_label().to_owned(), + command: profile.command_names().first().cloned().unwrap_or_default(), + available: integration_available(profile) || status.state != super::IntegrationStatusKind::NotInstalled, path, state: status.state, @@ -263,116 +167,27 @@ pub(crate) fn outdated_installed_integrations() -> Vec .collect() } -fn integration_specs() -> [( - crate::api::schema::IntegrationTarget, - io::Result, - u32, -); 17] { - [ - ( - crate::api::schema::IntegrationTarget::Pi, - pi_extension_dir().map(|dir| dir.join(super::PI_EXTENSION_INSTALL_NAME)), - super::PI_INTEGRATION_VERSION, - ), - ( - crate::api::schema::IntegrationTarget::Omp, - omp_extension_dir().map(|dir| dir.join(super::OMP_EXTENSION_INSTALL_NAME)), - super::OMP_INTEGRATION_VERSION, - ), - ( - crate::api::schema::IntegrationTarget::Claude, - claude_dir().map(|dir| dir.join("hooks").join(super::CLAUDE_HOOK_INSTALL_NAME)), - super::CLAUDE_INTEGRATION_VERSION, - ), - ( - crate::api::schema::IntegrationTarget::Codex, - codex_dir().map(|dir| dir.join(super::CODEX_HOOK_INSTALL_NAME)), - super::CODEX_INTEGRATION_VERSION, - ), - ( - crate::api::schema::IntegrationTarget::Copilot, - copilot_dir().map(|dir| dir.join("hooks").join(super::COPILOT_HOOK_INSTALL_NAME)), - super::COPILOT_INTEGRATION_VERSION, - ), - ( - crate::api::schema::IntegrationTarget::Devin, - devin_dir().map(|dir| dir.join(super::DEVIN_HOOK_INSTALL_NAME)), - super::DEVIN_INTEGRATION_VERSION, - ), - ( - crate::api::schema::IntegrationTarget::Droid, - droid_dir().map(|dir| dir.join("hooks").join(super::DROID_HOOK_INSTALL_NAME)), - super::DROID_INTEGRATION_VERSION, - ), - ( - crate::api::schema::IntegrationTarget::Kimi, - kimi_dir().map(|dir| dir.join("hooks").join(super::KIMI_HOOK_INSTALL_NAME)), - super::KIMI_INTEGRATION_VERSION, - ), - ( - crate::api::schema::IntegrationTarget::Opencode, - opencode_dir().map(|dir| { - dir.join("plugins") - .join(super::OPENCODE_PLUGIN_INSTALL_NAME) - }), - super::OPENCODE_INTEGRATION_VERSION, - ), - ( - crate::api::schema::IntegrationTarget::Kilo, - kilo_dir().map(|dir| dir.join("plugin").join(super::KILO_PLUGIN_INSTALL_NAME)), - super::KILO_INTEGRATION_VERSION, - ), - ( - crate::api::schema::IntegrationTarget::Hermes, - hermes_plugin_dir().map(|dir| dir.join(super::HERMES_PLUGIN_INIT_INSTALL_NAME)), - super::HERMES_INTEGRATION_VERSION, - ), - ( - crate::api::schema::IntegrationTarget::Qodercli, - qodercli_dir().map(|dir| dir.join("hooks").join(super::QODERCLI_HOOK_INSTALL_NAME)), - super::QODERCLI_INTEGRATION_VERSION, - ), - ( - crate::api::schema::IntegrationTarget::Qwen, - qwen_dir().map(|dir| dir.join("hooks").join(super::QWEN_HOOK_INSTALL_NAME)), - super::QWEN_INTEGRATION_VERSION, - ), - ( - crate::api::schema::IntegrationTarget::Cursor, - cursor_dir().map(|dir| dir.join(super::CURSOR_HOOK_INSTALL_NAME)), - super::CURSOR_INTEGRATION_VERSION, - ), - ( - crate::api::schema::IntegrationTarget::Mastracode, - mastracode_dir().map(|dir| dir.join("hooks").join(super::MASTRACODE_HOOK_INSTALL_NAME)), - super::MASTRACODE_INTEGRATION_VERSION, - ), - ( - crate::api::schema::IntegrationTarget::AntigravityCli, - antigravity_cli_dir().map(|dir| { - dir.join("hooks") - .join(super::ANTIGRAVITY_CLI_HOOK_INSTALL_NAME) - }), - super::ANTIGRAVITY_CLI_INTEGRATION_VERSION, - ), - ( - crate::api::schema::IntegrationTarget::Grok, - grok_dir().map(|dir| dir.join("hooks").join(super::GROK_HOOK_INSTALL_NAME)), - super::GROK_INTEGRATION_VERSION, - ), - ] +fn integration_specs() -> impl Iterator> { + crate::agents::registry() + .integration_capable_profiles() + .filter_map(|profile| profile.integration().cloned()) + .collect::>() + .into_iter() } pub(crate) fn integration_update_instructions( targets: &[crate::api::schema::IntegrationTarget], ) -> String { + let registry = crate::agents::registry(); let commands: Vec = targets .iter() .map(|target| { - format!( - "`herdr integration install {}`", - integration_target_label(*target) - ) + let label = registry + .profile_by_integration_target(*target) + .and_then(|profile| profile.integration()) + .map(|profile| profile.cli_label()) + .unwrap_or("unknown"); + format!("`herdr integration install {label}`") }) .collect(); @@ -400,51 +215,23 @@ pub(crate) fn print_outdated_update_notice() -> bool { true } -/// Whether the Herdr-owned Grok hook config exactly matches the installed -/// integration. JSON formatting and object key order do not affect validity. -fn grok_hook_config_is_valid(hook_path: &Path) -> bool { - let Some(hooks_dir) = hook_path.parent() else { - return false; - }; - let config_path = hooks_dir.join(super::GROK_HOOK_CONFIG_INSTALL_NAME); - fs::read_to_string(config_path) - .ok() - .and_then(|content| serde_json::from_str::(&content).ok()) - .is_some_and(|config| config == super::targets::grok_hook_config(hook_path)) -} - -fn opencode_tui_integration_is_valid(plugin_path: &Path, expected_version: u32) -> bool { - let Some(config_dir) = plugin_path.parent().and_then(Path::parent) else { - return false; - }; - let tui_plugin_path = config_dir.join(super::OPENCODE_TUI_PLUGIN_INSTALL_NAME); - let tui_plugin_current = fs::read_to_string(tui_plugin_path) +#[cfg(test)] +pub(crate) fn integration_status_at( + target: crate::api::schema::IntegrationTarget, + path: PathBuf, + expected_version: u32, +) -> super::IntegrationStatus { + let adapter = registered_integration_profile(target) .ok() - .and_then(|content| parse_integration_version(&content)) - .is_some_and(|version| version >= expected_version); - tui_plugin_current - && super::opencode_config::tui_plugin_is_configured( - config_dir, - super::OPENCODE_TUI_PLUGIN_SPEC, - ) - && (!config_dir.join("cli.json").exists() - || (super::opencode_config::cli_plugin_is_configured( - config_dir, - super::OPENCODE_V2_TUI_PLUGIN_SPEC, - ) && fs::read_to_string( - config_dir - .join(super::OPENCODE_V2_TUI_PLUGIN_DIR) - .join("tui.js"), - ) - .ok() - .and_then(|content| parse_integration_version(&content)) - .is_some_and(|version| version >= expected_version))) + .map(|profile| profile.adapter()); + integration_status_with_adapter(target, path, expected_version, adapter) } -pub(crate) fn integration_status_at( +fn integration_status_with_adapter( target: crate::api::schema::IntegrationTarget, path: PathBuf, expected_version: u32, + adapter: Option, ) -> super::IntegrationStatus { if !path.is_file() { return super::IntegrationStatus { @@ -465,19 +252,13 @@ pub(crate) fn integration_status_at( super::IntegrationStatusKind::Outdated }; - // Grok only invokes the hook when the herdr-owned `hooks/herdr.json` - // registers it, so a current hook script with a missing or broken config - // is a nonfunctional install: report it as outdated so `herdr integration - // status` flags it and a reinstall rewrites both files. - if target == crate::api::schema::IntegrationTarget::Grok - && state == super::IntegrationStatusKind::Current - && !grok_hook_config_is_valid(&path) - { - state = super::IntegrationStatusKind::Outdated; - } - if target == crate::api::schema::IntegrationTarget::Opencode - && state == super::IntegrationStatusKind::Current - && !opencode_tui_integration_is_valid(&path, expected_version) + // Some integrations need companion config or artifacts in addition to the + // primary versioned artifact. A current primary artifact with invalid + // companions is nonfunctional, so report it as outdated and let reinstall + // repair the complete integration. + if state == super::IntegrationStatusKind::Current + && !adapter + .is_some_and(|adapter| adapter.current_install_extra_is_valid(&path, expected_version)) { state = super::IntegrationStatusKind::Outdated; } diff --git a/src/integration/targets.rs b/src/integration/targets.rs index d204c55293..1eb5a921c4 100644 --- a/src/integration/targets.rs +++ b/src/integration/targets.rs @@ -25,7 +25,8 @@ use super::env::{ opencode_dir, opencode_state_dir, pi_extension_dir, qodercli_dir, qwen_dir, }; use super::file_ops::{ - make_executable, remove_dir_all_if_exists, remove_file_if_exists, remove_legacy_bash_hook_file, + atomic_replace_asset, remove_dir_all_if_exists, remove_file_if_exists, + remove_legacy_bash_hook_file, }; use super::opencode_config::{ add_cli_plugin, add_tui_plugin, remove_cli_plugin, remove_tui_plugin, tui_config_path, @@ -42,24 +43,54 @@ use super::types::{ OpenCodeUninstallResult, PiUninstallResult, QodercliInstallPaths, QodercliUninstallResult, QwenInstallPaths, QwenUninstallResult, }; -use super::{ - ANTIGRAVITY_CLI_HOOK_ASSET, ANTIGRAVITY_CLI_HOOK_BLOCK_NAME, ANTIGRAVITY_CLI_HOOK_EVENTS, - ANTIGRAVITY_CLI_HOOK_INSTALL_NAME, ANTIGRAVITY_CLI_HOOK_TIMEOUT_SEC, CLAUDE_HOOK_ASSET, - CLAUDE_HOOK_INSTALL_NAME, CODEX_HOOK_ASSET, CODEX_HOOK_INSTALL_NAME, COPILOT_HOOK_ASSET, - COPILOT_HOOK_EVENTS, COPILOT_HOOK_INSTALL_NAME, COPILOT_REMOVED_LIFECYCLE_HOOK_EVENTS, - CURSOR_HOOK_ASSET, CURSOR_HOOK_INSTALL_NAME, DEVIN_HOOK_ASSET, DEVIN_HOOK_EVENTS, - DEVIN_HOOK_INSTALL_NAME, DEVIN_REMOVED_LIFECYCLE_HOOK_EVENTS, DROID_HOOK_ASSET, - DROID_HOOK_EVENTS, DROID_HOOK_INSTALL_NAME, DROID_REMOVED_LIFECYCLE_HOOK_EVENTS, - GROK_HOOK_ASSET, GROK_HOOK_CONFIG_INSTALL_NAME, GROK_HOOK_INSTALL_NAME, - HERMES_PLUGIN_INIT_ASSET, HERMES_PLUGIN_INIT_INSTALL_NAME, HERMES_PLUGIN_MANIFEST_ASSET, - HERMES_PLUGIN_MANIFEST_INSTALL_NAME, KILO_PLUGIN_ASSET, KILO_PLUGIN_INSTALL_NAME, - KIMI_HOOK_ASSET, KIMI_HOOK_INSTALL_NAME, MASTRACODE_HOOK_ASSET, MASTRACODE_HOOK_EVENTS, - MASTRACODE_HOOK_INSTALL_NAME, MASTRACODE_HOOK_TIMEOUT_MS, MASTRACODE_REMOVED_HOOK_EVENTS, - OMP_EXTENSION_ASSET, OMP_EXTENSION_INSTALL_NAME, OPENCODE_PLUGIN_ASSET, - OPENCODE_PLUGIN_INSTALL_NAME, OPENCODE_TUI_PLUGIN_ASSET, OPENCODE_TUI_PLUGIN_INSTALL_NAME, - OPENCODE_TUI_PLUGIN_SPEC, PI_EXTENSION_ASSET, PI_EXTENSION_INSTALL_NAME, QODERCLI_HOOK_ASSET, - QODERCLI_HOOK_EVENTS, QODERCLI_HOOK_INSTALL_NAME, QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS, - QWEN_HOOK_ASSET, QWEN_HOOK_EVENTS, QWEN_HOOK_INSTALL_NAME, +use crate::integration::builtin::agy::{ + HOOK_BLOCK_NAME as ANTIGRAVITY_CLI_HOOK_BLOCK_NAME, HOOK_EVENTS as ANTIGRAVITY_CLI_HOOK_EVENTS, + HOOK_INSTALL_NAME as ANTIGRAVITY_CLI_HOOK_INSTALL_NAME, + HOOK_TIMEOUT_SEC as ANTIGRAVITY_CLI_HOOK_TIMEOUT_SEC, +}; +use crate::integration::builtin::claude::HOOK_INSTALL_NAME as CLAUDE_HOOK_INSTALL_NAME; +use crate::integration::builtin::codex::HOOK_INSTALL_NAME as CODEX_HOOK_INSTALL_NAME; +use crate::integration::builtin::copilot::{ + HOOK_EVENTS as COPILOT_HOOK_EVENTS, HOOK_INSTALL_NAME as COPILOT_HOOK_INSTALL_NAME, + REMOVED_LIFECYCLE_HOOK_EVENTS as COPILOT_REMOVED_LIFECYCLE_HOOK_EVENTS, +}; +use crate::integration::builtin::cursor::HOOK_INSTALL_NAME as CURSOR_HOOK_INSTALL_NAME; +use crate::integration::builtin::devin::{ + HOOK_EVENTS as DEVIN_HOOK_EVENTS, HOOK_INSTALL_NAME as DEVIN_HOOK_INSTALL_NAME, + REMOVED_LIFECYCLE_HOOK_EVENTS as DEVIN_REMOVED_LIFECYCLE_HOOK_EVENTS, +}; +use crate::integration::builtin::droid::{ + HOOK_EVENTS as DROID_HOOK_EVENTS, HOOK_INSTALL_NAME as DROID_HOOK_INSTALL_NAME, + REMOVED_LIFECYCLE_HOOK_EVENTS as DROID_REMOVED_LIFECYCLE_HOOK_EVENTS, +}; +use crate::integration::builtin::grok::{ + HOOK_CONFIG_INSTALL_NAME as GROK_HOOK_CONFIG_INSTALL_NAME, + HOOK_INSTALL_NAME as GROK_HOOK_INSTALL_NAME, +}; +use crate::integration::builtin::hermes::{ + PLUGIN_INIT_INSTALL_NAME as HERMES_PLUGIN_INIT_INSTALL_NAME, + PLUGIN_MANIFEST_INSTALL_NAME as HERMES_PLUGIN_MANIFEST_INSTALL_NAME, +}; +use crate::integration::builtin::kilo::PLUGIN_INSTALL_NAME as KILO_PLUGIN_INSTALL_NAME; +use crate::integration::builtin::kimi::HOOK_INSTALL_NAME as KIMI_HOOK_INSTALL_NAME; +use crate::integration::builtin::mastracode::{ + HOOK_EVENTS as MASTRACODE_HOOK_EVENTS, HOOK_INSTALL_NAME as MASTRACODE_HOOK_INSTALL_NAME, + HOOK_TIMEOUT_MS as MASTRACODE_HOOK_TIMEOUT_MS, + REMOVED_HOOK_EVENTS as MASTRACODE_REMOVED_HOOK_EVENTS, +}; +use crate::integration::builtin::omp::EXTENSION_INSTALL_NAME as OMP_EXTENSION_INSTALL_NAME; +use crate::integration::builtin::opencode::{ + PLUGIN_INSTALL_NAME as OPENCODE_PLUGIN_INSTALL_NAME, + TUI_PLUGIN_INSTALL_NAME as OPENCODE_TUI_PLUGIN_INSTALL_NAME, + TUI_PLUGIN_SPEC as OPENCODE_TUI_PLUGIN_SPEC, +}; +use crate::integration::builtin::pi::EXTENSION_INSTALL_NAME as PI_EXTENSION_INSTALL_NAME; +use crate::integration::builtin::qodercli::{ + HOOK_EVENTS as QODERCLI_HOOK_EVENTS, HOOK_INSTALL_NAME as QODERCLI_HOOK_INSTALL_NAME, + REMOVED_LIFECYCLE_HOOK_EVENTS as QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS, +}; +use crate::integration::builtin::qwen::{ + HOOK_EVENTS as QWEN_HOOK_EVENTS, HOOK_INSTALL_NAME as QWEN_HOOK_INSTALL_NAME, }; fn ensure_extension_dir(dir: &Path, agent: &str) -> io::Result<()> { @@ -75,16 +106,22 @@ fn ensure_extension_dir(dir: &Path, agent: &str) -> io::Result<()> { ))) } -pub(crate) fn install_pi() -> io::Result { +pub(crate) fn install_pi( + profile: &crate::agents::integration::IntegrationProfile, +) -> io::Result { + let asset = profile.asset(PI_EXTENSION_INSTALL_NAME)?; let dir = pi_extension_dir()?; ensure_extension_dir(&dir, "pi")?; let path = dir.join(PI_EXTENSION_INSTALL_NAME); - fs::write(&path, PI_EXTENSION_ASSET)?; + atomic_replace_asset(&path, asset, false)?; Ok(path) } -pub(crate) fn install_omp() -> io::Result { +pub(crate) fn install_omp( + profile: &crate::agents::integration::IntegrationProfile, +) -> io::Result { + let asset = profile.asset(OMP_EXTENSION_INSTALL_NAME)?; let dir = omp_extension_dir()?; let pi_dir = pi_extension_dir()?; if dir == pi_dir { @@ -97,7 +134,7 @@ pub(crate) fn install_omp() -> io::Result { let removed_legacy_pi_extension = remove_legacy_pi_extension_from_omp_dir(&dir)?; let extension_path = dir.join(OMP_EXTENSION_INSTALL_NAME); - fs::write(&extension_path, OMP_EXTENSION_ASSET)?; + atomic_replace_asset(&extension_path, asset, false)?; Ok(OmpInstallPaths { extension_path, removed_legacy_pi_extension, @@ -119,7 +156,10 @@ pub(crate) fn remove_legacy_pi_extension_from_omp_dir(dir: &Path) -> io::Result< Ok(false) } -pub(crate) fn install_claude() -> io::Result { +pub(crate) fn install_claude( + profile: &crate::agents::integration::IntegrationProfile, +) -> io::Result { + let asset = profile.asset(CLAUDE_HOOK_INSTALL_NAME)?; let dir = claude_dir()?; if !dir.is_dir() { return Err(io::Error::other(format!( @@ -132,8 +172,7 @@ pub(crate) fn install_claude() -> io::Result { fs::create_dir_all(&hooks_dir)?; let hook_path = hooks_dir.join(CLAUDE_HOOK_INSTALL_NAME); - fs::write(&hook_path, CLAUDE_HOOK_ASSET)?; - make_executable(&hook_path)?; + atomic_replace_asset(&hook_path, asset, true)?; let settings_path = dir.join("settings.json"); let existing_settings = if settings_path.is_file() { @@ -154,7 +193,10 @@ pub(crate) fn install_claude() -> io::Result { }) } -pub(crate) fn install_codex() -> io::Result { +pub(crate) fn install_codex( + profile: &crate::agents::integration::IntegrationProfile, +) -> io::Result { + let asset = profile.asset(CODEX_HOOK_INSTALL_NAME)?; let dir = codex_dir()?; if !dir.is_dir() { return Err(io::Error::other(format!( @@ -164,8 +206,7 @@ pub(crate) fn install_codex() -> io::Result { } let hook_path = dir.join(CODEX_HOOK_INSTALL_NAME); - fs::write(&hook_path, CODEX_HOOK_ASSET)?; - make_executable(&hook_path)?; + atomic_replace_asset(&hook_path, asset, true)?; let hooks_path = dir.join("hooks.json"); let mut hooks_file = if hooks_path.is_file() { @@ -217,7 +258,10 @@ pub(crate) fn install_codex() -> io::Result { }) } -pub(crate) fn install_kimi() -> io::Result { +pub(crate) fn install_kimi( + profile: &crate::agents::integration::IntegrationProfile, +) -> io::Result { + let asset = profile.asset(KIMI_HOOK_INSTALL_NAME)?; let dir = kimi_dir()?; if !dir.is_dir() { return Err(io::Error::other(format!( @@ -230,8 +274,7 @@ pub(crate) fn install_kimi() -> io::Result { fs::create_dir_all(&hooks_dir)?; let hook_path = hooks_dir.join(KIMI_HOOK_INSTALL_NAME); - fs::write(&hook_path, KIMI_HOOK_ASSET)?; - make_executable(&hook_path)?; + atomic_replace_asset(&hook_path, asset, true)?; let config_path = dir.join("config.toml"); let existing_config = if config_path.is_file() { @@ -251,7 +294,10 @@ pub(crate) fn install_kimi() -> io::Result { }) } -pub(crate) fn install_copilot() -> io::Result { +pub(crate) fn install_copilot( + profile: &crate::agents::integration::IntegrationProfile, +) -> io::Result { + let asset = profile.asset(COPILOT_HOOK_INSTALL_NAME)?; let dir = copilot_dir()?; if !dir.is_dir() { return Err(io::Error::other(format!( @@ -264,8 +310,7 @@ pub(crate) fn install_copilot() -> io::Result { fs::create_dir_all(&hooks_dir)?; let hook_path = hooks_dir.join(COPILOT_HOOK_INSTALL_NAME); - fs::write(&hook_path, COPILOT_HOOK_ASSET)?; - make_executable(&hook_path)?; + atomic_replace_asset(&hook_path, asset, true)?; let settings_path = dir.join("settings.json"); let mut settings = if settings_path.is_file() { @@ -305,7 +350,10 @@ pub(crate) fn install_copilot() -> io::Result { }) } -pub(crate) fn install_devin() -> io::Result { +pub(crate) fn install_devin( + profile: &crate::agents::integration::IntegrationProfile, +) -> io::Result { + let asset = profile.asset(DEVIN_HOOK_INSTALL_NAME)?; let dir = devin_dir()?; if !dir.is_dir() { return Err(io::Error::other(format!( @@ -315,8 +363,7 @@ pub(crate) fn install_devin() -> io::Result { } let hook_path = dir.join(DEVIN_HOOK_INSTALL_NAME); - fs::write(&hook_path, DEVIN_HOOK_ASSET)?; - make_executable(&hook_path)?; + atomic_replace_asset(&hook_path, asset, true)?; let settings_path = dir.join("config.json"); let mut settings = if settings_path.is_file() { @@ -361,7 +408,10 @@ pub(crate) fn install_devin() -> io::Result { }) } -pub(crate) fn install_droid() -> io::Result { +pub(crate) fn install_droid( + profile: &crate::agents::integration::IntegrationProfile, +) -> io::Result { + let asset = profile.asset(DROID_HOOK_INSTALL_NAME)?; let dir = droid_dir()?; if !dir.is_dir() { return Err(io::Error::other(format!( @@ -374,8 +424,7 @@ pub(crate) fn install_droid() -> io::Result { fs::create_dir_all(&hooks_dir)?; let hook_path = hooks_dir.join(DROID_HOOK_INSTALL_NAME); - fs::write(&hook_path, DROID_HOOK_ASSET)?; - make_executable(&hook_path)?; + atomic_replace_asset(&hook_path, asset, true)?; let settings_path = dir.join("settings.json"); let mut settings = if settings_path.is_file() { @@ -451,7 +500,11 @@ pub(crate) fn install_droid() -> io::Result { }) } -pub(crate) fn install_opencode() -> io::Result { +pub(crate) fn install_opencode( + profile: &crate::agents::integration::IntegrationProfile, +) -> io::Result { + let plugin = profile.asset(OPENCODE_PLUGIN_INSTALL_NAME)?; + let tui_plugin = profile.asset(OPENCODE_TUI_PLUGIN_INSTALL_NAME)?; let dir = opencode_dir()?; if !dir.is_dir() { return Err(io::Error::other(format!( @@ -465,13 +518,17 @@ pub(crate) fn install_opencode() -> io::Result { fs::create_dir_all(&plugins_dir)?; let plugin_path = plugins_dir.join(OPENCODE_PLUGIN_INSTALL_NAME); - fs::write(&plugin_path, OPENCODE_PLUGIN_ASSET)?; + atomic_replace_asset(&plugin_path, plugin, false)?; let tui_plugin_path = dir.join(OPENCODE_TUI_PLUGIN_INSTALL_NAME); - fs::write(&tui_plugin_path, OPENCODE_TUI_PLUGIN_ASSET)?; + atomic_replace_asset(&tui_plugin_path, tui_plugin, false)?; let tui_config_path = add_tui_plugin(&dir, OPENCODE_TUI_PLUGIN_SPEC)?; let v2_dir = dir.join(super::OPENCODE_V2_TUI_PLUGIN_DIR); fs::create_dir_all(&v2_dir)?; - fs::write(v2_dir.join("tui.js"), super::OPENCODE_V2_TUI_PLUGIN_ASSET)?; + atomic_replace_asset( + &v2_dir.join("tui.js"), + &crate::integration::builtin::opencode::v2_tui_entrypoint(profile.expected_version()), + false, + )?; let cli_config_path = add_cli_plugin( &dir, &opencode_state_dir()?, @@ -486,7 +543,10 @@ pub(crate) fn install_opencode() -> io::Result { }) } -pub(crate) fn install_kilo() -> io::Result { +pub(crate) fn install_kilo( + profile: &crate::agents::integration::IntegrationProfile, +) -> io::Result { + let asset = profile.asset(KILO_PLUGIN_INSTALL_NAME)?; let dir = kilo_dir()?; if !dir.is_dir() { return Err(io::Error::other(format!( @@ -499,12 +559,16 @@ pub(crate) fn install_kilo() -> io::Result { fs::create_dir_all(&plugins_dir)?; let plugin_path = plugins_dir.join(KILO_PLUGIN_INSTALL_NAME); - fs::write(&plugin_path, KILO_PLUGIN_ASSET)?; + atomic_replace_asset(&plugin_path, asset, false)?; Ok(KiloInstallPaths { plugin_path }) } -pub(crate) fn install_hermes() -> io::Result { +pub(crate) fn install_hermes( + profile: &crate::agents::integration::IntegrationProfile, +) -> io::Result { + let manifest = profile.asset(HERMES_PLUGIN_MANIFEST_INSTALL_NAME)?; + let plugin = profile.asset(HERMES_PLUGIN_INIT_INSTALL_NAME)?; let dir = hermes_dir()?; if !dir.is_dir() { return Err(io::Error::other(format!( @@ -515,13 +579,15 @@ pub(crate) fn install_hermes() -> io::Result { let plugin_dir = hermes_plugin_dir()?; fs::create_dir_all(&plugin_dir)?; - fs::write( - plugin_dir.join(HERMES_PLUGIN_MANIFEST_INSTALL_NAME), - HERMES_PLUGIN_MANIFEST_ASSET, + atomic_replace_asset( + &plugin_dir.join(HERMES_PLUGIN_MANIFEST_INSTALL_NAME), + manifest, + false, )?; - fs::write( - plugin_dir.join(HERMES_PLUGIN_INIT_INSTALL_NAME), - HERMES_PLUGIN_INIT_ASSET, + atomic_replace_asset( + &plugin_dir.join(HERMES_PLUGIN_INIT_INSTALL_NAME), + plugin, + false, )?; let config_path = dir.join("config.yaml"); @@ -904,7 +970,10 @@ pub(crate) fn uninstall_hermes() -> io::Result { }) } -pub(crate) fn install_qodercli() -> io::Result { +pub(crate) fn install_qodercli( + profile: &crate::agents::integration::IntegrationProfile, +) -> io::Result { + let asset = profile.asset(QODERCLI_HOOK_INSTALL_NAME)?; let dir = qodercli_dir()?; if !dir.is_dir() { return Err(io::Error::other(format!( @@ -917,8 +986,7 @@ pub(crate) fn install_qodercli() -> io::Result { fs::create_dir_all(&hooks_dir)?; let hook_path = hooks_dir.join(QODERCLI_HOOK_INSTALL_NAME); - fs::write(&hook_path, QODERCLI_HOOK_ASSET)?; - make_executable(&hook_path)?; + atomic_replace_asset(&hook_path, asset, true)?; // Register the hook in ~/.qoder/settings.json. The schema mirrors claude // settings.json (per https://docs.qoder.com/zh/cli/hooks): a top-level @@ -968,7 +1036,10 @@ pub(crate) fn install_qodercli() -> io::Result { }) } -pub(crate) fn install_qwen() -> io::Result { +pub(crate) fn install_qwen( + profile: &crate::agents::integration::IntegrationProfile, +) -> io::Result { + let asset = profile.asset(QWEN_HOOK_INSTALL_NAME)?; let dir = qwen_dir()?; if !dir.is_dir() { return Err(io::Error::other(format!( @@ -981,8 +1052,7 @@ pub(crate) fn install_qwen() -> io::Result { fs::create_dir_all(&hooks_dir)?; let hook_path = hooks_dir.join(QWEN_HOOK_INSTALL_NAME); - fs::write(&hook_path, QWEN_HOOK_ASSET)?; - make_executable(&hook_path)?; + atomic_replace_asset(&hook_path, asset, true)?; let settings_path = dir.join("settings.json"); let mut settings = if settings_path.is_file() { @@ -1021,7 +1091,10 @@ pub(crate) fn install_qwen() -> io::Result { }) } -pub(crate) fn install_cursor() -> io::Result { +pub(crate) fn install_cursor( + profile: &crate::agents::integration::IntegrationProfile, +) -> io::Result { + let asset = profile.asset(CURSOR_HOOK_INSTALL_NAME)?; let dir = cursor_dir()?; if !dir.is_dir() { return Err(io::Error::other(format!( @@ -1031,8 +1104,7 @@ pub(crate) fn install_cursor() -> io::Result { } let hook_path = dir.join(CURSOR_HOOK_INSTALL_NAME); - fs::write(&hook_path, CURSOR_HOOK_ASSET)?; - make_executable(&hook_path)?; + atomic_replace_asset(&hook_path, asset, true)?; let hooks_path = dir.join("hooks.json"); let mut hooks_file = if hooks_path.is_file() { @@ -1219,14 +1291,16 @@ pub(crate) fn mastracode_hook_command(hook_path: &Path, action: &str) -> String } } -pub(crate) fn install_mastracode() -> io::Result { +pub(crate) fn install_mastracode( + profile: &crate::agents::integration::IntegrationProfile, +) -> io::Result { + let asset = profile.asset(MASTRACODE_HOOK_INSTALL_NAME)?; let mastracode_home = mastracode_dir()?; let hook_dir = mastracode_home.join("hooks"); fs::create_dir_all(&hook_dir)?; let hook_path = hook_dir.join(MASTRACODE_HOOK_INSTALL_NAME); - fs::write(&hook_path, MASTRACODE_HOOK_ASSET)?; - make_executable(&hook_path)?; + atomic_replace_asset(&hook_path, asset, true)?; let hooks_path = mastracode_home.join("hooks.json"); let mut hooks_file = if hooks_path.is_file() { @@ -1314,7 +1388,10 @@ pub(crate) fn uninstall_mastracode() -> io::Result { }) } -pub(crate) fn install_antigravity_cli() -> io::Result { +pub(crate) fn install_antigravity_cli( + profile: &crate::agents::integration::IntegrationProfile, +) -> io::Result { + let asset = profile.asset(ANTIGRAVITY_CLI_HOOK_INSTALL_NAME)?; let dir = antigravity_cli_dir()?; if !dir.is_dir() { return Err(io::Error::other(format!( @@ -1327,8 +1404,7 @@ pub(crate) fn install_antigravity_cli() -> io::Result Value { }) } -pub(crate) fn install_grok() -> io::Result { +pub(crate) fn install_grok( + profile: &crate::agents::integration::IntegrationProfile, +) -> io::Result { + let asset = profile.asset(GROK_HOOK_INSTALL_NAME)?; let dir = grok_dir()?; if !dir.is_dir() { return Err(io::Error::other(format!( @@ -1476,8 +1555,7 @@ pub(crate) fn install_grok() -> io::Result { fs::create_dir_all(&hooks_dir)?; let hook_path = hooks_dir.join(GROK_HOOK_INSTALL_NAME); - fs::write(&hook_path, GROK_HOOK_ASSET)?; - make_executable(&hook_path)?; + atomic_replace_asset(&hook_path, asset, true)?; let config_path = hooks_dir.join(GROK_HOOK_CONFIG_INSTALL_NAME); fs::write( diff --git a/src/integration/tests.rs b/src/integration/tests.rs index dc597d1d53..044dcf29bf 100644 --- a/src/integration/tests.rs +++ b/src/integration/tests.rs @@ -6,13 +6,351 @@ use super::registry::*; use super::targets::*; use super::types::*; use super::version::*; -use super::*; + +use crate::integration::builtin::agy::{ + HOOK_ASSET as ANTIGRAVITY_CLI_HOOK_ASSET, HOOK_BLOCK_NAME as ANTIGRAVITY_CLI_HOOK_BLOCK_NAME, + HOOK_EVENTS as ANTIGRAVITY_CLI_HOOK_EVENTS, + HOOK_INSTALL_NAME as ANTIGRAVITY_CLI_HOOK_INSTALL_NAME, + HOOK_TIMEOUT_SEC as ANTIGRAVITY_CLI_HOOK_TIMEOUT_SEC, +}; +use crate::integration::builtin::claude::{ + HOOK_ASSET as CLAUDE_HOOK_ASSET, HOOK_INSTALL_NAME as CLAUDE_HOOK_INSTALL_NAME, +}; +use crate::integration::builtin::codex::{ + executable_name as codex_executable_name, HOOK_ASSET as CODEX_HOOK_ASSET, + HOOK_INSTALL_NAME as CODEX_HOOK_INSTALL_NAME, +}; +use crate::integration::builtin::copilot::{ + HOOK_ASSET as COPILOT_HOOK_ASSET, HOOK_INSTALL_NAME as COPILOT_HOOK_INSTALL_NAME, + REMOVED_LIFECYCLE_HOOK_EVENTS as COPILOT_REMOVED_LIFECYCLE_HOOK_EVENTS, +}; +use crate::integration::builtin::cursor::{ + HOOK_ASSET as CURSOR_HOOK_ASSET, HOOK_INSTALL_NAME as CURSOR_HOOK_INSTALL_NAME, +}; +use crate::integration::builtin::devin::{ + HOOK_ASSET as DEVIN_HOOK_ASSET, HOOK_EVENTS as DEVIN_HOOK_EVENTS, + HOOK_INSTALL_NAME as DEVIN_HOOK_INSTALL_NAME, + REMOVED_LIFECYCLE_HOOK_EVENTS as DEVIN_REMOVED_LIFECYCLE_HOOK_EVENTS, +}; +use crate::integration::builtin::droid::{ + HOOK_ASSET as DROID_HOOK_ASSET, HOOK_EVENTS as DROID_HOOK_EVENTS, + HOOK_INSTALL_NAME as DROID_HOOK_INSTALL_NAME, +}; +use crate::integration::builtin::grok::{ + HOOK_ASSET as GROK_HOOK_ASSET, HOOK_CONFIG_INSTALL_NAME as GROK_HOOK_CONFIG_INSTALL_NAME, + HOOK_INSTALL_NAME as GROK_HOOK_INSTALL_NAME, +}; +use crate::integration::builtin::hermes::{ + PLUGIN_INIT_ASSET as HERMES_PLUGIN_INIT_ASSET, + PLUGIN_INIT_INSTALL_NAME as HERMES_PLUGIN_INIT_INSTALL_NAME, + PLUGIN_INSTALL_NAME as HERMES_PLUGIN_INSTALL_NAME, + PLUGIN_MANIFEST_ASSET as HERMES_PLUGIN_MANIFEST_ASSET, + PLUGIN_MANIFEST_INSTALL_NAME as HERMES_PLUGIN_MANIFEST_INSTALL_NAME, +}; +use crate::integration::builtin::kilo::{ + PLUGIN_ASSET as KILO_PLUGIN_ASSET, PLUGIN_INSTALL_NAME as KILO_PLUGIN_INSTALL_NAME, +}; +use crate::integration::builtin::kimi::{ + ASK_USER_QUESTION_MATCHER as KIMI_ASK_USER_QUESTION_MATCHER, + CONFIG_BLOCK_BEGIN as KIMI_CONFIG_BLOCK_BEGIN, CONFIG_BLOCK_END as KIMI_CONFIG_BLOCK_END, + HOOK_ASSET as KIMI_HOOK_ASSET, HOOK_EVENTS as KIMI_HOOK_EVENTS, + HOOK_INSTALL_NAME as KIMI_HOOK_INSTALL_NAME, MIN_VERSION as KIMI_MIN_VERSION, + OTHER_TOOL_MATCHER as KIMI_OTHER_TOOL_MATCHER, +}; +use crate::integration::builtin::mastracode::{ + HOOK_ASSET as MASTRACODE_HOOK_ASSET, HOOK_EVENTS as MASTRACODE_HOOK_EVENTS, + HOOK_INSTALL_NAME as MASTRACODE_HOOK_INSTALL_NAME, + HOOK_TIMEOUT_MS as MASTRACODE_HOOK_TIMEOUT_MS, +}; +use crate::integration::builtin::omp::{ + EXTENSION_ASSET as OMP_EXTENSION_ASSET, EXTENSION_INSTALL_NAME as OMP_EXTENSION_INSTALL_NAME, +}; +use crate::integration::builtin::opencode::{ + PLUGIN_ASSET as OPENCODE_PLUGIN_ASSET, PLUGIN_INSTALL_NAME as OPENCODE_PLUGIN_INSTALL_NAME, + TUI_PLUGIN_ASSET as OPENCODE_TUI_PLUGIN_ASSET, + TUI_PLUGIN_INSTALL_NAME as OPENCODE_TUI_PLUGIN_INSTALL_NAME, + TUI_PLUGIN_SPEC as OPENCODE_TUI_PLUGIN_SPEC, V2_TUI_PLUGIN_DIR as OPENCODE_V2_TUI_PLUGIN_DIR, + V2_TUI_PLUGIN_SPEC as OPENCODE_V2_TUI_PLUGIN_SPEC, +}; +use crate::integration::builtin::pi::{ + EXTENSION_ASSET as PI_EXTENSION_ASSET, EXTENSION_INSTALL_NAME as PI_EXTENSION_INSTALL_NAME, +}; +use crate::integration::builtin::qodercli::{ + HOOK_ASSET as QODERCLI_HOOK_ASSET, HOOK_EVENTS as QODERCLI_HOOK_EVENTS, + HOOK_INSTALL_NAME as QODERCLI_HOOK_INSTALL_NAME, +}; +use crate::integration::builtin::qwen::{ + HOOK_ASSET as QWEN_HOOK_ASSET, HOOK_INSTALL_NAME as QWEN_HOOK_INSTALL_NAME, +}; use std::fs; use std::path::{Path, PathBuf}; use serde_json::{json, Map, Value}; +fn integration_profile( + target: crate::api::schema::IntegrationTarget, +) -> std::sync::Arc { + crate::agents::registry() + .profile_by_integration_target(target) + .and_then(|profile| profile.integration()) + .cloned() + .expect("integration target profile") +} + +macro_rules! bundled_installers { + ($($install:ident, $target:ident, $result:ty);+ $(;)?) => {$ ( + fn $install() -> std::io::Result<$result> { + super::targets::$install(&integration_profile(crate::api::schema::IntegrationTarget::$target)) + } + )+}; +} + +bundled_installers! { + install_pi, Pi, PathBuf; + install_omp, Omp, OmpInstallPaths; + install_claude, Claude, ClaudeInstallPaths; + install_codex, Codex, CodexInstallPaths; + install_copilot, Copilot, CopilotInstallPaths; + install_devin, Devin, DevinInstallPaths; + install_droid, Droid, DroidInstallPaths; + install_kimi, Kimi, KimiInstallPaths; + install_opencode, Opencode, OpenCodeInstallPaths; + install_kilo, Kilo, KiloInstallPaths; + install_hermes, Hermes, HermesInstallPaths; + install_qodercli, Qodercli, QodercliInstallPaths; + install_qwen, Qwen, QwenInstallPaths; + install_cursor, Cursor, CursorInstallPaths; + install_mastracode, Mastracode, MastracodeInstallPaths; + install_antigravity_cli, AntigravityCli, AntigravityCliInstallPaths; + install_grok, Grok, GrokInstallPaths; +} + +fn expected_integration_version(target: crate::api::schema::IntegrationTarget) -> u32 { + integration_profile(target).expected_version() +} + +#[test] +fn integration_registry_routes_current_platform_metadata_and_adapters() { + let expected_targets = crate::api::schema::IntegrationTarget::ALL; + let registry = crate::agents::registry(); + let profiles = registry + .integration_capable_profiles() + .map(|profile| profile.integration().expect("integration metadata")) + .collect::>(); + + assert_eq!( + profiles + .iter() + .map(|profile| profile.target()) + .collect::>(), + expected_targets + ); + for profile in &profiles { + let target = profile.target(); + assert_eq!(integration_target_label(target), profile.cli_label()); + assert_eq!( + integration_target_command_names(target), + profile.command_names() + ); + assert_eq!( + integration_target_command(target), + profile.command_names()[0] + ); + assert_eq!(integration_target_supported(target), profile.supported()); + let _adapter = profile.adapter(); + } +} + +#[test] +fn builtin_bindings_match_registry_integration_targets() { + let unknown = crate::detect::Agent::parse("future-agent").unwrap(); + assert!(super::builtin::binding(unknown).is_none()); + let registry = crate::agents::registry(); + for agent in crate::detect::Agent::ALL { + let profile = registry.profile_for_agent(agent); + let binding = super::builtin::binding(agent); + assert_eq!( + binding.map(|(target, _)| target), + profile + .integration() + .map(|integration| integration.target()), + "trusted integration target for {agent:?}" + ); + assert_eq!( + super::builtin::adapter(agent).is_some(), + profile.integration().is_some(), + "trusted integration adapter for {agent:?}" + ); + } +} + +// Parse only embedded metadata: compatibility checks must not consult HOME, +// installed agent layouts, the global registry, or any subprocess. +fn builtin_contract_packages() -> Vec { + macro_rules! package { + ($id:literal) => { + crate::agents::source::Package { + identity: toml::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/vendor/agent-registry/agents/", + $id, + "/agent.toml" + ))) + .unwrap(), + integration: Some( + toml::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/vendor/agent-registry/agents/", + $id, + "/integration.toml" + ))) + .unwrap(), + ), + process: None, + resume: None, + assets: Default::default(), + detection: None, + } + }; + } + vec![ + package!("agy"), + package!("claude"), + package!("codex"), + package!("copilot"), + package!("cursor"), + package!("devin"), + package!("droid"), + package!("grok"), + package!("hermes"), + package!("kilo"), + package!("kimi"), + package!("mastracode"), + package!("omp"), + package!("opencode"), + package!("pi"), + package!("qodercli"), + package!("qwen"), + ] +} + +#[test] +fn builtin_asset_contracts_accept_bundled_metadata_and_reordering() { + for mut package in builtin_contract_packages() { + super::builtin::validate_package(&package).unwrap(); + package.integration.as_mut().unwrap().assets.reverse(); + super::builtin::validate_package(&package).unwrap(); + } +} + +#[test] +fn builtin_asset_contracts_reject_ignored_metadata_changes_on_both_platforms() { + for package in builtin_contract_packages() { + let integration = package.integration.as_ref().unwrap(); + for index in 0..integration.assets.len() { + for field in ["install_name", "path", "role", "platform"] { + let mut changed = package.clone(); + let asset = &mut changed.integration.as_mut().unwrap().assets[index]; + match field { + "install_name" => asset.install_name = "renamed-reporter.js".into(), + "path" => asset.path = "assets/renamed-reporter.js".into(), + "role" => { + asset.role = if asset.role == "reporter" { + "manifest" + } else { + "reporter" + } + .into() + } + "platform" => { + asset.platform = if asset.platform == "unix" { + "windows" + } else { + "unix" + } + .into() + } + _ => unreachable!(), + } + let error = super::builtin::validate_package(&changed).unwrap_err(); + assert!(error.contains(&package.identity.id)); + assert!( + error.contains("asset contract mismatch"), + "{field}: {error}" + ); + } + } + for unix in [true, false] { + let mut changed = package.clone(); + let support = &mut changed.integration.as_mut().unwrap().supported; + if unix { + support.unix = false; + } else { + support.windows = false; + } + assert!(super::builtin::validate_package(&changed).is_err()); + } + let mut missing = package.clone(); + missing.integration.as_mut().unwrap().assets.pop(); + assert!(super::builtin::validate_package(&missing).is_err()); + let mut extra = package.clone(); + extra + .integration + .as_mut() + .unwrap() + .assets + .push(integration.assets[0].clone()); + assert!(super::builtin::validate_package(&extra).is_err()); + if integration.assets.len() > 1 { + let mut duplicate = package.clone(); + duplicate.integration.as_mut().unwrap().assets[1] = integration.assets[0].clone(); + assert!(super::builtin::validate_package(&duplicate).is_err()); + } + } +} + +#[test] +fn builtin_asset_contracts_allow_new_bytes_versions_and_inert_unknown_packages() { + const IDENTITY: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/vendor/agent-registry/agents/pi/agent.toml" + )); + const INTEGRATION: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/vendor/agent-registry/agents/pi/integration.toml" + )); + let mut metadata: toml::Value = toml::from_str(INTEGRATION).unwrap(); + let version = metadata["versions"]["unix"].as_integer().unwrap() + 1; + metadata["versions"]["unix"] = version.into(); + metadata["versions"]["windows"] = version.into(); + let definition = toml::to_string(&metadata).unwrap(); + let asset = format!("// HERDR_INTEGRATION_ID=pi\n// HERDR_INTEGRATION_VERSION={version}\n// new reporter bytes\n"); + let packages = crate::agents::source::load_packages(&[ + ("agents/pi/agent.toml", IDENTITY), + ("agents/pi/integration.toml", &definition), + ("agents/pi/assets/herdr-agent-state.ts", &asset), + ]) + .unwrap(); + super::builtin::validate_package(&packages[0]).unwrap(); + + let identity = IDENTITY.replace("\"pi\"", "\"future-agent\""); + let definition = definition + .replace("\"pi\"", "\"future-agent\"") + .replace("herdr-agent-state.ts", "custom-reporter.ts"); + let asset = asset.replace( + "HERDR_INTEGRATION_ID=pi", + "HERDR_INTEGRATION_ID=future-agent", + ); + let packages = crate::agents::source::load_packages(&[ + ("agents/future-agent/agent.toml", &identity), + ("agents/future-agent/integration.toml", &definition), + ("agents/future-agent/assets/custom-reporter.ts", &asset), + ]) + .unwrap(); + super::builtin::validate_package(&packages[0]).unwrap(); +} + #[test] fn windows_powershell_encoded_hook_command_preserves_script_invocation() { use base64::Engine; @@ -72,13 +410,21 @@ fn extract_version_triple_orders_versions() { } #[test] -fn agent_version_requirement_only_set_for_kimi() { - let requirement = agent_version_requirement(crate::api::schema::IntegrationTarget::Kimi) +fn agent_version_requirement_only_set_for_kimi_adapter() { + let requirement = integration_profile(crate::api::schema::IntegrationTarget::Kimi) + .adapter() + .agent_version_requirement() .expect("kimi must have a version requirement"); assert_eq!(requirement.binary, "kimi"); assert_eq!(requirement.min_version, KIMI_MIN_VERSION); - assert!(agent_version_requirement(crate::api::schema::IntegrationTarget::Claude).is_none()); - assert!(agent_version_requirement(crate::api::schema::IntegrationTarget::Codex).is_none()); + for target in crate::api::schema::IntegrationTarget::ALL { + if target != crate::api::schema::IntegrationTarget::Kimi { + assert!(integration_profile(target) + .adapter() + .agent_version_requirement() + .is_none()); + } + } } #[test] @@ -402,7 +748,7 @@ fn hermes_layout_makes_target_available() { std::env::set_var("LOCALAPPDATA", &local_app_data); std::env::set_var("PATH", ""); - assert!(hermes_install_layout_available()); + assert!(crate::integration::builtin::hermes::install_layout_available()); assert!(integration_target_available( crate::api::schema::IntegrationTarget::Hermes )); @@ -509,8 +855,8 @@ fn integration_recommendations_mark_standalone_codex_available() { fn integration_recommendation_installs_available_or_outdated_targets() { let mut recommendation = IntegrationRecommendation { target: crate::api::schema::IntegrationTarget::Claude, - label: "claude", - command: "claude", + label: "claude".into(), + command: "claude".into(), available: false, path: PathBuf::from("/tmp/herdr-agent-state.sh"), state: IntegrationStatusKind::NotInstalled, @@ -529,6 +875,181 @@ fn integration_recommendation_installs_available_or_outdated_targets() { assert!(!recommendation.needs_install()); } +fn newer_integration_files(id: &str) -> Vec<(String, String)> { + let root = Path::new(concat!( + env!("CARGO_MANIFEST_DIR"), + "/vendor/agent-registry" + )); + let mut files = crate::agents::files::read_source(root).unwrap(); + files.retain(|(path, _)| path.starts_with(&format!("agents/{id}/"))); + let metadata = files + .iter_mut() + .find(|(path, _)| path.ends_with("/integration.toml")) + .unwrap(); + let mut definition: toml::Value = toml::from_str(&metadata.1).unwrap(); + let previous = definition["versions"]["unix"].as_integer().unwrap(); + assert_eq!( + definition["versions"]["windows"].as_integer(), + Some(previous) + ); + definition["versions"]["unix"] = (previous + 1).into(); + definition["versions"]["windows"] = (previous + 1).into(); + metadata.1 = toml::to_string(&definition).unwrap(); + for (path, text) in &mut files { + if path.contains("/assets/") { + *text = text.replace( + &format!("HERDR_INTEGRATION_VERSION={previous}"), + &format!("HERDR_INTEGRATION_VERSION={}", previous + 1), + ); + } + } + files +} + +#[test] +fn registry_refresh_exposes_pi_update_without_installing_until_explicit_application() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + fs::create_dir_all(home.join(".pi/agent/extensions")).unwrap(); + std::env::set_var("HOME", &home); + let old = integration_profile(crate::api::schema::IntegrationTarget::Pi); + let path = super::targets::install_pi(&old).unwrap(); + let previous = fs::read_to_string(&path).unwrap(); + let store = crate::agents::store::RegistryStore::new( + newer_integration_files("pi"), + base.join("active.json"), + ) + .unwrap(); + let current = store.snapshot(); + let selected = current.profile_by_id("pi").unwrap().integration().unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), previous); + let recommendation = integration_recommendations_with_registry(¤t) + .pop() + .unwrap(); + assert_eq!(recommendation.state, IntegrationStatusKind::Outdated); + selected.adapter().install(selected).unwrap(); + assert_eq!( + fs::read_to_string(&path).unwrap(), + selected.asset(PI_EXTENSION_INSTALL_NAME).unwrap() + ); + assert_eq!( + integration_recommendations_with_registry(¤t)[0].state, + IntegrationStatusKind::Current + ); + assert_eq!(old.asset(PI_EXTENSION_INSTALL_NAME).unwrap(), previous); + std::env::remove_var("HOME"); + fs::remove_dir_all(base).unwrap(); +} + +#[test] +fn selected_claude_assets_preserve_unrelated_settings_and_hooks() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let dir = home.join(".claude"); + fs::create_dir_all(&dir).unwrap(); + std::env::set_var("HOME", &home); + let settings_path = dir.join("settings.json"); + let settings = json!({"model":"user-choice", "hooks":{"Stop":[{"hooks":[{"type":"command","command":"user-hook"}]}]}}); + fs::write(&settings_path, serde_json::to_string(&settings).unwrap()).unwrap(); + let current = + crate::agents::store::snapshot_for_test(newer_integration_files("claude"), 2).unwrap(); + let selected = current + .profile_by_id("claude") + .unwrap() + .integration() + .unwrap(); + let installed = super::targets::install_claude(selected).unwrap(); + assert_eq!( + fs::read_to_string(installed.hook_path).unwrap(), + selected.asset(CLAUDE_HOOK_INSTALL_NAME).unwrap() + ); + let actual: Value = serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap(); + assert_eq!(actual["model"], "user-choice"); + assert!(actual["hooks"]["Stop"] + .as_array() + .unwrap() + .contains(&settings["hooks"]["Stop"][0])); + std::env::remove_var("HOME"); + fs::remove_dir_all(base).unwrap(); +} + +#[test] +fn selected_opencode_update_keeps_v2_entrypoint_on_the_selected_version() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let dir = home.join(".config/opencode"); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("cli.json"), r#"{"plugins":["other"]}"#).unwrap(); + std::env::set_var("HOME", &home); + install_opencode().unwrap(); + let current = + crate::agents::store::snapshot_for_test(newer_integration_files("opencode"), 2).unwrap(); + let selected = current + .profile_by_id("opencode") + .unwrap() + .integration() + .unwrap(); + assert_eq!( + integration_recommendations_with_registry(¤t)[0].state, + IntegrationStatusKind::Outdated + ); + let installed = super::targets::install_opencode(selected).unwrap(); + assert_eq!( + fs::read_to_string(installed.plugin_path).unwrap(), + selected.asset(OPENCODE_PLUGIN_INSTALL_NAME).unwrap() + ); + assert_eq!( + fs::read_to_string(installed.tui_plugin_path).unwrap(), + selected.asset(OPENCODE_TUI_PLUGIN_INSTALL_NAME).unwrap() + ); + let entry = fs::read_to_string(dir.join(OPENCODE_V2_TUI_PLUGIN_DIR).join("tui.js")).unwrap(); + assert_eq!( + parse_integration_version(&entry), + Some(selected.expected_version()) + ); + assert!(entry.contains("export { default } from \"../herdr-tui-session.js\";")); + assert_eq!( + integration_recommendations_with_registry(¤t)[0].state, + IntegrationStatusKind::Current + ); + std::env::remove_var("HOME"); + fs::remove_dir_all(base).unwrap(); +} + +#[test] +fn missing_companion_asset_fails_before_any_opencode_install_mutation() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let dir = home.join(".config/opencode"); + fs::create_dir_all(dir.join("plugins")).unwrap(); + std::env::set_var("HOME", &home); + let plugin_path = dir.join("plugins").join(OPENCODE_PLUGIN_INSTALL_NAME); + fs::write(&plugin_path, "previous plugin").unwrap(); + let files = newer_integration_files("opencode"); + let borrowed: Vec<_> = files + .iter() + .map(|(p, t)| (p.as_str(), t.as_str())) + .collect(); + let mut packages = crate::agents::validate_packages(&borrowed).unwrap(); + packages[0].assets.remove("assets/herdr-tui-session.js"); + let registry = crate::agents::AgentRegistry::from_packages(packages).unwrap(); + let selected = registry + .profile_by_id("opencode") + .unwrap() + .integration() + .unwrap(); + assert!(super::targets::install_opencode(selected).is_err()); + assert_eq!(fs::read_to_string(&plugin_path).unwrap(), "previous plugin"); + assert!(!dir.join(OPENCODE_TUI_PLUGIN_INSTALL_NAME).exists()); + assert!(!dir.join("tui.json").exists()); + std::env::remove_var("HOME"); + fs::remove_dir_all(base).unwrap(); +} + #[test] fn install_pi_writes_embedded_asset_to_pi_extensions_dir() { let _lock = integration_env_lock(); @@ -834,7 +1355,10 @@ fn outdated_integrations_treat_missing_version_marker_as_legacy() { ); assert_eq!(outdated[0].path, extension_path); assert_eq!(outdated[0].installed_version, None); - assert_eq!(outdated[0].expected_version, PI_INTEGRATION_VERSION); + assert_eq!( + outdated[0].expected_version, + expected_integration_version(crate::api::schema::IntegrationTarget::Pi) + ); std::env::remove_var("HOME"); let _ = fs::remove_dir_all(base); @@ -864,7 +1388,10 @@ fn outdated_integrations_detect_previous_pi_version() { ); assert_eq!(outdated[0].path, extension_path); assert_eq!(outdated[0].installed_version, Some(4)); - assert_eq!(outdated[0].expected_version, PI_INTEGRATION_VERSION); + assert_eq!( + outdated[0].expected_version, + expected_integration_version(crate::api::schema::IntegrationTarget::Pi) + ); std::env::remove_var("HOME"); let _ = fs::remove_dir_all(base); @@ -894,7 +1421,10 @@ fn outdated_integrations_detect_previous_omp_version() { ); assert_eq!(outdated[0].path, extension_path); assert_eq!(outdated[0].installed_version, Some(4)); - assert_eq!(outdated[0].expected_version, OMP_INTEGRATION_VERSION); + assert_eq!( + outdated[0].expected_version, + expected_integration_version(crate::api::schema::IntegrationTarget::Omp) + ); std::env::remove_var("HOME"); let _ = fs::remove_dir_all(base); @@ -1127,7 +1657,10 @@ fn claude_v1_integration_status_is_outdated() { assert_eq!(claude.path, hook_path); assert_eq!(claude.installed_version, Some(1)); - assert_eq!(claude.expected_version, 9); + assert_eq!( + claude.expected_version, + expected_integration_version(crate::api::schema::IntegrationTarget::Claude) + ); assert_eq!(claude.state, IntegrationStatusKind::Outdated); std::env::remove_var("HOME"); @@ -1157,7 +1690,10 @@ fn claude_v2_integration_status_is_outdated() { assert_eq!(claude.path, hook_path); assert_eq!(claude.installed_version, Some(2)); - assert_eq!(claude.expected_version, 9); + assert_eq!( + claude.expected_version, + expected_integration_version(crate::api::schema::IntegrationTarget::Claude) + ); assert_eq!(claude.state, IntegrationStatusKind::Outdated); std::env::remove_var("HOME"); @@ -1290,7 +1826,10 @@ fn codex_v2_integration_status_is_outdated() { assert_eq!(codex.path, hook_path); assert_eq!(codex.installed_version, Some(2)); - assert_eq!(codex.expected_version, 8); + assert_eq!( + codex.expected_version, + expected_integration_version(crate::api::schema::IntegrationTarget::Codex) + ); assert_eq!(codex.state, IntegrationStatusKind::Outdated); std::env::remove_var("HOME"); @@ -1725,7 +2264,10 @@ fn copilot_v1_integration_status_is_outdated() { assert_eq!(copilot.path, hook_path); assert_eq!(copilot.installed_version, Some(1)); - assert_eq!(copilot.expected_version, COPILOT_INTEGRATION_VERSION); + assert_eq!( + copilot.expected_version, + expected_integration_version(crate::api::schema::IntegrationTarget::Copilot) + ); assert_eq!(copilot.state, IntegrationStatusKind::Outdated); std::env::remove_var("HOME"); @@ -2184,7 +2726,10 @@ fn droid_v1_integration_status_is_outdated() { assert_eq!(droid.path, hook_path); assert_eq!(droid.installed_version, Some(1)); - assert_eq!(droid.expected_version, DROID_INTEGRATION_VERSION); + assert_eq!( + droid.expected_version, + expected_integration_version(crate::api::schema::IntegrationTarget::Droid) + ); assert_eq!(droid.state, IntegrationStatusKind::Outdated); std::env::remove_var("HOME"); @@ -2339,6 +2884,10 @@ fn opencode_install_defers_v2_registration_while_migration_pending() { #[test] fn opencode_v2_install_status_and_uninstall_preserve_cli_preferences() { + assert_eq!( + crate::integration::builtin::opencode::v2_tui_entrypoint(12), + include_str!("assets/opencode/tui.js") + ); let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); @@ -2357,7 +2906,12 @@ fn opencode_v2_install_status_and_uninstall_preserve_cli_preferences() { integration_status_at( crate::api::schema::IntegrationTarget::Opencode, installed.plugin_path.clone(), - OPENCODE_INTEGRATION_VERSION, + crate::agents::registry() + .profile_by_agent(crate::detect::Agent::OpenCode) + .unwrap() + .integration() + .unwrap() + .expected_version(), ) .state }; @@ -2365,7 +2919,14 @@ fn opencode_v2_install_status_and_uninstall_preserve_cli_preferences() { let entry = dir.join(OPENCODE_V2_TUI_PLUGIN_DIR).join("tui.js"); assert_eq!( fs::read_to_string(&entry).unwrap(), - OPENCODE_V2_TUI_PLUGIN_ASSET + crate::integration::builtin::opencode::v2_tui_entrypoint( + crate::agents::registry() + .profile_by_agent(crate::detect::Agent::OpenCode) + .unwrap() + .integration() + .unwrap() + .expected_version() + ) ); fs::remove_file(&entry).unwrap(); assert_eq!(status(), IntegrationStatusKind::Outdated); @@ -2414,7 +2975,7 @@ fn opencode_status_requires_the_tui_plugin_and_config_entry() { integration_status_at( crate::api::schema::IntegrationTarget::Opencode, installed.plugin_path.clone(), - OPENCODE_INTEGRATION_VERSION, + expected_integration_version(crate::api::schema::IntegrationTarget::Opencode), ) .state }; @@ -2860,48 +3421,36 @@ fn install_hermes_errors_when_config_dir_missing() { } #[test] -fn bundled_integration_asset_versions_match_expected_versions() { - for (name, asset, expected_version) in [ - ("pi", PI_EXTENSION_ASSET, PI_INTEGRATION_VERSION), - ("omp", OMP_EXTENSION_ASSET, OMP_INTEGRATION_VERSION), - ("claude", CLAUDE_HOOK_ASSET, CLAUDE_INTEGRATION_VERSION), - ("codex", CODEX_HOOK_ASSET, CODEX_INTEGRATION_VERSION), - ("kimi", KIMI_HOOK_ASSET, KIMI_INTEGRATION_VERSION), - ("copilot", COPILOT_HOOK_ASSET, COPILOT_INTEGRATION_VERSION), - ("devin", DEVIN_HOOK_ASSET, DEVIN_INTEGRATION_VERSION), - ("droid", DROID_HOOK_ASSET, DROID_INTEGRATION_VERSION), - ( - "opencode", - OPENCODE_PLUGIN_ASSET, - OPENCODE_INTEGRATION_VERSION, - ), - ("kilo", KILO_PLUGIN_ASSET, KILO_INTEGRATION_VERSION), - ( - "hermes", - HERMES_PLUGIN_INIT_ASSET, - HERMES_INTEGRATION_VERSION, - ), - ( - "qodercli", - QODERCLI_HOOK_ASSET, - QODERCLI_INTEGRATION_VERSION, - ), - ("cursor", CURSOR_HOOK_ASSET, CURSOR_INTEGRATION_VERSION), +fn bundled_integration_asset_versions_match_current_platform_registry_versions() { + use crate::api::schema::IntegrationTarget; + + for (target, asset) in [ + (IntegrationTarget::Pi, PI_EXTENSION_ASSET), + (IntegrationTarget::Omp, OMP_EXTENSION_ASSET), + (IntegrationTarget::Claude, CLAUDE_HOOK_ASSET), + (IntegrationTarget::Codex, CODEX_HOOK_ASSET), + (IntegrationTarget::Copilot, COPILOT_HOOK_ASSET), + (IntegrationTarget::Devin, DEVIN_HOOK_ASSET), + (IntegrationTarget::Droid, DROID_HOOK_ASSET), + (IntegrationTarget::Kimi, KIMI_HOOK_ASSET), + (IntegrationTarget::Opencode, OPENCODE_PLUGIN_ASSET), + (IntegrationTarget::Kilo, KILO_PLUGIN_ASSET), + (IntegrationTarget::Hermes, HERMES_PLUGIN_INIT_ASSET), + (IntegrationTarget::Qodercli, QODERCLI_HOOK_ASSET), + (IntegrationTarget::Qwen, QWEN_HOOK_ASSET), + (IntegrationTarget::Cursor, CURSOR_HOOK_ASSET), + (IntegrationTarget::Mastracode, MASTRACODE_HOOK_ASSET), ( - "antigravity_cli", + IntegrationTarget::AntigravityCli, ANTIGRAVITY_CLI_HOOK_ASSET, - ANTIGRAVITY_CLI_INTEGRATION_VERSION, - ), - ( - "mastracode", - MASTRACODE_HOOK_ASSET, - MASTRACODE_INTEGRATION_VERSION, ), + (IntegrationTarget::Grok, GROK_HOOK_ASSET), ] { + let profile = integration_profile(target); assert_eq!( parse_integration_version(asset), - Some(expected_version), - "{name} asset version must match its integration version constant" + Some(profile.expected_version()), + "{target:?} asset version must match its current-platform registry version" ); } } @@ -3470,7 +4019,12 @@ fn cursor_v1_integration_status_is_current() { .find(|status| status.target == crate::api::schema::IntegrationTarget::Cursor) .expect("cursor integration status"); assert_eq!(cursor.state, IntegrationStatusKind::Current); - assert_eq!(cursor.installed_version, Some(CURSOR_INTEGRATION_VERSION)); + assert_eq!( + cursor.installed_version, + Some(expected_integration_version( + crate::api::schema::IntegrationTarget::Cursor + )) + ); clear_integration_path_env(); let _ = fs::remove_dir_all(base); @@ -4071,7 +4625,12 @@ fn grok_v1_integration_status_is_current() { .find(|status| status.target == crate::api::schema::IntegrationTarget::Grok) .expect("grok integration status"); assert_eq!(grok.state, IntegrationStatusKind::Current); - assert_eq!(grok.installed_version, Some(GROK_INTEGRATION_VERSION)); + assert_eq!( + grok.installed_version, + Some(expected_integration_version( + crate::api::schema::IntegrationTarget::Grok + )) + ); clear_integration_path_env(); let _ = fs::remove_dir_all(base); diff --git a/src/integration/types.rs b/src/integration/types.rs index 8169ab9823..115a768318 100644 --- a/src/integration/types.rs +++ b/src/integration/types.rs @@ -153,8 +153,8 @@ pub(crate) enum IntegrationStatusKind { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct IntegrationRecommendation { pub target: crate::api::schema::IntegrationTarget, - pub label: &'static str, - pub command: &'static str, + pub label: String, + pub command: String, pub available: bool, pub path: PathBuf, pub state: IntegrationStatusKind, diff --git a/src/integration/version.rs b/src/integration/version.rs index 648b86ae32..0655fc594b 100644 --- a/src/integration/version.rs +++ b/src/integration/version.rs @@ -1,25 +1,6 @@ use std::io; -pub(crate) struct AgentVersionRequirement { - pub label: &'static str, - pub binary: &'static str, - pub args: &'static [&'static str], - pub min_version: &'static str, -} - -pub(crate) fn agent_version_requirement( - target: crate::api::schema::IntegrationTarget, -) -> Option { - match target { - crate::api::schema::IntegrationTarget::Kimi => Some(AgentVersionRequirement { - label: "kimi code", - binary: "kimi", - args: &["--version"], - min_version: super::KIMI_MIN_VERSION, - }), - _ => None, - } -} +pub(crate) use crate::agents::integration::AgentVersionRequirement; pub(crate) fn extract_version_triple(text: &str) -> Option<(u64, u64, u64)> { text.split_whitespace().find_map(|token| { diff --git a/src/logging.rs b/src/logging.rs index 2d1cd5a155..61028b2457 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -370,11 +370,7 @@ pub(crate) fn update_available(version: &str) { ); } -pub(crate) fn integration_action( - action: &'static str, - target: &'static str, - outcome: &'static str, -) { +pub(crate) fn integration_action(action: &'static str, target: &str, outcome: &'static str) { tracing::info!( event = "integration.action", subsystem = "integration", diff --git a/src/main.rs b/src/main.rs index 87f3ce0af8..b26388e0e5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,7 @@ const NESTED_HERDR_MESSAGES: [&str; 6] = [ ]; mod agent_resume; +mod agents; mod api; mod app; mod build_info; @@ -554,6 +555,9 @@ fn main() -> io::Result<()> { } if args.get(1).map(|s| s.as_str()) == Some("server") { + // Compile the active registry before server hot paths start. Transport + // CLI commands above use the server's registry, not a local snapshot. + let _ = agents::registry(); return server::headless::run_server(); } @@ -617,6 +621,7 @@ fn main() -> io::Result<()> { println!(" herdr pane ..."); println!(" herdr session ..."); println!(" herdr integration ..."); + println!(" herdr registry validate "); println!(); println!("Common commands:"); for (command, description) in [ @@ -687,6 +692,10 @@ fn main() -> io::Result<()> { println!(); println!("Advanced commands:"); println!(" {:<32} Run as headless server", "herdr server"); + println!( + " {:<32} Validate local agent packages without activating them", + "herdr registry validate " + ); println!(); println!("Options:"); println!(" --session Use or create a named persistent session"); @@ -762,6 +771,7 @@ fn main() -> io::Result<()> { "pane", "session", "integration", + "registry", ] .contains(&arg.as_str()) { diff --git a/src/pane.rs b/src/pane.rs index 0ba411d0c0..de5c45c1a6 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -35,7 +35,7 @@ mod xtgettcap; use self::agent_detection::{ decide_detection_screen_read, decide_screen_detection_publish, - detection_update_for_publish_with_osc, mark_detection_content_changed, + detection_update_for_publish_with_registry, mark_detection_content_changed, observe_detection_content_change, DetectionPublishDecision, DetectionScreenReadDecision, DetectionScreenReadInput, PendingIdleConfirmation, ScreenDetectionPublishInput, AGENT_PENDING_IDLE_RECHECK, AGENT_STARTUP_GRACE_WINDOW, @@ -205,10 +205,12 @@ fn active_pending_release( } async fn publish_state_changed_event( + registry_generation: u64, state_events: mpsc::Sender, pane_id: PaneId, agent: Option, state: AgentState, + visible_idle: bool, visible_blocker: bool, visible_working: bool, process_exited: bool, @@ -218,14 +220,18 @@ async fn publish_state_changed_event( // Waiting for queue space here preserves correctness-critical state transitions // without blocking pane I/O. if let Err(e) = state_events - .send(AppEvent::StateChanged { - pane_id, - agent, - state, - visible_blocker, - visible_working, - process_exited, - observed_at, + .send(AppEvent::AgentDetection { + registry_generation, + observation: Box::new(AppEvent::StateChanged { + pane_id, + agent, + state, + visible_idle, + visible_blocker, + visible_working, + process_exited, + observed_at, + }), }) .await { @@ -238,16 +244,20 @@ async fn publish_state_changed_event( } async fn publish_agent_process_detected_event( + registry_generation: u64, state_events: mpsc::Sender, pane_id: PaneId, agent: Agent, observed_at: std::time::Instant, -) { +) -> bool { if let Err(e) = state_events - .send(AppEvent::AgentProcessDetected { - pane_id, - agent, - observed_at, + .send(AppEvent::AgentDetection { + registry_generation, + observation: Box::new(AppEvent::AgentProcessDetected { + pane_id, + agent, + observed_at, + }), }) .await { @@ -256,13 +266,17 @@ async fn publish_agent_process_detected_event( err = %e, "failed to deliver AgentProcessDetected event" ); + return false; } + true } #[derive(Debug, Clone, Copy)] struct AgentDetectionPublishUpdate { + registry_generation: u64, state: AgentState, visible_idle: bool, + screen_visible_idle: bool, visible_blocker: bool, visible_working: bool, process_exited: bool, @@ -294,10 +308,12 @@ async fn apply_agent_detection_publish_update( *foreground_shell_exit_reported = true; } publish_state_changed_event( + update.registry_generation, state_events, pane_id, agent, update.state, + update.screen_visible_idle, update.visible_blocker, update.visible_working, update.process_exited, @@ -570,8 +586,291 @@ fn sync_content_change_acquisition( } } +/// Registry lifetime is independent of the detector's legacy membership reset. +/// Only the bound *observed process* can use a retired profile; acquisitions always +/// use `active`, even while a removed agent is still waiting for exit confirmation. +struct DetectionRegistryState { + active: Arc, + bound: Option, + needs_publish: bool, + read_process_identity: fn(u32) -> Option, +} + +struct BoundDetectionProcess { + agent: Agent, + process: crate::platform::ForegroundProcess, + process_group_id: u32, + profile: Arc, + resume_recipe: Option, + identity: Option, + resume_registry: Arc, + resume_options: Option, +} + +impl BoundDetectionProcess { + fn observe_resume_options( + &self, + processes: &[crate::platform::ForegroundProcess], + read_identity: fn(u32) -> Option, + ) -> Option { + let identity = self.identity?; + let policy = &self + .resume_registry + .profile_by_agent(self.agent)? + .session()? + .resume_options; + if policy.flags.is_empty() && policy.options.is_empty() { + return None; + } + let candidates = processes + .iter() + .filter(|process| process.pid == self.process.pid) + .chain( + processes + .iter() + .filter(|process| process.pid != self.process.pid), + ); + for process in candidates { + let Some(args) = + crate::detect::structured_resume_args(&self.resume_registry, process, self.agent) + else { + continue; + }; + let argv_owner = if process.pid == identity.pid { + identity + } else { + read_identity(process.pid)? + }; + return Some(crate::agent_resume::ProcessResumeOptions { + argv_owner, + options: policy.filter(args), + }); + } + None + } +} + +impl DetectionRegistryState { + fn new(active: Arc) -> Self { + Self { + active, + bound: None, + needs_publish: false, + read_process_identity: crate::platform::process_identity, + } + } + + fn refresh( + &mut self, + generation: u64, + acquire: impl FnOnce() -> Arc, + has_process_probe: &mut bool, + last_screen_scan: &mut Option, + ) -> bool { + if self.active.generation == generation { + return false; + } + self.active = acquire(); + // Invalidate both positive and negative probe/screen scheduling caches, + // without touching identity, authority, OSC, startup grace or pending idle. + *has_process_probe = false; + self.request_screen_republish(last_screen_scan); + if let Some(bound) = &mut self.bound { + if self.active.profile_by_agent(bound.agent).is_some() { + bound.profile = self.active.clone(); + } + } + true + } + + fn request_screen_republish(&mut self, last_screen_scan: &mut Option) { + *last_screen_scan = None; + self.needs_publish = true; + } + + async fn publish_resume_binding( + &self, + events: &mpsc::Sender, + pane_id: PaneId, + now: std::time::Instant, + ) { + if let Some(bound) = &self.bound { + let _ = events + .send(AppEvent::AgentResumeProcessBound { + pane_id, + binding: Box::new(crate::agent_resume::LiveAgentResumeBinding { + agent: bound.agent, + recipe: bound.identity.and(bound.resume_recipe.clone()), + process: Some((bound.process_group_id, bound.process.clone())), + process_identity: bound.identity, + observed_at: now, + managed_admission: false, + resume_options_owner: None, + report_proof: None, + resume_options: bound.resume_options.clone(), + }), + }) + .await; + } + } + + fn bound_process( + &self, + ) -> Option<( + &crate::platform::ForegroundProcess, + &crate::agents::AgentRegistry, + )> { + self.bound + .as_ref() + .map(|bound| (&bound.process, &*bound.resume_registry.registry)) + } + + fn screen_snapshot(&self, agent: Option) -> &crate::agents::store::RegistrySnapshot { + self.bound + .as_ref() + .filter(|bound| Some(bound.agent) == agent) + .map_or(&self.active, |bound| &bound.profile) + } + + fn retained_probe( + &self, + job: &crate::platform::ForegroundJob, + pid: u32, + ) -> Option { + let bound = self.bound.as_ref()?; + if !job + .processes + .iter() + .any(|process| process.pid == bound.process.pid) + || bound.identity.is_none() + || (self.read_process_identity)(bound.process.pid) != bound.identity + { + return None; + } + Some(process_probe_result( + job, + pid, + bound.agent, + bound.process.name.clone(), + )) + } + + /// Confirms that a probe taken after a registry refresh still describes the + /// process binding owned by this detector. Callers gate this on an actual + /// generation change; this method deliberately rechecks PID membership and + /// birth identity instead of promoting an event queued by the old generation. + fn revalidated_bound_agent( + &self, + probe: &ProcessProbeResult, + current_agent: Option, + ) -> Option { + let agent = current_agent?; + let bound = self.bound.as_ref()?; + (probe.agent == Some(agent) + && bound.agent == agent + && probe.process_group_id == Some(bound.process_group_id) + && probe + .processes + .iter() + .any(|process| process.pid == bound.process.pid) + && (self.read_process_identity)(bound.process.pid) == bound.identity) + .then_some(agent) + } + + fn bind(&mut self, probe: &ProcessProbeResult, agent: Option) -> bool { + let Some(agent) = agent else { + return self.bound.take().is_some(); + }; + if let Some(bound) = &mut self.bound { + if bound.agent == agent + && probe.process_group_id.is_some() + && probe + .processes + .iter() + .any(|process| process.pid == bound.process.pid) + && bound.identity.is_some() + && (self.read_process_identity)(bound.process.pid) == bound.identity + { + let options = + bound.observe_resume_options(&probe.processes, self.read_process_identity); + let changed = probe.process_group_id != Some(bound.process_group_id) + || options != bound.resume_options; + bound.resume_options = options; + if let Some(process) = probe + .processes + .iter() + .find(|process| process.pid == bound.process.pid) + { + bound.process = process.clone(); + } + if let Some(pgid) = probe.process_group_id { + bound.process_group_id = pgid; + } + return changed; + } + // Unknown birth identity grants no retained recipe or old-profile + // matching. Repeated negative evidence need not allocate an event. + if bound.identity.is_none() + && bound.agent == agent + && probe.process_group_id == Some(bound.process_group_id) + && probe + .processes + .iter() + .any(|process| process.pid == bound.process.pid) + && (self.read_process_identity)(bound.process.pid).is_none() + { + return false; + } + } + // The previous binding must never classify a new PID/command, including + // a new process with the same name after its profile has been removed. + if probe.agent != Some(agent) { + return false; + } + self.bound = None; + let Some(process_group_id) = probe.process_group_id else { + return false; + }; + let process = probe.processes.iter().find(|process| { + crate::platform::process_agent_hint_with_registry(&self.active, process.pid) + == Some(agent) + || crate::detect::identify_agent_in_job_with_registry( + &self.active, + &crate::platform::ForegroundJob { + process_group_id: process.pid, + processes: vec![(*process).clone()], + }, + ) + .is_some_and(|(identified, _)| identified == agent) + }); + if let Some(process) = process { + let identity = (self.read_process_identity)(process.pid); + let mut bound = BoundDetectionProcess { + agent, + process: process.clone(), + identity, + process_group_id, + resume_recipe: identity.and_then(|_| { + self.active + .profile_by_agent(agent) + .and_then(crate::agent_resume::PinnedAgentResumeRecipe::capture) + }), + profile: self.active.clone(), + resume_registry: self.active.clone(), + resume_options: None, + }; + bound.resume_options = + bound.observe_resume_options(&probe.processes, self.read_process_identity); + self.bound = Some(bound); + return true; + } + false + } +} + #[derive(Debug, Clone)] struct ProcessProbeResult { + processes: Vec, process_group_id: Option, foreground_is_pane_shell: bool, agent: Option, @@ -597,6 +896,7 @@ fn agent_hint_for_non_leader_foreground_job_members( } fn identify_process_group_leader_in_job( + registry: &crate::agents::AgentRegistry, job: &crate::platform::ForegroundJob, ) -> Option<(Agent, String)> { let leader = job @@ -607,7 +907,7 @@ fn identify_process_group_leader_in_job( process_group_id: job.process_group_id, processes: vec![leader.clone()], }; - crate::detect::identify_agent_in_job(&leader_job) + crate::detect::identify_agent_in_job_with_registry(registry, &leader_job) } fn process_probe_result( @@ -617,6 +917,7 @@ fn process_probe_result( process_name: String, ) -> ProcessProbeResult { ProcessProbeResult { + processes: job.processes.clone(), process_group_id: Some(job.process_group_id), foreground_is_pane_shell: job.processes.iter().any(|process| process.pid == pid), agent: Some(agent), @@ -634,10 +935,11 @@ fn hinted_process_probe_result( job, pid, agent, - crate::detect::agent_label(agent).to_string(), + crate::detect::agent_label(&agent).to_string(), )) } +#[cfg(test)] fn probe_foreground_process_from_jobs( pid: u32, foreground_pgid: Option, @@ -645,26 +947,66 @@ fn probe_foreground_process_from_jobs( foreground_job: impl FnOnce() -> Option, read_hint: impl Fn(u32) -> Option + Copy, ) -> ProcessProbeResult { + probe_foreground_process_from_jobs_with_registry( + &DetectionRegistryState::new(crate::agents::registry()), + pid, + foreground_pgid, + leader_job, + foreground_job, + read_hint, + ) +} + +fn probe_foreground_process_from_jobs_with_registry( + registry: &DetectionRegistryState, + pid: u32, + foreground_pgid: Option, + leader_job: Option, + foreground_job: impl FnOnce() -> Option, + read_hint: impl Fn(u32) -> Option + Copy, +) -> ProcessProbeResult { + // A leader-only fast path may miss the bound child. While bound, inspect + // the complete observed job before allowing another package to acquire it. + let mut foreground_job = Some(foreground_job); + let full_job = if registry.bound.is_some() { + Some(foreground_job.take().expect("foreground job reader")()) + } else { + None + }; + if let Some(job) = full_job + .as_ref() + .and_then(|job| job.as_ref()) + .or(leader_job.as_ref()) + { + if let Some(retained) = registry.retained_probe(job, pid) { + return retained; + } + } if let Some(job) = leader_job.as_ref() { if let Some(hinted) = hinted_process_probe_result(job, pid, read_hint) { return hinted; } - if let Some((agent, process_name)) = crate::detect::identify_agent_in_job(job) { + if let Some((agent, process_name)) = + crate::detect::identify_agent_in_job_with_registry(®istry.active, job) + { return process_probe_result(job, pid, agent, process_name); } } - let foreground_job = foreground_job(); + let foreground_job = + full_job.unwrap_or_else(|| foreground_job.take().expect("foreground job reader")()); if let Some(job) = foreground_job.as_ref() { if let Some(agent) = read_hint(job.process_group_id) { return process_probe_result( job, pid, agent, - crate::detect::agent_label(agent).to_string(), + crate::detect::agent_label(&agent).to_string(), ); } - if let Some((agent, process_name)) = identify_process_group_leader_in_job(job) { + if let Some((agent, process_name)) = + identify_process_group_leader_in_job(®istry.active, job) + { return process_probe_result(job, pid, agent, process_name); } if let Some(agent) = agent_hint_for_non_leader_foreground_job_members(job, read_hint) { @@ -672,12 +1014,13 @@ fn probe_foreground_process_from_jobs( job, pid, agent, - crate::detect::agent_label(agent).to_string(), + crate::detect::agent_label(&agent).to_string(), ); } - let identified = crate::detect::identify_agent_in_job(job); + let identified = crate::detect::identify_agent_in_job_with_registry(®istry.active, job); return ProcessProbeResult { + processes: job.processes.clone(), process_group_id: Some(job.process_group_id), foreground_is_pane_shell: job.processes.iter().any(|process| process.pid == pid), agent: identified.as_ref().map(|(agent, _)| *agent), @@ -686,6 +1029,7 @@ fn probe_foreground_process_from_jobs( } ProcessProbeResult { + processes: Vec::new(), process_group_id: foreground_pgid, foreground_is_pane_shell: false, agent: None, @@ -693,13 +1037,24 @@ fn probe_foreground_process_from_jobs( } } -fn probe_foreground_process(pid: u32, foreground_pgid: Option) -> ProcessProbeResult { - probe_foreground_process_from_jobs( +fn probe_foreground_process( + registry: &DetectionRegistryState, + pid: u32, + foreground_pgid: Option, +) -> ProcessProbeResult { + probe_foreground_process_from_jobs_with_registry( + registry, pid, foreground_pgid, foreground_pgid.and_then(crate::detect::foreground_group_leader_job), - || crate::detect::foreground_job(pid), - crate::platform::process_agent_hint, + || { + crate::platform::foreground_job_with_registry( + ®istry.active, + pid, + registry.bound_process(), + ) + }, + |pid| crate::platform::process_agent_hint_with_registry(®istry.active, pid), ) } @@ -725,6 +1080,7 @@ fn spawn_basic_detection_task( let mut agent_presence = AgentDetectionPresence::from_agent(None); let mut state = AgentState::Unknown; let mut last_visible_idle = false; + let mut last_screen_visible_idle = false; let mut last_visible_blocker = false; let mut last_visible_working = false; let mut last_visible_signal_refresh = None; @@ -739,7 +1095,9 @@ fn spawn_basic_detection_task( let mut last_detection_text = String::new(); let mut last_screen_scan_detection_content_seq = None; let mut agent_startup_grace_until = None; + let mut pending_acquisition_republish = false; let mut pending_idle = PendingIdleConfirmation::default(); + let mut registry = DetectionRegistryState::new(crate::agents::registry()); loop { let sleep_duration = if pending_idle.active() { @@ -751,8 +1109,10 @@ fn spawn_basic_detection_task( _ = tokio::time::sleep(sleep_duration) => {} _ = detect_reset.notified() => { agent_presence = AgentDetectionPresence::from_agent(None); + registry.bound = None; state = AgentState::Unknown; last_visible_idle = false; + last_screen_visible_idle = false; last_visible_blocker = false; last_visible_working = false; last_visible_signal_refresh = None; @@ -767,10 +1127,21 @@ fn spawn_basic_detection_task( last_detection_text.clear(); last_screen_scan_detection_content_seq = None; agent_startup_grace_until = None; + pending_acquisition_republish = false; pending_idle.clear(); } } + let registry_changed = registry.refresh( + crate::agents::store::generation(), + crate::agents::registry, + &mut has_process_probe, + &mut last_screen_scan_detection_content_seq, + ); + if registry_changed { + last_screen_visible_idle = false; + pending_acquisition_republish |= agent_presence.current_agent().is_some(); + } let now = std::time::Instant::now(); let suppressed_agent = active_pending_release(&pending_release_for_task, now); if suppressed_agent.is_none() && release_was_active { @@ -785,7 +1156,13 @@ fn spawn_basic_detection_task( let lifecycle_authority_active = full_lifecycle_authority_active.load(Ordering::Acquire); let foreground_pgid = (pid > 0) - .then(|| crate::detect::foreground_process_group_id(pid)) + .then(|| { + crate::platform::foreground_process_group_id_with_registry( + ®istry.active, + pid, + registry.bound_process(), + ) + }) .flatten(); let process_group_changed = foreground_group_changed(foreground_pgid, last_foreground_pgid); @@ -802,17 +1179,19 @@ fn spawn_basic_detection_task( pending_restore_probe: false, elapsed_since_process_check: now.duration_since(last_process_check), }; - !should_skip_process_probe_for_lifecycle_authority( - lifecycle_authority_active, - process_probe_input, - ) && should_probe_foreground_job(process_probe_input) + pending_acquisition_republish + || registry_changed + || (!should_skip_process_probe_for_lifecycle_authority( + lifecycle_authority_active, + process_probe_input, + ) && should_probe_foreground_job(process_probe_input)) }; if should_check_process { last_process_check = now; let had_process_probe = has_process_probe; has_process_probe = true; - let probe = probe_foreground_process(pid, foreground_pgid); + let probe = probe_foreground_process(®istry, pid, foreground_pgid); let process_group_id = probe.process_group_id; let tracked_process_group_id = process_group_for_change_tracking(foreground_pgid, process_group_id); @@ -840,6 +1219,16 @@ fn spawn_basic_detection_task( &mut pending_foreground_shell_clear, &mut foreground_shell_exit_reported, ); + if registry.bind(&probe, agent_presence.current_agent()) { + registry + .publish_resume_binding(&state_events, pane_id, now) + .await; + } + let refreshed_process_acquisition = pending_acquisition_republish + .then(|| { + registry.revalidated_bound_agent(&probe, agent_presence.current_agent()) + }) + .flatten(); last_foreground_pgid = tracked_process_group_id; if new_agent.is_some() { acquisition_started_at = None; @@ -866,20 +1255,40 @@ fn spawn_basic_detection_task( agent_startup_grace_until = Some(now + AGENT_STARTUP_GRACE_WINDOW); state = AgentState::Unknown; last_visible_idle = false; + last_screen_visible_idle = false; last_visible_blocker = false; last_visible_working = false; last_visible_signal_refresh = None; - publish_agent_process_detected_event( + if publish_agent_process_detected_event( + registry.active.generation, state_events.clone(), pane_id, agent, now, ) - .await; + .await + { + pending_acquisition_republish = false; + } } else { agent_startup_grace_until = None; + pending_acquisition_republish = false; } } + } else if let Some(agent) = refreshed_process_acquisition { + if publish_agent_process_detected_event( + registry.active.generation, + state_events.clone(), + pane_id, + agent, + now, + ) + .await + { + pending_acquisition_republish = false; + registry + .request_screen_republish(&mut last_screen_scan_detection_content_seq); + } } } @@ -892,21 +1301,22 @@ fn spawn_basic_detection_task( continue; } - if let Some(until) = agent_startup_grace_until { + let startup_grace_active = if let Some(until) = agent_startup_grace_until { if process_exited { agent_startup_grace_until = None; pending_idle.clear(); + false + } else if now < until { + true } else { - if now < until { - pending_idle.clear(); - continue; - } agent_startup_grace_until = None; last_screen_scan_detection_content_seq = None; pending_idle.clear(); continue; } - } + } else { + false + }; let current_detection_content_seq = if agent.is_some() { Some(detection_content_seq.load(Ordering::Relaxed)) @@ -930,10 +1340,6 @@ fn spawn_basic_detection_task( last_screen_scan_detection_content_seq = current_detection_content_seq; let content_changed = content != last_detection_text; last_detection_text.clone_from(&content); - if !process_exited && crate::detect::should_skip_state_update(agent, &content) { - pending_idle.clear(); - continue; - } sync_content_change_acquisition( agent_presence.current_agent(), suppressed_agent, @@ -946,7 +1352,8 @@ fn spawn_basic_detection_task( let osc_title = terminal.agent_osc_title(); let osc_progress = terminal.agent_osc_progress(); - let Some(screen_detection) = detection_update_for_publish_with_osc( + let Some(screen_detection) = detection_update_for_publish_with_registry( + registry.screen_snapshot(agent), agent, &content, &osc_title, @@ -958,14 +1365,17 @@ fn spawn_basic_detection_task( }; match decide_screen_detection_publish( ScreenDetectionPublishInput { + force_publish: registry.needs_publish, screen_detection, current_state: state, last_visible_idle, + last_screen_visible_idle, last_visible_blocker, last_visible_working, last_visible_signal_refresh, process_exited, agent_changed, + startup_grace_active, now, }, &mut pending_idle, @@ -974,17 +1384,21 @@ fn spawn_basic_detection_task( DetectionPublishDecision::Publish { state: new_state, visible_idle, + screen_visible_idle, visible_blocker, visible_working, process_exited: publish_process_exited, } => { + last_screen_visible_idle = screen_visible_idle; apply_agent_detection_publish_update( state_events.clone(), pane_id, agent, AgentDetectionPublishUpdate { + registry_generation: registry.active.generation, state: new_state, visible_idle, + screen_visible_idle, visible_blocker, visible_working, process_exited: publish_process_exited, @@ -998,6 +1412,10 @@ fn spawn_basic_detection_task( &mut foreground_shell_exit_reported, ) .await; + if publish_process_exited { + pending_acquisition_republish = false; + } + registry.needs_publish = false; } } } @@ -2416,7 +2834,6 @@ impl PaneRuntime { let (detect_handle, detect_reset_notify, pending_release) = if agent_detection == AgentDetection::Enabled { - use crate::detect; use std::time::{Duration, Instant}; const TICK_UNIDENTIFIED: Duration = Duration::from_millis(500); @@ -2440,6 +2857,7 @@ impl PaneRuntime { AgentDetectionPresence::from_agent(initial_state.detected_agent); let mut state = AgentState::Idle; let mut last_visible_idle = initial_state.detected_agent.is_some(); + let mut last_screen_visible_idle = false; let mut last_process_check = Instant::now(); #[cfg(windows)] let mut last_observation = (Instant::now(), Some(0)); @@ -2457,7 +2875,9 @@ impl PaneRuntime { let mut last_detection_text = String::new(); let mut last_screen_scan_detection_content_seq = None; let mut agent_startup_grace_until = None; + let mut pending_acquisition_republish = false; let mut pending_idle = PendingIdleConfirmation::default(); + let mut registry = DetectionRegistryState::new(crate::agents::registry()); tokio::time::sleep(Duration::from_millis(50)).await; @@ -2479,8 +2899,10 @@ impl PaneRuntime { _ = tokio::time::sleep(tick) => {} _ = detect_reset.notified() => { agent_presence = AgentDetectionPresence::from_agent(None); + registry.bound = None; state = AgentState::Unknown; last_visible_idle = false; + last_screen_visible_idle = false; last_foreground_pgid = None; has_process_probe = false; acquisition_started_at = None; @@ -2495,10 +2917,21 @@ impl PaneRuntime { last_detection_text.clear(); last_screen_scan_detection_content_seq = None; agent_startup_grace_until = None; + pending_acquisition_republish = false; pending_idle.clear(); } } + let registry_changed = registry.refresh( + crate::agents::store::generation(), + crate::agents::registry, + &mut has_process_probe, + &mut last_screen_scan_detection_content_seq, + ); + if registry_changed { + last_screen_visible_idle = false; + pending_acquisition_republish |= agent_presence.current_agent().is_some(); + } let now = Instant::now(); let suppressed_agent = active_pending_release(&pending_release_for_task, now); if suppressed_agent.is_none() && release_was_active { @@ -2528,19 +2961,25 @@ impl PaneRuntime { #[cfg(windows)] let last_content_seq = last_observation.1; #[cfg(windows)] - let foreground_observation_due = should_observe_foreground_process_group( - lifecycle_authority_active, - last_content_seq != Some(content_seq) - && (last_content_seq.is_some() - || now.duration_since(last_observation.0) >= TICK_IDENTIFIED), - now.duration_since(last_observation.0), - process_probe_input, - ); + let foreground_observation_due = pending_acquisition_republish + || registry_changed + || should_observe_foreground_process_group( + lifecycle_authority_active, + last_content_seq != Some(content_seq) + && (last_content_seq.is_some() + || now.duration_since(last_observation.0) >= TICK_IDENTIFIED), + now.duration_since(last_observation.0), + process_probe_input, + ); #[cfg(not(windows))] let foreground_observation_due = true; let foreground_pgid = match (pid, foreground_observation_due) { (0, _) => None, - (_, true) => detect::foreground_process_group_id(pid), + (_, true) => crate::platform::foreground_process_group_id_with_registry( + ®istry.active, + pid, + registry.bound_process(), + ), _ => last_foreground_pgid, }; #[cfg(windows)] @@ -2556,10 +2995,12 @@ impl PaneRuntime { foreground_pgid, ..process_probe_input }; - !should_skip_process_probe_for_lifecycle_authority( - lifecycle_authority_active, - process_probe_input, - ) && should_probe_foreground_job(process_probe_input) + pending_acquisition_republish + || registry_changed + || (!should_skip_process_probe_for_lifecycle_authority( + lifecycle_authority_active, + process_probe_input, + ) && should_probe_foreground_job(process_probe_input)) }; let mut agent_changed = false; @@ -2568,8 +3009,8 @@ impl PaneRuntime { let had_process_probe = has_process_probe; has_process_probe = true; if pid > 0 { - let probe = probe_foreground_process(pid, foreground_pgid); - let process_name = probe.process_name; + let probe = probe_foreground_process(®istry, pid, foreground_pgid); + let process_name = probe.process_name.clone(); let process_group_id = probe.process_group_id; let tracked_process_group_id = process_group_for_change_tracking( foreground_pgid, @@ -2603,6 +3044,19 @@ impl PaneRuntime { &mut pending_foreground_shell_clear, &mut foreground_shell_exit_reported, ); + if registry.bind(&probe, agent_presence.current_agent()) { + registry + .publish_resume_binding(&state_events, pane_id, now) + .await; + } + let refreshed_process_acquisition = pending_acquisition_republish + .then(|| { + registry.revalidated_bound_agent( + &probe, + agent_presence.current_agent(), + ) + }) + .flatten(); last_foreground_pgid = tracked_process_group_id; if new_agent.is_some() { acquisition_started_at = None; @@ -2635,18 +3089,24 @@ impl PaneRuntime { Some(now + AGENT_STARTUP_GRACE_WINDOW); state = AgentState::Unknown; last_visible_idle = false; + last_screen_visible_idle = false; last_visible_blocker = false; last_visible_working = false; last_visible_signal_refresh = None; - publish_agent_process_detected_event( + if publish_agent_process_detected_event( + registry.active.generation, state_events.clone(), pane_id, agent, now, ) - .await; + .await + { + pending_acquisition_republish = false; + } } else { agent_startup_grace_until = None; + pending_acquisition_republish = false; } } if let Some(process_name) = process_name { @@ -2668,6 +3128,21 @@ impl PaneRuntime { ); } agent_changed = true; + } else if let Some(agent) = refreshed_process_acquisition { + if publish_agent_process_detected_event( + registry.active.generation, + state_events.clone(), + pane_id, + agent, + now, + ) + .await + { + pending_acquisition_republish = false; + registry.request_screen_republish( + &mut last_screen_scan_detection_content_seq, + ); + } } } } @@ -2690,21 +3165,22 @@ impl PaneRuntime { continue; } - if let Some(until) = agent_startup_grace_until { + let startup_grace_active = if let Some(until) = agent_startup_grace_until { if process_exited { agent_startup_grace_until = None; last_screen_scan_detection_content_seq = None; pending_idle.clear(); + false + } else if now < until { + true } else { - if now < until { - pending_idle.clear(); - continue; - } agent_startup_grace_until = None; pending_idle.clear(); continue; } - } + } else { + false + }; let current_detection_content_seq = if agent.is_some() { Some(detection_content_seq.load(Ordering::Relaxed)) @@ -2728,10 +3204,6 @@ impl PaneRuntime { last_screen_scan_detection_content_seq = current_detection_content_seq; let content_changed = content != last_detection_text; last_detection_text.clone_from(&content); - if detect::should_skip_state_update(agent, &content) { - pending_idle.clear(); - continue; - } sync_content_change_acquisition( agent_presence.current_agent(), suppressed_agent, @@ -2744,7 +3216,8 @@ impl PaneRuntime { let osc_title = terminal.agent_osc_title(); let osc_progress = terminal.agent_osc_progress(); - let Some(screen_detection) = detection_update_for_publish_with_osc( + let Some(screen_detection) = detection_update_for_publish_with_registry( + registry.screen_snapshot(agent), agent, &content, &osc_title, @@ -2756,14 +3229,17 @@ impl PaneRuntime { }; match decide_screen_detection_publish( ScreenDetectionPublishInput { + force_publish: registry.needs_publish, screen_detection, current_state: state, last_visible_idle, + last_screen_visible_idle, last_visible_blocker, last_visible_working, last_visible_signal_refresh, process_exited, agent_changed, + startup_grace_active, now, }, &mut pending_idle, @@ -2772,17 +3248,21 @@ impl PaneRuntime { DetectionPublishDecision::Publish { state: new_state, visible_idle, + screen_visible_idle, visible_blocker, visible_working, process_exited: publish_process_exited, } => { + last_screen_visible_idle = screen_visible_idle; apply_agent_detection_publish_update( state_events.clone(), pane_id, agent, AgentDetectionPublishUpdate { + registry_generation: registry.active.generation, state: new_state, visible_idle, + screen_visible_idle, visible_blocker, visible_working, process_exited: publish_process_exited, @@ -2796,6 +3276,10 @@ impl PaneRuntime { &mut foreground_shell_exit_reported, ) .await; + if publish_process_exited { + pending_acquisition_republish = false; + } + registry.needs_publish = false; } } } @@ -4360,6 +4844,620 @@ mod tests { ); } + fn test_detection_registry( + snapshot: Arc, + ) -> DetectionRegistryState { + let mut registry = DetectionRegistryState::new(snapshot); + registry.read_process_identity = |pid| { + Some(crate::platform::ProcessIdentity { + pid, + birth_token: 1, + }) + }; + registry + } + + #[test] + fn resume_options_capture_is_birth_pinned_and_clears_on_unreadable_argv() { + use crate::agent_resume::resume_options_test_registry; + let initial = resume_options_test_registry(1, "options=['--model']"); + let expanded = resume_options_test_registry(2, "options=['--model']\nflags=['--yolo']"); + let mut registry = test_detection_registry(initial); + let mut probe = fixture_probe(®istry, 99_999_999, "options-cli"); + probe.processes[0].argv = Some(vec![ + "options-cli".into(), + "--model".into(), + "model name".into(), + "--yolo".into(), + ]); + assert!(registry.bind(&probe, probe.agent)); + assert_eq!( + registry + .bound + .as_ref() + .unwrap() + .resume_options + .as_ref() + .unwrap() + .options, + ["--model", "model name"] + ); + assert!(!registry.bind(&probe, probe.agent)); + registry.refresh(2, || expanded, &mut true, &mut Some(1)); + assert!(!registry.bind(&probe, probe.agent)); + assert_eq!( + registry + .bound + .as_ref() + .unwrap() + .resume_options + .as_ref() + .unwrap() + .options, + ["--model", "model name"] + ); + probe.processes[0].argv = None; + assert!(registry.bind(&probe, probe.agent)); + assert!(registry.bound.as_ref().unwrap().resume_options.is_none()); + probe.processes[0].pid -= 1; + probe.process_group_id = Some(probe.processes[0].pid); + probe.processes[0].argv = Some(vec!["options-cli".into(), "--yolo".into()]); + assert!(registry.bind(&probe, probe.agent)); + assert_eq!( + registry + .bound + .as_ref() + .unwrap() + .resume_options + .as_ref() + .unwrap() + .options, + ["--yolo"] + ); + } + + #[test] + fn resume_options_use_concrete_descendant_argv_not_opaque_wrapper_text() { + let mut registry = test_detection_registry( + crate::agent_resume::resume_options_test_registry(1, "options=['--model']"), + ); + let mut probe = fixture_probe(®istry, 99_999_999, "options-cli"); + probe.processes[0].name = "cmd.exe".into(); + probe.processes[0].argv = Some(vec![ + "cmd.exe".into(), + "/C".into(), + "options-cli --model untrusted".into(), + ]); + let mut descendant = foreground_process(99_999_998, "node"); + descendant.argv = Some(vec![ + "node".into(), + "/bin/options-cli".into(), + "--model".into(), + "exact ' value".into(), + ]); + probe.processes.push(descendant); + assert!(registry.bind(&probe, probe.agent)); + let options = registry + .bound + .as_ref() + .unwrap() + .resume_options + .as_ref() + .unwrap(); + assert_eq!(options.argv_owner.pid, 99_999_998); + assert_eq!(options.options, ["--model", "exact ' value"]); + probe.processes.pop(); + assert!(registry.bind(&probe, probe.agent)); + assert!(registry.bound.as_ref().unwrap().resume_options.is_none()); + } + + fn detection_registry_fixture( + generation: u64, + profiles: &[(&str, &str)], + ) -> Arc { + let mut files = Vec::new(); + for (id, process) in profiles { + files.push((format!("agents/{id}/agent.toml"), format!( + "schema = 1\nid = '{id}'\nname = '{id}'\naliases = []\nstartable = true\n[launch]\nunix = '{id}'\nwindows = '{id}'\n" + ))); + files.push(( + format!("agents/{id}/process.toml"), + format!("names = ['{process}']\n"), + )); + files.push((format!("agents/{id}/detection.toml"), format!( + "id = '{id}'\nversion = '2026.06.10.1'\nmin_engine_version = 1\n[[rules]]\nid = 'working'\nstate = 'working'\npriority = 100\nregion = 'whole_recent'\ncontains = ['Working...']\n" + ))); + } + crate::agents::store::snapshot_for_test(files, generation).unwrap() + } + + fn fixture_probe( + registry: &DetectionRegistryState, + process_pid: u32, + name: &str, + ) -> ProcessProbeResult { + let job = crate::platform::ForegroundJob { + process_group_id: process_pid, + processes: vec![foreground_process(process_pid, name)], + }; + probe_foreground_process_from_jobs_with_registry( + registry, + 42, + Some(process_pid), + Some(job.clone()), + || Some(job), + |_| None, + ) + } + + #[test] + fn resume_binding_survives_profile_reload_but_same_id_replacement_acquires_new_recipe() { + let snapshot = |generation, executable: &str| { + crate::agents::store::snapshot_for_test(vec![ + ("agents/codex/agent.toml".into(), format!("schema = 1\nid = 'codex'\nname = 'codex'\naliases = []\nstartable = true\n[launch]\nunix = '{executable}'\nwindows = '{executable}'\n")), + ("agents/codex/process.toml".into(), format!("names = ['{executable}']\n")), + ("agents/codex/resume.toml".into(), "accepted_references = ['id']\npreferred_reference = 'id'\nstrategy = 'subcommand'\ntoken = 'resume'\n".into()), + ], generation).unwrap() + }; + let mut registry = test_detection_registry(snapshot(1, "old-cli")); + let probe = fixture_probe(®istry, 101, "old-cli"); + registry.bind(&probe, Some(Agent::Codex)); + assert_eq!( + registry + .bound + .as_ref() + .unwrap() + .resume_recipe + .as_ref() + .unwrap() + .executable, + "old-cli" + ); + registry.refresh(2, || snapshot(2, "new-cli"), &mut true, &mut None); + let probe = fixture_probe(®istry, 101, "old-cli"); + registry.bind(&probe, Some(Agent::Codex)); + assert_eq!(registry.bound.as_ref().unwrap().profile.generation, 2); + assert_eq!( + registry + .bound + .as_ref() + .unwrap() + .resume_recipe + .as_ref() + .unwrap() + .executable, + "old-cli" + ); + let probe = fixture_probe(®istry, 102, "new-cli"); + registry.bind(&probe, Some(Agent::Codex)); + assert_eq!( + registry + .bound + .as_ref() + .unwrap() + .resume_recipe + .as_ref() + .unwrap() + .executable, + "new-cli" + ); + } + + #[tokio::test] + async fn generation_refresh_transient_miss_recovers_and_republishes_same_id_process_acquisition( + ) { + let original = Agent::parse("zeta-agent").unwrap(); + let mut registry = + test_detection_registry(detection_registry_fixture(1, &[("zeta-agent", "worker")])); + let probe = fixture_probe(®istry, 101, "worker"); + assert!(registry.bind(&probe, Some(original))); + let old_recipe = crate::agent_resume::PinnedAgentResumeRecipe::unavailable("zeta-agent"); + registry.bound.as_mut().unwrap().resume_recipe = Some(old_recipe.clone()); + let old_identity = registry.bound.as_ref().unwrap().identity; + + assert!(registry.refresh( + 2, + || detection_registry_fixture(2, &[("zeta-agent", "worker")]), + &mut true, + &mut Some(10), + )); + let mut pending_acquisition_republish = true; + let transient_miss = ProcessProbeResult { + processes: Vec::new(), + process_group_id: Some(101), + foreground_is_pane_shell: false, + agent: None, + process_name: None, + }; + assert!(!registry.bind(&transient_miss, Some(original))); + assert_eq!( + pending_acquisition_republish + .then(|| registry.revalidated_bound_agent(&transient_miss, Some(original))) + .flatten(), + None + ); + assert!( + pending_acquisition_republish, + "a transient miss must leave generation reacquisition pending" + ); + + let refreshed_probe = fixture_probe(®istry, 101, "worker"); + assert!(!registry.bind(&refreshed_probe, Some(original))); + let reacquired = pending_acquisition_republish + .then(|| registry.revalidated_bound_agent(&refreshed_probe, Some(original))) + .flatten(); + assert_eq!(reacquired, Some(original)); + assert_eq!(registry.bound.as_ref().unwrap().identity, old_identity); + assert_eq!( + registry.bound.as_ref().unwrap().resume_recipe.as_ref(), + Some(&old_recipe) + ); + + let pane_id = PaneId::from_raw(42); + let observed_at = std::time::Instant::now(); + let (events, mut received) = mpsc::channel(1); + if publish_agent_process_detected_event( + registry.active.generation, + events, + pane_id, + reacquired.unwrap(), + observed_at, + ) + .await + { + pending_acquisition_republish = false; + } + assert!(!pending_acquisition_republish); + let AppEvent::AgentDetection { + registry_generation, + observation, + } = received.recv().await.unwrap() + else { + panic!("expected guarded process acquisition"); + }; + assert_eq!(registry_generation, 2); + assert!(matches!( + *observation, + AppEvent::AgentProcessDetected { + pane_id: detected_pane, + agent: detected_agent, + observed_at: detected_at, + } if detected_pane == pane_id + && detected_agent == original + && detected_at == observed_at + )); + + let mut terminal = crate::terminal::TerminalState::new( + crate::terminal::TerminalId::alloc(), + "/var/tmp".into(), + ); + let injected_at = observed_at - std::time::Duration::from_millis(2); + terminal.begin_managed_agent_with_readiness( + Some("recovering".into()), + original, + true, + injected_at, + std::time::Duration::ZERO, + std::time::Duration::from_secs(30), + ); + terminal.set_detected_state_with_screen_signals_at( + Some(original), + AgentState::Idle, + false, + true, + false, + false, + injected_at + std::time::Duration::from_millis(1), + ); + terminal.set_detected_agent_process_at(original, observed_at); + terminal.reconcile_managed_agent_at(observed_at, false); + assert!(!terminal.managed_agent_interactive_ready()); + + // The miss already published idle for this unchanged content sequence. + registry.needs_publish = false; + let mut last_scan = Some(10); + registry.request_screen_republish(&mut last_scan); + assert_eq!( + decide_detection_screen_read(DetectionScreenReadInput { + state: AgentState::Idle, + agent: Some(original), + pending_idle_active: false, + agent_changed: false, + process_exited: false, + current_detection_content_seq: Some(10), + last_screen_scan_detection_content_seq: last_scan, + }), + DetectionScreenReadDecision::Read + ); + let decision = decide_screen_detection_publish( + ScreenDetectionPublishInput { + force_publish: registry.needs_publish, + current_state: AgentState::Idle, + last_visible_idle: true, + last_screen_visible_idle: true, + last_visible_blocker: false, + last_visible_working: false, + last_visible_signal_refresh: Some(observed_at), + screen_detection: crate::detect::AgentDetection { + state: AgentState::Idle, + skip_state_update: false, + visible_idle: true, + screen_visible_idle: true, + visible_blocker: false, + visible_working: false, + }, + process_exited: false, + agent_changed: false, + startup_grace_active: true, + now: observed_at, + }, + &mut PendingIdleConfirmation::default(), + ); + let DetectionPublishDecision::Publish { + state, + screen_visible_idle, + visible_blocker, + visible_working, + process_exited, + .. + } = decision + else { + panic!("reacquisition must republish unchanged painted input"); + }; + terminal.set_detected_state_with_screen_signals_at( + Some(original), + state, + visible_blocker, + screen_visible_idle, + visible_working, + process_exited, + observed_at, + ); + terminal.reconcile_managed_agent_at(observed_at, false); + assert!(terminal.managed_agent_interactive_ready()); + } + + #[test] + fn generation_refresh_republication_rejects_stale_birth_evidence() { + let original = Agent::parse("zeta-agent").unwrap(); + let mut registry = + test_detection_registry(detection_registry_fixture(1, &[("zeta-agent", "worker")])); + let old_probe = fixture_probe(®istry, 101, "worker"); + assert!(registry.bind(&old_probe, Some(original))); + assert!(registry.refresh( + 2, + || detection_registry_fixture(2, &[("zeta-agent", "worker")]), + &mut true, + &mut Some(10), + )); + registry.read_process_identity = |pid| { + Some(crate::platform::ProcessIdentity { + pid, + birth_token: 2, + }) + }; + + assert_eq!( + registry.revalidated_bound_agent(&old_probe, Some(original)), + None, + "an old-generation probe must not become new acquisition evidence after PID reuse" + ); + } + + #[test] + fn retained_birth_identity_survives_pgid_change_without_reacquiring_current_recipe() { + let original = Agent::parse("zeta-agent").unwrap(); + let mut registry = + test_detection_registry(detection_registry_fixture(1, &[("zeta-agent", "worker")])); + let probe = fixture_probe(®istry, 101, "worker"); + assert!(registry.bind(&probe, Some(original))); + let old_recipe = crate::agent_resume::PinnedAgentResumeRecipe::unavailable("zeta-agent"); + registry.bound.as_mut().unwrap().resume_recipe = Some(old_recipe.clone()); + registry.refresh( + 2, + || detection_registry_fixture(2, &[("alpha-agent", "worker")]), + &mut true, + &mut None, + ); + let job = crate::platform::ForegroundJob { + process_group_id: 999, + processes: probe.processes.clone(), + }; + let retained = registry.retained_probe(&job, 42).unwrap(); + assert_eq!(retained.agent, Some(original)); + assert!( + registry.bind(&retained, Some(original)), + "group bookkeeping change must reach terminal state" + ); + assert_eq!(registry.bound.as_ref().unwrap().process_group_id, 999); + assert_eq!( + registry.bound.as_ref().unwrap().resume_recipe.as_ref(), + Some(&old_recipe) + ); + assert!(!registry.bind(&retained, Some(original))); + } + + #[test] + fn binding_change_detection_uses_birth_identity_not_mutable_process_text() { + let mut registry = + test_detection_registry(detection_registry_fixture(1, &[("zeta-agent", "worker")])); + let probe = fixture_probe(®istry, 999, "worker"); + assert!(registry.bind(&probe, probe.agent)); + assert!(!registry.bind(&probe, probe.agent)); + let mut changed = probe.clone(); + changed.processes[0].name = "renamed".into(); + changed.processes[0].argv = Some(vec!["different title".into()]); + assert!(!registry.bind(&changed, probe.agent)); + let job = crate::platform::ForegroundJob { + process_group_id: 999, + processes: changed.processes, + }; + assert!(registry.retained_probe(&job, 42).is_some()); + registry.read_process_identity = |pid| { + Some(crate::platform::ProcessIdentity { + pid, + birth_token: 2, + }) + }; + assert!(registry.retained_probe(&job, 42).is_none()); + assert!(registry.bind(&probe, probe.agent)); + registry.read_process_identity = |_| None; + assert!(registry.retained_probe(&job, 42).is_none()); + assert!(registry.bind(&probe, probe.agent)); + assert!(registry.bound.as_ref().unwrap().resume_recipe.is_none()); + assert!(!registry.bind(&probe, probe.agent)); + } + + #[test] + fn generation_switch_invalidates_both_caches_without_content_or_identity_reset() { + let first = detection_registry_fixture(1, &[("zeta-agent", "worker")]); + let second = + detection_registry_fixture(2, &[("alpha-agent", "other"), ("zeta-agent", "worker")]); + let mut registry = test_detection_registry(first); + let mut has_probe = true; + let mut last_scan = Some(10); + assert!(!registry.refresh( + 1, + || panic!("unchanged generation must not acquire"), + &mut has_probe, + &mut last_scan + )); + assert!(has_probe); + assert_eq!(last_scan, Some(10)); + assert!(registry.refresh(2, || second.clone(), &mut has_probe, &mut last_scan)); + assert!(!has_probe); + assert_eq!(last_scan, None); + assert!(registry.needs_publish); + assert_eq!( + decide_detection_screen_read(DetectionScreenReadInput { + state: AgentState::Idle, + agent: Some(Agent::parse("zeta-agent").unwrap()), + pending_idle_active: false, + agent_changed: false, + process_exited: false, + current_detection_content_seq: Some(10), + last_screen_scan_detection_content_seq: last_scan, + }), + DetectionScreenReadDecision::Read + ); + } + + #[test] + fn registry_reorder_and_reassigned_matcher_cannot_steal_bound_process() { + let original = Agent::parse("zeta-agent").unwrap(); + let mut registry = + test_detection_registry(detection_registry_fixture(1, &[("zeta-agent", "worker")])); + let probe = fixture_probe(®istry, 99_999_999, "worker"); + registry.bind(&probe, probe.agent); + assert_eq!(probe.agent, Some(original)); + let replacement = + detection_registry_fixture(2, &[("alpha-agent", "worker"), ("zeta-agent", "other")]); + registry.refresh(2, || replacement, &mut true, &mut Some(10)); + assert_eq!( + fixture_probe(®istry, 99_999_999, "worker").agent, + Some(original) + ); + assert_eq!(registry.screen_snapshot(Some(original)).generation, 2); + // A different PID is a genuine acquisition, not a continuation. + assert_eq!( + fixture_probe(®istry, 99_999_998, "worker").agent, + Some(Agent::parse("alpha-agent").unwrap()) + ); + } + + #[test] + fn removed_bound_profile_is_pinned_but_never_used_for_new_processes() { + let original = Agent::parse("zeta-agent").unwrap(); + let mut registry = + test_detection_registry(detection_registry_fixture(1, &[("zeta-agent", "worker")])); + let probe = fixture_probe(®istry, 99_999_999, "worker"); + registry.bind(&probe, probe.agent); + let replacement = detection_registry_fixture(2, &[("alpha-agent", "other")]); + registry.refresh(2, || replacement, &mut true, &mut Some(10)); + assert_eq!( + fixture_probe(®istry, 99_999_999, "worker").agent, + Some(original) + ); + assert_eq!(registry.screen_snapshot(Some(original)).generation, 1); + let retained_detection = detection_update_for_publish_with_registry( + registry.screen_snapshot(Some(original)), + Some(original), + "Working...", + "", + "", + false, + ) + .unwrap(); + assert_eq!(retained_detection.state, AgentState::Working); + assert_eq!(fixture_probe(®istry, 99_999_998, "worker").agent, None); + let shell_probe = fixture_probe(®istry, 42, "sh"); + let mut presence = AgentDetectionPresence::from_agent(Some(original)); + let action = foreground_shell_agent_action( + Some(original), + shell_probe.agent, + shell_probe.foreground_is_pane_shell, + false, + ); + assert_eq!(action, ForegroundShellAgentAction::ReportProcessExit); + let mut pending_clear = false; + let mut exit_reported = false; + apply_foreground_shell_agent_action( + &mut presence, + action, + Some(original), + None, + &mut pending_clear, + &mut exit_reported, + ); + assert!(pending_clear); + assert_eq!(presence.current_agent(), Some(original)); + // The ordinary idle exit transition still precedes clearing identity. + let detection = detection_update_for_publish_with_registry( + registry.screen_snapshot(Some(original)), + Some(original), + "Working...", + "", + "", + true, + ) + .unwrap(); + assert_eq!(detection.state, AgentState::Idle); + exit_reported = true; + let action = foreground_shell_agent_action(Some(original), None, true, exit_reported); + assert_eq!(action, ForegroundShellAgentAction::ClearAgent); + apply_foreground_shell_agent_action( + &mut presence, + action, + Some(original), + None, + &mut pending_clear, + &mut exit_reported, + ); + registry.bind(&shell_probe, presence.current_agent()); + assert_eq!(presence.current_agent(), None); + assert!(registry.bound.is_none()); + } + + #[test] + fn unknown_pane_acquires_new_package_after_generation_switch_without_output() { + let mut registry = + test_detection_registry(detection_registry_fixture(1, &[("alpha-agent", "other")])); + assert_eq!(fixture_probe(®istry, 99_999_999, "worker").agent, None); + let replacement = detection_registry_fixture(2, &[("zeta-agent", "worker")]); + let mut has_probe = true; + let mut last_scan = Some(10); + assert!(registry.refresh(2, || replacement, &mut has_probe, &mut last_scan)); + assert!(should_probe_foreground_job(ProcessProbeInput { + current_agent: None, + has_process_probe: has_probe, + ..process_probe_input() + })); + assert_eq!( + fixture_probe(®istry, 99_999_999, "worker").agent, + Some(Agent::parse("zeta-agent").unwrap()) + ); + } + #[test] fn foreground_agent_hint_wins_over_process_name_detection() { let job = crate::platform::ForegroundJob { @@ -5048,6 +6146,7 @@ mod tests { .unwrap(); let publish = publish_state_changed_event( + crate::agents::store::generation(), tx.clone(), pane_id, Some(Agent::Pi), @@ -5055,6 +6154,7 @@ mod tests { false, false, false, + false, std::time::Instant::now(), ); tokio::pin!(publish); @@ -5085,11 +6185,12 @@ mod tests { .expect("queue should yield second event") .expect("sender still alive"); assert!(matches!( - second, + second.into_current_detection(crate::agents::store::generation()).unwrap(), AppEvent::StateChanged { pane_id: delivered_pane, agent: Some(Agent::Pi), state: AgentState::Idle, + visible_idle: false, visible_blocker: false, visible_working: false, process_exited: false, diff --git a/src/pane/agent_detection.rs b/src/pane/agent_detection.rs index 9ef10dfedf..79c534c23f 100644 --- a/src/pane/agent_detection.rs +++ b/src/pane/agent_detection.rs @@ -175,6 +175,7 @@ pub(super) enum DetectionTransitionDecision { #[derive(Debug, Clone, Copy)] pub(super) struct DetectionTransitionInput { + pub(super) force_publish: bool, pub(super) previous_publish: DetectionPublishState, pub(super) next_publish: DetectionPublishState, pub(super) agent_changed: bool, @@ -197,13 +198,15 @@ pub(super) fn decide_detection_transition( return DetectionTransitionDecision::NoPublish; } - if should_publish_detection_update( - input.previous_publish, - input.next_publish, - input.agent_changed, - input.process_exited, - input.stable_refresh_due, - ) { + if input.force_publish + || should_publish_detection_update( + input.previous_publish, + input.next_publish, + input.agent_changed, + input.process_exited, + input.stable_refresh_due, + ) + { return DetectionTransitionDecision::PublishNext; } @@ -216,6 +219,7 @@ pub(super) enum DetectionPublishDecision { Publish { state: AgentState, visible_idle: bool, + screen_visible_idle: bool, visible_blocker: bool, visible_working: bool, process_exited: bool, @@ -224,14 +228,17 @@ pub(super) enum DetectionPublishDecision { #[derive(Debug, Clone, Copy)] pub(super) struct ScreenDetectionPublishInput { + pub(super) force_publish: bool, pub(super) current_state: AgentState, pub(super) last_visible_idle: bool, + pub(super) last_screen_visible_idle: bool, pub(super) last_visible_blocker: bool, pub(super) last_visible_working: bool, pub(super) last_visible_signal_refresh: Option, pub(super) screen_detection: AgentDetection, pub(super) process_exited: bool, pub(super) agent_changed: bool, + pub(super) startup_grace_active: bool, pub(super) now: std::time::Instant, } @@ -242,9 +249,23 @@ pub(super) fn decide_screen_detection_publish( let detection = input.screen_detection; let new_state = crate::terminal::state::stabilize_agent_detection(detection); let visible_idle = detection.visible_idle && new_state == AgentState::Idle; + let screen_visible_idle = detection.screen_visible_idle && new_state == AgentState::Idle; let visible_blocker = detection.visible_blocker && new_state == AgentState::Blocked; let visible_working = detection.visible_working && new_state == AgentState::Working; + // Startup grace debounces only the known-agent idle fallback. Explicit + // screen evidence must remain observable for strict managed readiness and + // for permission/working transitions shown during startup. + if input.startup_grace_active + && !input.process_exited + && !visible_idle + && !visible_blocker + && !visible_working + { + pending_idle.clear(); + return DetectionPublishDecision::NoPublish; + } + let previous_publish = DetectionPublishState { state: input.current_state, visible_idle: input.last_visible_idle, @@ -266,6 +287,8 @@ pub(super) fn decide_screen_detection_publish( match decide_detection_transition( DetectionTransitionInput { + force_publish: input.force_publish + || screen_visible_idle != input.last_screen_visible_idle, previous_publish, next_publish, agent_changed: input.agent_changed, @@ -279,6 +302,7 @@ pub(super) fn decide_screen_detection_publish( DetectionTransitionDecision::PublishNext => DetectionPublishDecision::Publish { state: new_state, visible_idle, + screen_visible_idle, visible_blocker, visible_working, process_exited: input.process_exited, @@ -286,33 +310,44 @@ pub(super) fn decide_screen_detection_publish( } } -#[allow(dead_code)] // shim for tests; detection_update_for_publish_with_osc is the real path -pub(super) fn detection_update_for_publish( - agent: Option, - content: &str, - process_exited: bool, -) -> Option { - detection_update_for_publish_with_osc(agent, content, "", "", process_exited) -} - -pub(super) fn detection_update_for_publish_with_osc( +pub(super) fn detection_update_for_publish_with_registry( + snapshot: &crate::agents::store::RegistrySnapshot, agent: Option, content: &str, osc_title: &str, osc_progress: &str, process_exited: bool, -) -> Option { +) -> Option { if process_exited { return Some(crate::detect::AgentDetection { state: AgentState::Idle, skip_state_update: false, visible_idle: true, + screen_visible_idle: false, visible_blocker: false, visible_working: false, }); } - let detection = crate::detect::detect_agent_with_osc(agent, content, osc_title, osc_progress); + let detection = match agent { + Some(agent) => crate::detect::manifest::detect_with_registry( + snapshot, + agent, + crate::detect::manifest::DetectionInput { + screen: content, + osc_title, + osc_progress, + }, + ), + None => AgentDetection { + state: AgentState::Unknown, + skip_state_update: false, + visible_idle: false, + screen_visible_idle: false, + visible_blocker: false, + visible_working: false, + }, + }; (!detection.skip_state_update).then_some(detection) } @@ -344,6 +379,7 @@ mod tests { state, skip_state_update: false, visible_idle: state == AgentState::Idle, + screen_visible_idle: state == AgentState::Idle, visible_blocker: false, visible_working: state == AgentState::Working, } @@ -355,14 +391,17 @@ mod tests { now: std::time::Instant, ) -> ScreenDetectionPublishInput { ScreenDetectionPublishInput { + force_publish: false, current_state, last_visible_idle: false, + last_screen_visible_idle: false, last_visible_blocker: false, last_visible_working: false, last_visible_signal_refresh: None, screen_detection, process_exited: false, agent_changed: false, + startup_grace_active: false, now, } } @@ -478,6 +517,7 @@ mod tests { assert_eq!( decide_detection_transition( DetectionTransitionInput { + force_publish: false, previous_publish: publish_state(AgentState::Idle), next_publish: blocked, agent_changed: false, @@ -491,6 +531,62 @@ mod tests { ); } + #[test] + fn generation_republish_preserves_pending_idle_confirmation() { + let now = std::time::Instant::now(); + let mut pending = PendingIdleConfirmation::default(); + let input = DetectionTransitionInput { + force_publish: true, + previous_publish: publish_state(AgentState::Working), + next_publish: publish_state(AgentState::Idle), + agent_changed: false, + process_exited: false, + stable_refresh_due: false, + now, + }; + assert_eq!( + decide_detection_transition(input, &mut pending), + DetectionTransitionDecision::NoPublish + ); + assert!(pending.active()); + assert_eq!( + decide_detection_transition(input, &mut pending), + DetectionTransitionDecision::NoPublish + ); + assert!(pending.active()); + assert_eq!( + decide_detection_transition( + DetectionTransitionInput { + now: now + AGENT_PENDING_IDLE_CAP, + ..input + }, + &mut pending + ), + DetectionTransitionDecision::PublishNext + ); + } + + #[test] + fn generation_republishes_unchanged_state_after_stale_observation_was_dropped() { + let now = std::time::Instant::now(); + let mut pending = PendingIdleConfirmation::default(); + assert_eq!( + decide_detection_transition( + DetectionTransitionInput { + force_publish: true, + previous_publish: publish_state(AgentState::Idle), + next_publish: publish_state(AgentState::Idle), + agent_changed: false, + process_exited: false, + stable_refresh_due: false, + now, + }, + &mut pending + ), + DetectionTransitionDecision::PublishNext + ); + } + #[test] fn screen_publish_keeps_visible_working_without_pty_activity() { let now = std::time::Instant::now(); @@ -504,6 +600,7 @@ mod tests { DetectionPublishDecision::Publish { state: AgentState::Working, visible_idle: false, + screen_visible_idle: false, visible_blocker: false, visible_working: true, process_exited: false, @@ -524,6 +621,7 @@ mod tests { DetectionPublishDecision::Publish { state: AgentState::Idle, visible_idle: true, + screen_visible_idle: true, visible_blocker: false, visible_working: false, process_exited: false, @@ -531,6 +629,73 @@ mod tests { ); } + #[test] + fn painted_idle_republishes_after_osc_idle_without_changing_status() { + let now = std::time::Instant::now(); + let mut pending = PendingIdleConfirmation::default(); + let mut osc = screen_detection(AgentState::Idle); + osc.screen_visible_idle = false; + assert!(matches!( + decide_screen_detection_publish( + screen_publish_input(AgentState::Unknown, osc, now), + &mut pending, + ), + DetectionPublishDecision::Publish { + state: AgentState::Idle, + visible_idle: true, + screen_visible_idle: false, + .. + } + )); + let mut painted = + screen_publish_input(AgentState::Idle, screen_detection(AgentState::Idle), now); + painted.last_visible_idle = true; + painted.last_visible_signal_refresh = Some(now); + assert!(matches!( + decide_screen_detection_publish(painted, &mut pending), + DetectionPublishDecision::Publish { + state: AgentState::Idle, + screen_visible_idle: true, + .. + } + )); + } + + #[test] + fn startup_grace_debounces_only_plain_fallback_and_keeps_positive_screen_evidence() { + let now = std::time::Instant::now(); + let mut pending_idle = PendingIdleConfirmation::default(); + let mut plain = screen_detection(AgentState::Idle); + plain.visible_idle = false; + plain.screen_visible_idle = false; + let mut input = screen_publish_input(AgentState::Unknown, plain, now); + input.startup_grace_active = true; + assert_eq!( + decide_screen_detection_publish(input, &mut pending_idle), + DetectionPublishDecision::NoPublish + ); + + for detection in [ + screen_detection(AgentState::Idle), + AgentDetection { + state: AgentState::Blocked, + skip_state_update: false, + visible_idle: false, + screen_visible_idle: false, + visible_blocker: true, + visible_working: false, + }, + screen_detection(AgentState::Working), + ] { + let mut input = screen_publish_input(AgentState::Unknown, detection, now); + input.startup_grace_active = true; + assert!(matches!( + decide_screen_detection_publish(input, &mut pending_idle), + DetectionPublishDecision::Publish { .. } + )); + } + } + #[test] fn detection_content_change_tracks_raw_nonempty_reads_for_scan_scheduling() { let seq = AtomicU64::new(0); diff --git a/src/persist/restore.rs b/src/persist/restore.rs index 3c5775c9e1..c835aed295 100644 --- a/src/persist/restore.rs +++ b/src/persist/restore.rs @@ -23,6 +23,7 @@ use super::{ }; struct AgentRestoreState<'a> { + registry: Arc, enabled: bool, resumed_sessions: &'a mut HashSet, } @@ -35,6 +36,7 @@ struct PaneRestoreStartup<'a> { } struct RestoreRuntimeContext<'a> { + registry: Arc, scrollback_limit_bytes: usize, shell_config: crate::pane::PaneShellConfig<'a>, resume_agents_on_restore: bool, @@ -272,8 +274,10 @@ fn restore_with_imports_and_failures( let mut terminal_runtimes = HashMap::new(); let mut resumed_agent_sessions = HashSet::new(); let mut failed_imports = 0; + let registry = crate::agents::registry(); for (idx, ws_snap) in snapshot.workspaces.iter().enumerate() { let runtime_context = RestoreRuntimeContext { + registry: registry.clone(), scrollback_limit_bytes, shell_config, resume_agents_on_restore, @@ -494,13 +498,14 @@ fn restore_tab( let saved_agent_name = saved_pane.and_then(|p| p.agent_name.clone()); let saved_managed_agent = saved_pane .and_then(|pane| pane.managed_agent_kind.as_deref()) - .and_then(crate::detect::parse_canonical_agent_label); + .and_then(|id| crate::detect::Agent::parse(id).ok()); let saved_launch_argv = saved_pane.and_then(|p| p.launch_argv.clone()); let saved_agent_session = saved_pane.and_then(|p| p.agent_session.as_ref()); let saved_history = old_id.and_then(|old_id| history.and_then(|history| history.panes.get(old_id))); let startup = { let mut agent_restore = AgentRestoreState { + registry: runtime_context.registry.clone(), enabled: runtime_context.resume_agents_on_restore, resumed_sessions: resumed_agent_sessions, }; @@ -511,7 +516,7 @@ fn restore_tab( let initial_restore_agent = startup .restore_plan .as_ref() - .and_then(|plan| crate::detect::parse_agent_label(&plan.agent)); + .and_then(|plan| crate::detect::Agent::parse(&plan.agent).ok()); let old_pane_id = reverse_id_map.get(id).copied(); let public_pane_id = old_pane_id @@ -535,31 +540,25 @@ fn restore_tab( }; if let Some(plan) = pending_native_agent_restore { let terminal_id = TerminalId::alloc(); + let strict_input_readiness = plan.strict_input_readiness; let mut terminal = TerminalState::new(terminal_id.clone(), cwd.clone()) .with_pending_agent_resume_plan(plan); if let Some(label) = saved_label { terminal.set_manual_label(label); } if let Some(session) = restored_agent_session { - terminal.set_persisted_agent_session(session); - } - match (saved_agent_name, saved_managed_agent) { - (Some(agent_name), Some(agent)) => { - terminal.restore_managed_agent(agent_name, agent) - } - (Some(_), None) => {} - (None, _) => {} + terminal.restore_agent_session( + session, + saved_agent_session.map_or_else(Vec::new, |saved| saved.resume_options.clone()), + ); + terminal.pinned_agent_resume_recipe = + saved_agent_session.and_then(|session| session.recipe.clone()); } if let Some(agent) = initial_restore_agent { - let _ = terminal.set_detected_state_with_screen_signals_at( - Some(agent), - AgentState::Idle, - false, - false, - false, - false, - std::time::Instant::now(), - ); + let restored_name = (saved_managed_agent == Some(agent)) + .then_some(saved_agent_name) + .flatten(); + terminal.queue_managed_agent(restored_name, agent, strict_input_readiness); } panes.insert(*id, PaneState::new(terminal_id)); terminals.push(terminal); @@ -638,7 +637,29 @@ fn restore_tab( terminal.set_manual_label(label); } if let Some(session) = restored_agent_session { - terminal.set_persisted_agent_session(session); + terminal.restore_agent_session( + session, + saved_agent_session + .map_or_else(Vec::new, |saved| saved.resume_options.clone()), + ); + terminal.pinned_agent_resume_recipe = + saved_agent_session.and_then(|session| session.recipe.clone()); + } + if was_imported { + if let Some(session) = saved_agent_session { + if let Ok(agent) = crate::detect::Agent::parse(&session.agent) { + let recipe = session.recipe.clone().or_else(|| { + crate::agents::bundled_profile(&session.agent) + .and_then(crate::agent_resume::PinnedAgentResumeRecipe::capture) + }); + terminal.admit_agent_resume_recipe( + agent, + recipe, + terminal.persisted_agent_session.clone(), + std::time::Instant::now(), + ); + } + } } match (saved_agent_name, saved_managed_agent) { (Some(agent_name), Some(agent)) if was_imported => { @@ -745,8 +766,13 @@ fn pane_restore_startup<'a>( // resumable agent session and resume is enabled, do not replay saved pane // presentation history into that terminal, even when this pane is a // duplicate suppressed by session de-duplication. - let restore_plan = - session.and_then(|session| restore_plan_for_snapshot(session, agent_restore.enabled)); + let restore_plan = session.and_then(|session| { + restore_plan_for_snapshot_with_registry( + session, + agent_restore.enabled, + &agent_restore.registry, + ) + }); let has_native_agent_restore = restore_plan.is_some(); // Reserve before spawning so later panes in the same restore pass cannot // launch the same native agent session. The caller rolls this reservation @@ -781,21 +807,36 @@ fn pane_restore_startup<'a>( } } -fn restore_plan_for_snapshot( +fn restore_plan_for_snapshot_with_registry( session: &PaneAgentSessionSnapshot, resume_agents_on_restore: bool, + registry: &crate::agents::RegistrySnapshot, ) -> Option { if !resume_agents_on_restore { return None; } let persisted = persisted_agent_session_from_snapshot(session)?; - crate::agent_resume::plan(&session.source, &session.agent, &persisted.session_ref) + match crate::agent_resume::pinned_plan(registry, &persisted, session.recipe.as_ref()) { + Ok(mut plan) => { + plan.resume_options = registry + .profile_by_id(&session.agent) + .and_then(|profile| profile.session()) + .map_or_else(Vec::new, |profile| { + profile.resume_options.filter(&session.resume_options) + }); + Some(plan) + } + Err(reason) => { + warn!(agent = %session.agent, reason, "automatic agent resume disabled; session metadata retained"); + None + } + } } fn persisted_agent_session_from_snapshot( session: &PaneAgentSessionSnapshot, ) -> Option { - crate::agent_resume::session_ref_from_snapshot( + crate::agent_resume::retained_snapshot_session( &session.source, &session.agent, session.kind, @@ -813,6 +854,13 @@ fn restored_terminal_agent_session( session.and_then(persisted_agent_session_from_snapshot) } +#[cfg(test)] +fn restore_plan_for_snapshot( + session: &PaneAgentSessionSnapshot, + enabled: bool, +) -> Option { + restore_plan_for_snapshot_with_registry(session, enabled, &crate::agents::registry()) +} #[cfg(test)] fn take_restore_plan_for_snapshot( session: &PaneAgentSessionSnapshot, @@ -917,6 +965,192 @@ fn collect_ids_inner(node: &Node, ids: &mut Vec) { mod tests { use super::*; + #[test] + fn resume_options_snapshot_is_optional_bounded_and_revalidated_for_novel_agents() { + let registry = crate::agent_resume::resume_options_test_registry(1, "options=['--model']"); + let recipe = crate::agent_resume::PinnedAgentResumeRecipe::capture( + registry.profile_by_id("novel-options").unwrap(), + ) + .unwrap(); + let mut json = serde_json::json!({"source":"herdr:launch", "agent":"novel-options", "kind":"id", "value":"native", "recipe":recipe}); + let old: PaneAgentSessionSnapshot = serde_json::from_value(json.clone()).unwrap(); + assert!(old.resume_options.is_empty()); + json["resume_options"] = + serde_json::json!(["--model", "chosen model", "--yolo", "--resume=other"]); + let saved: PaneAgentSessionSnapshot = serde_json::from_value(json.clone()).unwrap(); + let plan = restore_plan_for_snapshot_with_registry(&saved, true, ®istry).unwrap(); + assert_eq!(plan.resume_options, ["--model", "chosen model"]); + json["resume_options"] = serde_json::json!(["x".repeat(4097)]); + assert!(serde_json::from_value::(json.clone()).is_err()); + json["resume_options"] = serde_json::json!(vec!["--model"; 129]); + assert!(serde_json::from_value::(json).is_err()); + } + + #[tokio::test] + async fn missing_dynamic_package_preserves_recipe_and_metadata_without_auto_resume() { + let registry = crate::agent_resume::test_registry( + "novel-42", + "shared-cli", + "separate_flag", + "--session", + ); + let recipe = crate::agent_resume::PinnedAgentResumeRecipe::capture( + registry.profile_by_id("novel-42").unwrap(), + ) + .unwrap(); + let mut state = crate::app::AppState::test_with_adversarial_identity_state(); + state.assert_invariants_for_test(); + let ws = &state.workspaces[0]; + let tab_idx = ws.active_tab; + let pane_id = ws.tabs[tab_idx].root_pane; + let terminal_id = ws.terminal_id(pane_id).unwrap().clone(); + let public_number = ws.public_pane_number(pane_id).unwrap(); + let public_tab_number = ws.tabs[tab_idx].number; + let terminal = state.terminals.get_mut(&terminal_id).unwrap(); + terminal.restore_managed_agent( + "reviewer".into(), + registry.profile_by_id("novel-42").unwrap().legacy_agent(), + ); + terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { + source: "herdr:launch".into(), + agent: "novel-42".into(), + session_ref: crate::agent_resume::AgentSessionRef::id("native-id").unwrap(), + }); + terminal.pinned_agent_resume_recipe = Some(recipe.clone()); + let snapshot = super::super::snapshot::capture( + &state.workspaces, + &state.terminals, + &crate::terminal::TerminalRuntimeRegistry::new(), + state.active, + state.selected, + ); + let session = snapshot.workspaces[0].tabs[tab_idx].panes[&pane_id.raw()] + .agent_session + .clone() + .unwrap(); + let snapshot: SessionSnapshot = + serde_json::from_str(&serde_json::to_string(&snapshot).unwrap()).unwrap(); + let (events, _rx) = mpsc::channel(32); + let (workspaces, terminals, runtimes) = restore( + &snapshot, + None, + 24, + 80, + 0, + test_restore_shell(), + crate::config::ShellModeConfig::NonLogin, + true, + events, + Arc::new(Notify::new()), + Arc::new(RenderSignal::new()), + ); + state.workspaces = workspaces; + state.terminals = terminals; + state.assert_invariants_for_test(); + let ws = &state.workspaces[0]; + assert_eq!(ws.tabs[tab_idx].number, public_tab_number); + let restored_pane = ws.tabs[tab_idx].root_pane; + assert_eq!(ws.public_pane_number(restored_pane), Some(public_number)); + let terminal = &state.terminals[ws.terminal_id(restored_pane).unwrap()]; + assert!(terminal.pending_agent_resume_plan.is_none()); + assert!(crate::agent_resume::pinned_plan( + &crate::agents::registry(), + terminal.persisted_agent_session.as_ref().unwrap(), + terminal.pinned_agent_resume_recipe.as_ref(), + ) + .unwrap_err() + .contains("missing")); + assert!(terminal.hook_authority.is_none()); + assert!(terminal.managed_agent_kind().is_none()); + assert_eq!( + terminal.persisted_agent_session.as_ref().unwrap().agent, + "novel-42" + ); + assert_eq!(terminal.pinned_agent_resume_recipe.as_ref(), Some(&recipe)); + let captured = super::super::snapshot::capture( + &state.workspaces, + &state.terminals, + &crate::terminal::TerminalRuntimeRegistry::new(), + state.active, + state.selected, + ); + let saved = captured.workspaces[0].tabs[tab_idx].panes[&restored_pane.raw()] + .agent_session + .as_ref() + .unwrap(); + assert_eq!(saved, &session); + for runtime in runtimes.into_values() { + runtime.shutdown(); + } + } + + #[test] + fn restore_plan_uses_one_retained_snapshot() { + let old = crate::agent_resume::test_registry("novel-42", "old-cli", "subcommand", "resume"); + let new = crate::agent_resume::test_registry("novel-42", "new-cli", "subcommand", "resume"); + let session = PaneAgentSessionSnapshot { + resume_options: Vec::new(), + source: "herdr:launch".into(), + agent: "novel-42".into(), + kind: crate::agent_resume::AgentSessionRefKind::Id, + value: "native-id".into(), + recipe: old + .profile_by_id("novel-42") + .and_then(crate::agent_resume::PinnedAgentResumeRecipe::capture), + }; + assert_eq!( + restore_plan_for_snapshot_with_registry(&session, true, &old) + .unwrap() + .argv[0], + "old-cli" + ); + assert!(restore_plan_for_snapshot_with_registry(&session, true, &new).is_none()); + assert!(crate::agent_resume::pinned_plan( + &new, + &persisted_agent_session_from_snapshot(&session).unwrap(), + session.recipe.as_ref(), + ) + .unwrap_err() + .contains("changed")); + } + + #[test] + fn rejected_recipe_keeps_history_and_does_not_reserve_a_session() { + let mut session = PaneAgentSessionSnapshot { + resume_options: Vec::new(), + source: "herdr:codex".into(), + agent: "codex".into(), + kind: crate::agent_resume::AgentSessionRefKind::Id, + value: "native-id".into(), + recipe: crate::agents::bundled_profile("codex") + .and_then(crate::agent_resume::PinnedAgentResumeRecipe::capture), + }; + session.recipe.as_mut().unwrap().token = "changed".into(); + let history = PaneHistorySnapshot { + ansi: "saved history".into(), + lines: 1, + }; + let mut resumed = HashSet::new(); + let mut state = AgentRestoreState { + registry: crate::agents::registry(), + enabled: true, + resumed_sessions: &mut resumed, + }; + let startup = pane_restore_startup(Some(&session), Some(&history), &mut state); + assert!(startup.restore_plan.is_none()); + assert_eq!(startup.initial_history_ansi, Some("saved history")); + assert!(!startup.duplicate_agent_session); + assert!(state.resumed_sessions.is_empty()); + assert!(restored_terminal_agent_session(Some(&session), false).is_some()); + assert!(crate::agent_resume::pinned_plan( + &state.registry, + &persisted_agent_session_from_snapshot(&session).unwrap(), + session.recipe.as_ref(), + ) + .unwrap_err() + .contains("changed")); + } + fn test_session_path(name: &str) -> String { std::env::current_dir() .unwrap() @@ -1013,6 +1247,8 @@ mod tests { fn restore_plan_respects_opt_in_and_allowlist() { let pi_session_path = test_session_path("pi-session.jsonl"); let session = super::super::snapshot::PaneAgentSessionSnapshot { + resume_options: Vec::new(), + recipe: None, source: "herdr:pi".into(), agent: "pi".into(), kind: crate::agent_resume::AgentSessionRefKind::Path, @@ -1026,6 +1262,8 @@ mod tests { ); let unsupported_path = super::super::snapshot::PaneAgentSessionSnapshot { + resume_options: Vec::new(), + recipe: None, source: "herdr:claude".into(), agent: "claude".into(), kind: crate::agent_resume::AgentSessionRefKind::Path, @@ -1038,6 +1276,8 @@ mod tests { fn restore_plan_selection_suppresses_duplicates() { let pi_session_path = test_session_path("pi-session.jsonl"); let session = super::super::snapshot::PaneAgentSessionSnapshot { + resume_options: Vec::new(), + recipe: None, source: "herdr:pi".into(), agent: "pi".into(), kind: crate::agent_resume::AgentSessionRefKind::Path, @@ -1060,6 +1300,8 @@ mod tests { #[test] fn pane_restore_startup_suppresses_history_for_native_agent_resume() { let session = super::super::snapshot::PaneAgentSessionSnapshot { + resume_options: Vec::new(), + recipe: None, source: "herdr:pi".into(), agent: "pi".into(), kind: crate::agent_resume::AgentSessionRefKind::Path, @@ -1071,6 +1313,7 @@ mod tests { }; let mut resumed = HashSet::new(); let mut agent_restore = AgentRestoreState { + registry: crate::agents::registry(), enabled: true, resumed_sessions: &mut resumed, }; @@ -1085,6 +1328,8 @@ mod tests { #[test] fn pane_restore_startup_suppresses_history_for_duplicate_native_agent_session() { let session = super::super::snapshot::PaneAgentSessionSnapshot { + resume_options: Vec::new(), + recipe: None, source: "herdr:pi".into(), agent: "pi".into(), kind: crate::agent_resume::AgentSessionRefKind::Path, @@ -1096,6 +1341,7 @@ mod tests { }; let mut resumed = HashSet::new(); let mut agent_restore = AgentRestoreState { + registry: crate::agents::registry(), enabled: true, resumed_sessions: &mut resumed, }; @@ -1113,6 +1359,8 @@ mod tests { #[test] fn pane_restore_startup_keeps_history_without_native_agent_resume() { let session = super::super::snapshot::PaneAgentSessionSnapshot { + resume_options: Vec::new(), + recipe: None, source: "herdr:pi".into(), agent: "pi".into(), kind: crate::agent_resume::AgentSessionRefKind::Path, @@ -1124,6 +1372,7 @@ mod tests { }; let mut resumed = HashSet::new(); let mut agent_restore = AgentRestoreState { + registry: crate::agents::registry(), enabled: false, resumed_sessions: &mut resumed, }; @@ -1139,6 +1388,8 @@ mod tests { #[test] fn restore_rehydrates_agent_session_metadata() { let session = super::super::snapshot::PaneAgentSessionSnapshot { + resume_options: Vec::new(), + recipe: None, source: "herdr:hermes".into(), agent: "hermes".into(), kind: crate::agent_resume::AgentSessionRefKind::Id, @@ -1155,6 +1406,8 @@ mod tests { #[test] fn restore_does_not_rehydrate_duplicate_agent_session_metadata() { let session = super::super::snapshot::PaneAgentSessionSnapshot { + resume_options: Vec::new(), + recipe: None, source: "herdr:pi".into(), agent: "pi".into(), kind: crate::agent_resume::AgentSessionRefKind::Path, @@ -1192,6 +1445,8 @@ mod tests { agent_name: Some("reviewer".into()), managed_agent_kind: Some("opencode".into()), agent_session: Some(super::super::snapshot::PaneAgentSessionSnapshot { + resume_options: Vec::new(), + recipe: None, source: "herdr:opencode".into(), agent: "opencode".into(), kind: crate::agent_resume::AgentSessionRefKind::Id, @@ -1352,6 +1607,8 @@ mod tests { agent_name: Some("planner".into()), managed_agent_kind: None, agent_session: Some(super::super::snapshot::PaneAgentSessionSnapshot { + resume_options: Vec::new(), + recipe: None, source: "herdr:codex".into(), agent: "codex".into(), kind: crate::agent_resume::AgentSessionRefKind::Id, @@ -1479,8 +1736,11 @@ mod tests { #[tokio::test] #[cfg(unix)] - async fn native_agent_restore_defers_runtime_launch() { + async fn native_agent_restore_defers_runtime_launch_without_claiming_readiness() { let cwd = std::env::current_dir().unwrap(); + let recipe = crate::agents::bundled_profile("codex") + .and_then(crate::agent_resume::PinnedAgentResumeRecipe::capture) + .expect("bundled codex resume recipe"); let snapshot = SessionSnapshot { version: super::super::snapshot::SNAPSHOT_VERSION, workspaces: vec![WorkspaceSnapshot { @@ -1503,6 +1763,8 @@ mod tests { agent_name: None, managed_agent_kind: None, agent_session: Some(super::super::snapshot::PaneAgentSessionSnapshot { + resume_options: Vec::new(), + recipe: Some(recipe.clone()), source: "herdr:codex".into(), agent: "codex".into(), kind: crate::agent_resume::AgentSessionRefKind::Id, @@ -1547,6 +1809,21 @@ mod tests { terminal.pending_agent_resume_plan.is_some(), "restored native agent panes should defer resume until client terminal context is known" ); + assert!( + terminal.is_agent_terminal(), + "unnamed queued restore remains addressable" + ); + assert_eq!(terminal.agent_name, None); + assert_eq!( + terminal.managed_agent_kind(), + Some(crate::detect::Agent::Codex) + ); + assert!(terminal.managed_agent_launch_pending()); + assert!(!terminal.managed_agent_interactive_ready()); + assert_eq!(terminal.next_managed_agent_deadline(), None); + assert_eq!(terminal.detected_agent, None); + assert_eq!(terminal.state, AgentState::Unknown); + assert_eq!(terminal.pinned_agent_resume_recipe.as_ref(), Some(&recipe)); assert!( !terminal.respawn_shell_on_exit, "deferred agent resume should not use native restore lifecycle before launch" diff --git a/src/persist/snapshot.rs b/src/persist/snapshot.rs index e6ca70f9d8..ed6d8c934d 100644 --- a/src/persist/snapshot.rs +++ b/src/persist/snapshot.rs @@ -111,6 +111,14 @@ pub struct PaneSnapshot { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PaneAgentSessionSnapshot { + #[serde( + default, + skip_serializing_if = "Vec::is_empty", + deserialize_with = "crate::agents::session::deserialize_resume_options" + )] + pub resume_options: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub recipe: Option, pub source: String, pub agent: String, pub kind: crate::agent_resume::AgentSessionRefKind, @@ -330,31 +338,35 @@ fn capture_tab( terminal.agent_name.clone(), terminal .managed_agent_kind() - .map(|agent| crate::detect::agent_label(agent).to_string()), + .map(|agent| crate::detect::agent_label(&agent).to_string()), ) }) .unwrap_or_default(); let launch_argv = terminal.and_then(|terminal| terminal.launch_argv.clone()); let agent_session = terminal.and_then(|terminal| { - if let Some(authority) = terminal.hook_authority.as_ref() { - if let Some(session_ref) = authority.session_ref.as_ref() { - return Some(PaneAgentSessionSnapshot { + let hook_session = terminal.hook_authority.as_ref().and_then(|authority| { + authority.session_ref.as_ref().map(|session_ref| { + crate::agent_resume::PersistedAgentSession { source: authority.source.clone(), agent: authority.agent_label.clone(), - kind: session_ref.kind, - value: session_ref.value.clone(), - }); - } - } - terminal - .persisted_agent_session - .as_ref() - .map(|session| PaneAgentSessionSnapshot { - source: session.source.clone(), - agent: session.agent.clone(), - kind: session.session_ref.kind, - value: session.session_ref.value.clone(), + session_ref: session_ref.clone(), + } }) + }); + let session = hook_session + .as_ref() + .or(terminal.persisted_agent_session.as_ref())?; + Some(PaneAgentSessionSnapshot { + resume_options: terminal.resume_options_for_session(session).to_vec(), + recipe: terminal + .pinned_agent_resume_recipe + .clone() + .filter(|recipe| recipe.agent == session.agent), + source: session.source.clone(), + agent: session.agent.clone(), + kind: session.session_ref.kind, + value: session.session_ref.value.clone(), + }) }); panes.insert( id.raw(), @@ -578,6 +590,7 @@ mod tests { assert_eq!(pending_pane.managed_agent_kind, None); let terminal = state.terminals.get_mut(&terminal_id).unwrap(); + terminal.set_detected_agent_process_at(crate::detect::Agent::Pi, now); terminal.set_detected_state( Some(crate::detect::Agent::Pi), crate::detect::AgentState::Idle, diff --git a/src/platform/client_state.rs b/src/platform/client_state.rs index d339bae82e..4473805cdb 100644 --- a/src/platform/client_state.rs +++ b/src/platform/client_state.rs @@ -2,12 +2,12 @@ use std::path::Path; #[cfg(not(windows))] pub(crate) fn create_private_state_file(path: &Path) -> std::io::Result { - super::create_remote_ssh_config_file(path) + super::create_private_file(path) } #[cfg(windows)] pub(crate) fn create_private_state_file(path: &Path) -> std::io::Result { - super::windows::create_remote_ssh_config_file(path) + super::windows::create_private_file(path) } #[cfg(not(windows))] diff --git a/src/platform/fallback.rs b/src/platform/fallback.rs index a533796fce..677e359ede 100644 --- a/src/platform/fallback.rs +++ b/src/platform/fallback.rs @@ -56,9 +56,7 @@ pub(crate) fn create_remote_ssh_config_dir(_control_socket_name: &str) -> std::i )) } -pub(crate) fn create_remote_ssh_config_file( - path: &std::path::Path, -) -> std::io::Result { +pub(crate) fn create_private_file(path: &std::path::Path) -> std::io::Result { let mut options = std::fs::OpenOptions::new(); options.write(true).create_new(true); #[cfg(unix)] @@ -215,6 +213,11 @@ pub fn session_processes(_child_pid: u32) -> Vec { pub fn signal_processes(_pids: &[u32], _signal: Signal) {} /// Unsupported platform stub. +/// Unsupported platforms cannot prove that a PID still names a bound process. +pub(crate) fn process_identity(_pid: u32) -> Option { + None +} + pub fn process_exists(_pid: u32) -> bool { false } diff --git a/src/platform/linux.rs b/src/platform/linux.rs index ac0645c1ce..31c0c33bed 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -1,6 +1,6 @@ use std::{ collections::{HashSet, VecDeque}, - io::Write, + io::{Read, Write}, os::fd::RawFd, path::PathBuf, process::{Command, Stdio}, @@ -13,8 +13,8 @@ use super::{ }; pub(crate) use super::unix_common::{ - configure_status_command, create_remote_private_dir, create_remote_ssh_config_dir, - create_remote_ssh_config_file, hostname, local_datetime, remote_bridge_endpoint_path, + configure_status_command, create_private_file, create_remote_private_dir, + create_remote_ssh_config_dir, hostname, local_datetime, remote_bridge_endpoint_path, remote_private_temp_base, remote_reattach_argument, remote_reattach_program, remote_ssh_config_paths, set_default_plugin_pane_pwd, status_commands_supported, wait_client_stream_readable, StatusCommandGuard, @@ -366,15 +366,19 @@ fn process_pgrp_and_comm_from_stat(stat: &str) -> Option<(i32, String)> { fn process_argv(pid: u32) -> Option> { let bytes = std::fs::read(format!("/proc/{pid}/cmdline")).ok()?; + parse_proc_cmdline(&bytes) +} + +fn parse_proc_cmdline(bytes: &[u8]) -> Option> { if bytes.is_empty() { return None; } - let parts: Vec = bytes - .split(|&b| b == 0) - .filter(|part| !part.is_empty()) - .map(|part| String::from_utf8_lossy(part).into_owned()) - .collect(); - (!parts.is_empty()).then_some(parts) + bytes + .strip_suffix(&[0]) + .unwrap_or(bytes) + .split(|&byte| byte == 0) + .map(|part| std::str::from_utf8(part).map(str::to_owned).ok()) + .collect() } /// Get the current working directory of a process. @@ -388,11 +392,18 @@ pub fn process_cwd(pid: u32) -> Option { /// Read a Herdr agent identity hint from a process environment. pub fn process_agent_hint(pid: u32) -> Option { + process_agent_hint_with_registry(&crate::agents::registry(), pid) +} + +pub(crate) fn process_agent_hint_with_registry( + registry: &crate::agents::AgentRegistry, + pid: u32, +) -> Option { if pid == 0 { return None; } let environ = std::fs::read(format!("/proc/{pid}/environ")).ok()?; - super::parse_agent_env_hint(&environ) + super::parse_agent_env_hint_with_registry(registry, &environ) } pub fn session_processes(child_pid: u32) -> Vec { @@ -437,6 +448,59 @@ pub fn signal_processes(pids: &[u32], signal: Signal) { } } +/// Query only when acquiring or revalidating a bound process, not once per +/// process candidate. The token and `/proc` read use fixed-size stack storage. +pub(crate) fn process_identity(pid: u32) -> Option { + if pid == 0 { + return None; + } + let mut path = [0u8; 32]; + let path_len = { + let mut writer = std::io::Cursor::new(path.as_mut_slice()); + write!(&mut writer, "/proc/{pid}/stat").ok()?; + writer.position() as usize + }; + let path = std::str::from_utf8(&path[..path_len]).ok()?; + let mut file = std::fs::File::open(path).ok()?; + let mut stat = [0u8; 4096]; + let length = file.read(&mut stat).ok()?; + if length == stat.len() { + return None; + } + process_identity_from_stat(pid, &stat[..length]) +} + +fn process_identity_from_stat(pid: u32, stat: &[u8]) -> Option { + let name_start = stat.iter().position(|&byte| byte == b'(')?; + let observed_pid = std::str::from_utf8(&stat[..name_start]) + .ok()? + .trim() + .parse::() + .ok()?; + if pid == 0 || observed_pid != pid { + return None; + } + // comm can contain spaces, newlines, closing parens, and non-UTF-8 bytes. + // Numeric stat fields after its final ')' cannot contain parens. + let name_end = stat.iter().rposition(|&byte| byte == b')')?; + if name_end <= name_start { + return None; + } + let mut fields = stat[name_end + 1..] + .split(|byte| byte.is_ascii_whitespace()) + .filter(|field| !field.is_empty()); + let state = fields.next()?; + if state.len() != 1 || matches!(state, b"Z" | b"X" | b"x") { + return None; + } + // state is field 3; starttime is field 22 (the nineteenth following field). + let birth_token = std::str::from_utf8(fields.nth(18)?) + .ok()? + .parse::() + .ok()?; + Some(super::ProcessIdentity { pid, birth_token }) +} + pub fn process_exists(pid: u32) -> bool { if pid == 0 { return false; @@ -841,6 +905,26 @@ mod tests { LOCK.get_or_init(|| Mutex::new(())) } + #[test] + fn process_argv_preserves_exact_argument_boundaries() { + assert_eq!( + parse_proc_cmdline(b"agent\0--model\0model name\0\0\0"), + Some(vec![ + "agent".into(), + "--model".into(), + "model name".into(), + "".into(), + "".into() + ]) + ); + assert_eq!(parse_proc_cmdline(b"agent\0\xff\0"), None); + assert_eq!(parse_proc_cmdline(b""), None); + assert_eq!( + process_argv(std::process::id()), + Some(std::env::args().collect()) + ); + } + #[test] fn wsl_marker_detection_matches_kernel_release_text() { assert!(text_indicates_wsl("5.15.167.4-microsoft-standard-WSL2")); @@ -1073,6 +1157,84 @@ mod tests { assert_eq!(discover(), vec![200, 201]); } + fn identity_stat(pid: u32, name: &[u8], state: u8, birth: &str) -> Vec { + let mut stat = format!("{pid} (").into_bytes(); + stat.extend_from_slice(name); + stat.extend_from_slice( + format!(") {} {}{birth} 0 0\n", char::from(state), "0 ".repeat(18)).as_bytes(), + ); + stat + } + + #[test] + fn process_identity_stat_ignores_mutable_names_and_distinguishes_birth_ticks() { + let first = + process_identity_from_stat(123, &identity_stat(123, b"worker", b'S', "456")).unwrap(); + assert_eq!( + first, + super::super::ProcessIdentity { + pid: 123, + birth_token: 456 + } + ); + assert_eq!( + Some(first), + process_identity_from_stat(123, &identity_stat(123, b"renamed ) (\n\xff", b'R', "456")) + ); + assert_ne!( + Some(first), + process_identity_from_stat(123, &identity_stat(123, b"worker", b'S', "457")) + ); + } + + #[test] + fn process_identity_stat_rejects_exits_mismatches_and_malformed_ticks() { + for state in [b'Z', b'X', b'x'] { + assert_eq!( + process_identity_from_stat(123, &identity_stat(123, b"worker", state, "456")), + None + ); + } + for birth in ["-1", "18446744073709551616", "not-a-number", ""] { + let mut stat = identity_stat(123, b"worker", b'S', birth); + if birth.is_empty() { + stat.truncate(stat.len() - 5); + } + assert_eq!(process_identity_from_stat(123, &stat), None); + } + assert_eq!( + process_identity_from_stat(124, &identity_stat(123, b"worker", b'S', "456")), + None + ); + assert_eq!( + process_identity_from_stat(123, b"123 (truncated) S 1 2"), + None + ); + assert_eq!( + process_identity_from_stat(0, &identity_stat(0, b"worker", b'S', "456")), + None + ); + } + + #[test] + fn process_identity_survives_native_thread_title_changes() { + let tid = unsafe { libc::syscall(libc::SYS_gettid) } as u32; + let before = process_identity(tid).expect("current thread birth ticks"); + let mut original = [0u8; 16]; + assert_eq!( + unsafe { libc::prctl(libc::PR_GET_NAME, original.as_mut_ptr()) }, + 0 + ); + assert_eq!( + unsafe { libc::prctl(libc::PR_SET_NAME, c"renamed) worker".as_ptr()) }, + 0 + ); + let after = process_identity(tid); + let restored = unsafe { libc::prctl(libc::PR_SET_NAME, original.as_ptr()) }; + assert_eq!(restored, 0); + assert_eq!(after, Some(before)); + } + #[test] fn proc_stat_parsing_keeps_group_leader_inputs_live() { assert_eq!( diff --git a/src/platform/macos.rs b/src/platform/macos.rs index 8840c7ed9b..b1ed4347c1 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -13,8 +13,8 @@ use super::{ }; pub(crate) use super::unix_common::{ - configure_status_command, create_remote_private_dir, create_remote_ssh_config_dir, - create_remote_ssh_config_file, hostname, local_datetime, remote_bridge_endpoint_path, + configure_status_command, create_private_file, create_remote_private_dir, + create_remote_ssh_config_dir, hostname, local_datetime, remote_bridge_endpoint_path, remote_private_temp_base, remote_reattach_argument, remote_reattach_program, remote_ssh_config_paths, set_default_plugin_pane_pwd, status_commands_supported, wait_client_stream_readable, StatusCommandGuard, @@ -797,11 +797,18 @@ fn process_argv(pid: u32) -> Option> { /// Read a Herdr agent identity hint from a process environment. pub fn process_agent_hint(pid: u32) -> Option { + process_agent_hint_with_registry(&crate::agents::registry(), pid) +} + +pub(crate) fn process_agent_hint_with_registry( + registry: &crate::agents::AgentRegistry, + pid: u32, +) -> Option { if pid == 0 { return None; } let buf = kern_procargs2(pid)?; - super::parse_agent_env_hint(procargs2_env(&buf)?) + super::parse_agent_env_hint_with_registry(registry, procargs2_env(&buf)?) } fn procargs2_argv_start(rest: &[u8]) -> Option { @@ -828,7 +835,7 @@ fn procargs2_argv(buf: &[u8]) -> Option> { } let argc = i32::from_ne_bytes([buf[0], buf[1], buf[2], buf[3]]); - if argc < 1 { + if argc < 1 || argc as usize > buf.len() { return None; } @@ -843,12 +850,8 @@ fn procargs2_argv(buf: &[u8]) -> Option> { let end = rest[current..] .iter() .position(|&b| b == 0) - .map(|offset| current + offset) - .unwrap_or(rest.len()); - if end == current { - return None; - } - argv.push(String::from_utf8_lossy(&rest[current..end]).into_owned()); + .map(|offset| current + offset)?; + argv.push(std::str::from_utf8(&rest[current..end]).ok()?.to_owned()); current = end + 1; } @@ -983,6 +986,33 @@ pub fn signal_processes(pids: &[u32], signal: Signal) { } } +/// Query only on bound-process acquisition/revalidation, not in candidate +/// recognition loops. BSD start seconds/microseconds form an allocation-free token. +pub(crate) fn process_identity(pid: u32) -> Option { + if pid == 0 || pid > i32::MAX as u32 { + return None; + } + process_identity_from_bsdinfo(pid, &process_bsdinfo(pid)?) +} + +fn process_identity_from_bsdinfo( + pid: u32, + info: &libc::proc_bsdinfo, +) -> Option { + if pid == 0 + || info.pbi_pid != pid + || info.pbi_status == libc::SZOMB + || info.pbi_start_tvusec >= 1_000_000 + { + return None; + } + let birth_token = info + .pbi_start_tvsec + .checked_mul(1_000_000)? + .checked_add(info.pbi_start_tvusec)?; + Some(super::ProcessIdentity { pid, birth_token }) +} + pub fn process_exists(pid: u32) -> bool { if pid == 0 { return false; @@ -997,6 +1027,35 @@ pub fn process_exists(pid: u32) -> bool { #[cfg(test)] mod tests { + #[test] + fn process_identity_bsd_start_time_ignores_name_and_rejects_reused_pid() { + let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() }; + info.pbi_pid = 123; + info.pbi_start_tvsec = 100; + info.pbi_start_tvusec = 456; + let first = super::process_identity_from_bsdinfo(123, &info).unwrap(); + assert_eq!(first.birth_token, 100_000_456); + info.pbi_comm[0] = b'x' as libc::c_char; + assert_eq!( + super::process_identity_from_bsdinfo(123, &info), + Some(first) + ); + info.pbi_start_tvusec += 1; + assert_ne!( + super::process_identity_from_bsdinfo(123, &info), + Some(first) + ); + assert_eq!(super::process_identity_from_bsdinfo(124, &info), None); + info.pbi_status = libc::SZOMB; + assert_eq!(super::process_identity_from_bsdinfo(123, &info), None); + info.pbi_status = 0; + info.pbi_start_tvusec = 1_000_000; + assert_eq!(super::process_identity_from_bsdinfo(123, &info), None); + info.pbi_start_tvusec = 0; + info.pbi_start_tvsec = u64::MAX; + assert_eq!(super::process_identity_from_bsdinfo(123, &info), None); + } + use super::*; #[test] @@ -1054,6 +1113,25 @@ mod tests { assert!(!argv.join(" ").contains("codex.system")); } + #[test] + fn procargs2_argv_preserves_exact_arguments_and_rejects_lossy_or_truncated_input() { + let args = ["agent", "--model", "model name", "", ""]; + let buf = build_procargs2("/bin/agent", &args, &[]); + assert_eq!(procargs2_argv(&buf), Some(args.map(str::to_owned).to_vec())); + let mut invalid = build_procargs2("/bin/agent", &["agent", "value"], &[]); + let value = invalid + .windows(5) + .position(|bytes| bytes == b"value") + .unwrap(); + invalid[value] = 0xff; + assert!(procargs2_argv(&invalid).is_none()); + let mut truncated = build_procargs2("/bin/agent", &["agent", "value"], &[]); + while truncated.last() == Some(&0) { + truncated.pop(); + } + assert!(procargs2_argv(&truncated).is_none()); + } + #[test] fn procargs2_env_reads_agent_hint_after_argv() { let buf = build_procargs2( diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 67e474ff2e..98e1513ce4 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -3,6 +3,16 @@ //! Centralizes OS-dependent behavior behind a clean boundary so core //! modules don't scatter `#[cfg]` branches through product logic. +/// A process lifetime, independent of mutable names, argv and terminal titles. +/// The birth token is opaque and host/boot-local, not a persisted session ID. +/// Compare the complete value: equal PIDs with different tokens are different +/// processes. Failure to read an identity is not evidence of a retained binding. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ProcessIdentity { + pub(crate) pid: u32, + pub(crate) birth_token: u64, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ForegroundProcess { pub pid: u32, @@ -265,12 +275,19 @@ pub(crate) struct RemoteSshConfigPaths { mod unix_common; #[cfg(unix)] pub(crate) use unix_common::{ - begin_cli_output, end_cli_output, forward_remote_bridge_stdio, RemoteBridgeWake, + begin_cli_output, end_cli_output, forward_remote_bridge_stdio, sync_directory_after_replace, + RemoteBridgeWake, }; mod client_state; pub(crate) use client_state::{create_private_state_file, replace_file, sync_parent_directory}; +/// Preserve the existing non-Unix post-replacement durability policy. +#[cfg(not(unix))] +pub(crate) fn sync_directory_after_replace(_path: &std::path::Path) -> std::io::Result<()> { + Ok(()) +} + #[cfg(not(unix))] pub(crate) fn begin_cli_output() {} @@ -416,17 +433,71 @@ pub fn process_agent_hint(_pid: u32) -> Option { None } -#[cfg(any(target_os = "linux", target_os = "macos"))] +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +pub(crate) fn process_agent_hint_with_registry( + _registry: &crate::agents::AgentRegistry, + _pid: u32, +) -> Option { + None +} + +#[cfg(all(test, any(target_os = "linux", target_os = "macos")))] pub(crate) fn parse_agent_env_hint(environ: &[u8]) -> Option { + parse_agent_env_hint_with_registry(&crate::agents::registry(), environ) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) fn parse_agent_env_hint_with_registry( + registry: &crate::agents::AgentRegistry, + environ: &[u8], +) -> Option { for record in environ.split(|&byte| byte == 0) { let Some(value) = record.strip_prefix(b"HERDR_AGENT=") else { continue; }; - return crate::detect::parse_agent_label(std::str::from_utf8(value).ok()?); + // Preserve the label parser's alias, executable suffix, and basename + // compatibility without reacquiring the registry for each job member. + let mut label = std::str::from_utf8(value).ok()?.trim().to_lowercase(); + for suffix in [".exe", ".cmd", ".bat", ".ps1", ".js"] { + if label.ends_with(suffix) { + label.truncate(label.len() - suffix.len()); + break; + } + } + let name = label + .rsplit(['/', '\\']) + .find(|component| !component.is_empty()) + .unwrap_or(&label); + return registry + .profile_by_normalized_alias(name) + .or_else(|| registry.profile_by_versioned_process_name(name)) + .map(|profile| profile.legacy_agent()); } None } +/// Unix foreground observation is registry-independent; Windows must select +/// candidates with the same snapshot as its caller's detection cycle. A bound +/// process is only a PID locator; callers must validate its saved birth token +/// before retaining identity from the returned observation. +#[cfg(not(windows))] +pub(crate) fn foreground_job_with_registry( + _registry: &crate::agents::RegistrySnapshot, + pid: u32, + _bound: Option<(&ForegroundProcess, &crate::agents::AgentRegistry)>, +) -> Option { + foreground_job(pid) +} + +#[cfg(not(windows))] +pub(crate) fn foreground_process_group_id_with_registry( + _registry: &crate::agents::RegistrySnapshot, + pid: u32, + _bound: Option<(&ForegroundProcess, &crate::agents::AgentRegistry)>, +) -> Option { + foreground_process_group_id(pid) +} + #[cfg(not(any(target_os = "macos", target_os = "windows")))] #[derive(Debug)] pub(crate) struct InputSourceRestore; @@ -565,9 +636,43 @@ mod tests { assert_eq!(parse_agent_env_hint(b"HERDR_AGENT=not-an-agent\0"), None); } + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn parse_agent_env_hint_uses_the_pinned_registry_and_preserves_label_compatibility() { + let registry = crate::agents::store::snapshot_for_test(vec![( + "agents/pane-env-agent/agent.toml".into(), + "schema = 1\nid = 'pane-env-agent'\nname = 'Pane env'\naliases = ['pane-env-alias']\nstartable = true\n[launch]\nunix = 'pane-env-agent'\nwindows = 'pane-env-agent'\n".into(), + )], 99).unwrap(); + let agent = crate::detect::Agent::parse("pane-env-agent").unwrap(); + for label in [ + "pane-env-agent", + " PANE-ENV-ALIAS ", + r"C:\bin\PANE-ENV-ALIAS.EXE", + "/opt/bin/pane-env-alias.js", + ] { + let environ = format!("PATH=/bin\0HERDR_AGENT={label}\0"); + assert_eq!( + parse_agent_env_hint_with_registry(®istry, environ.as_bytes()), + Some(agent) + ); + } + assert_eq!( + parse_agent_env_hint_with_registry( + &crate::agents::AgentRegistry::default(), + b"HERDR_AGENT=pane-env-agent\0" + ), + None + ); + assert_eq!( + parse_agent_env_hint_with_registry(®istry, b"HERDR_AGENT=\xff\0"), + None + ); + } + #[cfg(any(target_os = "linux", target_os = "macos"))] #[test] fn interactive_shell_command_quotes_for_posix_and_powershell() { + assert_eq!(interactive_shell_command(&[], "bash"), None); let argv = vec![ "pi".into(), String::new(), @@ -650,3 +755,81 @@ mod tests { ); } } + +#[cfg(all(test, any(target_os = "linux", target_os = "macos", windows)))] +mod process_identity_tests { + use super::process_identity; + use std::process::{Child, Command, Stdio}; + + struct HarmlessChild(Child); + + impl HarmlessChild { + fn spawn() -> Self { + #[cfg(unix)] + let mut command = { + let mut command = Command::new("/bin/sh"); + command.args(["-c", "read -r line"]); + command + }; + #[cfg(windows)] + let mut command = { + let mut command = Command::new("cmd.exe"); + command.args(["/D", "/Q", "/C", "set /p line="]); + command + }; + Self( + command + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn harmless stdin-waiting child"), + ) + } + } + + impl Drop for HarmlessChild { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + + #[test] + fn process_identity_is_stable_for_the_current_native_process() { + let pid = std::process::id(); + let identity = process_identity(pid).expect("current process birth token"); + assert_eq!(identity.pid, pid); + assert_eq!(process_identity(pid), Some(identity)); + assert_eq!(process_identity(0), None); + assert_eq!(process_identity(u32::MAX), None); + } + + #[test] + fn process_identity_does_not_retain_a_reaped_native_child() { + let mut child = HarmlessChild::spawn(); + let pid = child.0.id(); + let identity = process_identity(pid).expect("live child birth token"); + assert_eq!(process_identity(pid), Some(identity)); + child.0.kill().expect("terminate harmless child"); + child.0.wait().expect("reap harmless child"); + assert_ne!(process_identity(pid), Some(identity)); + } + + #[cfg(windows)] + #[test] + fn process_identity_rejects_a_child_that_exited_with_still_active_code() { + let mut child = Command::new("cmd.exe") + .args(["/D", "/Q", "/C", "exit 259"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn harmless exiting child"); + let pid = child.id(); + assert_eq!(child.wait().expect("reap child").code(), Some(259)); + // Child still owns its handle here, keeping the exited process object + // queryable. The exit timestamp must reject it despite numeric code 259. + assert_eq!(process_identity(pid), None); + } +} diff --git a/src/platform/unix_common.rs b/src/platform/unix_common.rs index 756abc174a..d6f691cc38 100644 --- a/src/platform/unix_common.rs +++ b/src/platform/unix_common.rs @@ -183,7 +183,13 @@ pub(crate) fn create_remote_ssh_config_dir(control_socket_name: &str) -> std::io )) } -pub(crate) fn create_remote_ssh_config_file(path: &Path) -> std::io::Result { +/// Sync directory entries after a durable file has been atomically replaced. +/// Failure occurs after the caller's commit point and must not imply rollback. +pub(crate) fn sync_directory_after_replace(path: &Path) -> std::io::Result<()> { + std::fs::File::open(path)?.sync_all() +} + +pub(crate) fn create_private_file(path: &Path) -> std::io::Result { use std::os::unix::fs::OpenOptionsExt; std::fs::OpenOptions::new() diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 8344be2dc2..9beee286fa 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -328,9 +328,7 @@ pub(crate) fn create_remote_ssh_config_dir(_control_socket_name: &str) -> std::i )) } -pub(crate) fn create_remote_ssh_config_file( - path: &std::path::Path, -) -> std::io::Result { +pub(crate) fn create_private_file(path: &std::path::Path) -> std::io::Result { std::fs::OpenOptions::new() .write(true) .create_new(true) @@ -531,6 +529,7 @@ struct CachedForegroundSelection { #[derive(Debug, Default)] struct ForegroundSelectionCache { + registry_generation: Option, entries: HashMap, } @@ -622,7 +621,7 @@ struct ProcessSnapshot { entries: Vec, entry_by_pid: HashMap, children_by_parent: HashMap>, - agent_indices: OnceLock>, + agent_indices: Mutex>)>>, } impl ProcessSnapshot { @@ -640,7 +639,7 @@ impl ProcessSnapshot { entries, entry_by_pid, children_by_parent, - agent_indices: OnceLock::new(), + agent_indices: Mutex::new(None), } } @@ -650,6 +649,7 @@ impl ProcessSnapshot { .map(|&index| &self.entries[index]) } + #[cfg(test)] fn descendant_signatures(&self, root_pid: u32) -> Vec { let mut signatures = descendant_entries(root_pid, self) .into_iter() @@ -659,14 +659,35 @@ impl ProcessSnapshot { signatures } - fn agent_indices(&self) -> &[usize] { - self.agent_indices.get_or_init(|| { + fn agent_indices(&self, registry: &crate::agents::RegistrySnapshot) -> Arc> { + { + let cached = self + .agent_indices + .lock() + .unwrap_or_else(|err| err.into_inner()); + if let Some((generation, indices)) = &*cached { + if *generation == registry.generation { + return indices.clone(); + } + } + } + // Compile both positive and negative selections against one pinned + // registry; never hold a cache/registry lock over the candidate loop. + let indices = Arc::new( self.entries .iter() .enumerate() - .filter_map(|(index, entry)| process_entry_identifies_agent(entry).then_some(index)) - .collect() - }) + .filter_map(|(index, entry)| { + process_entry_identifies_agent(registry, entry).then_some(index) + }) + .collect(), + ); + *self + .agent_indices + .lock() + .unwrap_or_else(|err| err.into_inner()) = + Some((registry.generation, Arc::clone(&indices))); + indices } } @@ -1141,7 +1162,15 @@ pub fn current_process_is_detached_server_daemon() -> bool { } pub fn foreground_job(child_pid: u32) -> Option { - select_pane_foreground_job_cached(child_pid) + foreground_job_with_registry(&crate::agents::registry(), child_pid, None) +} + +pub(crate) fn foreground_job_with_registry( + registry: &crate::agents::RegistrySnapshot, + child_pid: u32, + bound: Option<(&super::ForegroundProcess, &crate::agents::AgentRegistry)>, +) -> Option { + select_pane_foreground_job_cached(registry, child_pid, bound) } pub(crate) fn available_pane_shell(child_pid: u32) -> Option { @@ -1164,6 +1193,17 @@ fn available_pane_shell_from_snapshot( pub fn foreground_group_leader_job(process_group_id: u32) -> Option { let snapshot = cached_foreground_processes(); + if let Some(job) = FOREGROUND_SELECTION_CACHE + .lock() + .unwrap_or_else(|err| err.into_inner()) + .job_for_selected( + crate::agents::store::generation(), + process_group_id, + &snapshot, + ) + { + return Some(job); + } let entry = snapshot.entry(process_group_id)?; Some(ForegroundJob { process_group_id, @@ -1171,8 +1211,12 @@ pub fn foreground_group_leader_job(process_group_id: u32) -> Option Option { - select_pane_foreground_job_cached(child_pid).map(|job| job.process_group_id) +pub(crate) fn foreground_process_group_id_with_registry( + registry: &crate::agents::RegistrySnapshot, + child_pid: u32, + bound: Option<(&super::ForegroundProcess, &crate::agents::AgentRegistry)>, +) -> Option { + select_pane_foreground_job_cached(registry, child_pid, bound).map(|job| job.process_group_id) } pub fn process_cwd(pid: u32) -> Option { @@ -1183,45 +1227,113 @@ pub fn process_cwd(pid: u32) -> Option { .filter(|path| path.is_absolute()) } -fn select_pane_foreground_job_cached(shell_pid: u32) -> Option { +fn select_pane_foreground_job_cached( + registry: &crate::agents::RegistrySnapshot, + shell_pid: u32, + bound: Option<(&super::ForegroundProcess, &crate::agents::AgentRegistry)>, +) -> Option { let snapshot = cached_foreground_processes(); + if let Some((bound, retained_registry)) = bound { + if let Some(job) = retained_foreground_job( + retained_registry, + shell_pid, + &snapshot, + bound, + |shell| process_is_git_bash(shell.pid), + |entry| process_runtime_marker(entry.pid), + |entry| { + process_identity(entry.pid).is_some_and(|identity| { + Some(identity.birth_token) == entry.command().creation_time + }) + }, + ) { + return Some(job); + } + } let (job, retry_with_fresh_snapshot) = - select_pane_foreground_job_from_snapshot(shell_pid, &snapshot)?; + select_pane_foreground_job_from_snapshot(registry, shell_pid, &snapshot)?; if !retry_with_fresh_snapshot { return Some(job); } let snapshot = fresh_foreground_processes(); - select_pane_foreground_job_from_snapshot(shell_pid, &snapshot).map(|(job, _)| job) + select_pane_foreground_job_from_snapshot(registry, shell_pid, &snapshot).map(|(job, _)| job) +} + +/// Registry reloads change acquisition policy, not ownership of an already +/// observed live process. Locate the bound PID and revalidate pane ownership +/// independently of matchers, then leave all new acquisitions to the active +/// registry. The caller must compare its saved process_identity birth token +/// before retaining an agent: this observation is not itself identity evidence. +/// Names/argv may change during one lifetime. Unix has a kernel foreground group +/// and does not need this fallback. +fn retained_foreground_job( + registry: &crate::agents::AgentRegistry, + shell_pid: u32, + snapshot: &ProcessSnapshot, + bound: &super::ForegroundProcess, + shell_is_git_bash: impl FnOnce(&WindowsProcessEntry) -> bool, + mut runtime_marker: impl FnMut(&WindowsProcessEntry) -> Option, + is_live: impl FnOnce(&WindowsProcessEntry) -> bool, +) -> Option { + let entry = snapshot.entry(bound.pid)?; + if !is_live(entry) { + return None; + } + if entry.pid == shell_pid || process_is_ancestor(shell_pid, entry.pid, snapshot) { + return Some(retained_job_with_descendants(registry, entry, snapshot)); + } + let shell = snapshot.entry(shell_pid)?; + if !shell_is_git_bash(shell) { + return None; + } + let marker = runtime_marker(shell).filter(|marker| !marker.is_empty())?; + (runtime_marker(entry).as_deref() == Some(marker.as_str())) + .then(|| retained_job_with_descendants(registry, entry, snapshot)) +} + +fn retained_job_with_descendants( + registry: &crate::agents::AgentRegistry, + entry: &WindowsProcessEntry, + snapshot: &ProcessSnapshot, +) -> ForegroundJob { + let candidates: Vec<_> = descendant_entries(entry.pid, snapshot) + .into_iter() + .filter(|candidate| process_entry_identifies_agent(registry, candidate)) + .collect(); + foreground_job_from_selection(entry, &candidates, snapshot) } fn select_pane_foreground_job_from_snapshot( + registry: &crate::agents::RegistrySnapshot, shell_pid: u32, snapshot: &ProcessSnapshot, ) -> Option<(ForegroundJob, bool)> { if let Some(job) = FOREGROUND_SELECTION_CACHE .lock() .unwrap_or_else(|err| err.into_inner()) - .get(shell_pid, snapshot) + .get_with_generation(registry.generation, shell_pid, snapshot) { return Some((job, false)); } - let job = select_pane_foreground_job_from_snapshot_uncached(shell_pid, snapshot)?; + let job = select_pane_foreground_job_from_snapshot_uncached(registry, shell_pid, snapshot)?; let cached = prepare_cached_foreground_selection(shell_pid, snapshot, &job); let retry_with_fresh_snapshot = job.process_group_id != shell_pid && cached.is_none(); FOREGROUND_SELECTION_CACHE .lock() .unwrap_or_else(|err| err.into_inner()) - .remember(shell_pid, cached); + .remember_with_generation(registry.generation, shell_pid, cached); Some((job, retry_with_fresh_snapshot)) } fn select_pane_foreground_job_from_snapshot_uncached( + registry: &crate::agents::RegistrySnapshot, shell_pid: u32, snapshot: &ProcessSnapshot, ) -> Option { - select_pane_foreground_job_from_snapshot_with_runtime_inspection( + select_pane_foreground_job_from_snapshot_with_registry( + registry, shell_pid, snapshot, |shell| process_is_git_bash(shell.pid), @@ -1229,7 +1341,8 @@ fn select_pane_foreground_job_from_snapshot_uncached( ) } -fn select_pane_foreground_job_from_snapshot_with_runtime_inspection( +fn select_pane_foreground_job_from_snapshot_with_registry( + registry: &crate::agents::RegistrySnapshot, shell_pid: u32, snapshot: &ProcessSnapshot, shell_is_git_bash: impl FnOnce(&WindowsProcessEntry) -> bool, @@ -1240,19 +1353,23 @@ fn select_pane_foreground_job_from_snapshot_with_runtime_inspection( let descendants = descendant_entries(shell_pid, snapshot); let mut candidates = Vec::new(); for entry in std::iter::once(shell).chain(descendants) { - if process_entry_identifies_agent(entry) { + if process_entry_identifies_agent(registry, entry) { candidates.push(entry); } } if let Some(selected) = select_topmost_agent_chain_candidate(&candidates, snapshot) { - return Some(foreground_job_from_entry(selected)); + return Some(foreground_job_from_selection( + selected, + &candidates, + snapshot, + )); } if !candidates.is_empty() || !shell_is_git_bash(shell) { return Some(foreground_job_from_entry(shell)); } - let escaped_agent_indices = snapshot.agent_indices(); + let escaped_agent_indices = snapshot.agent_indices(registry); if escaped_agent_indices.is_empty() { return Some(foreground_job_from_entry(shell)); } @@ -1268,7 +1385,11 @@ fn select_pane_foreground_job_from_snapshot_with_runtime_inspection( .collect(); let selected = select_topmost_agent_chain_candidate(&matching_candidates, snapshot).unwrap_or(shell); - Some(foreground_job_from_entry(selected)) + Some(foreground_job_from_selection( + selected, + &matching_candidates, + snapshot, + )) } #[cfg(test)] @@ -1277,14 +1398,55 @@ fn select_pane_foreground_job( entries: &[WindowsProcessEntry], ) -> Option { select_pane_foreground_job_from_snapshot_uncached( + &crate::agents::registry(), shell_pid, &ProcessSnapshot::new(entries.to_vec()), ) } -fn process_entry_identifies_agent(entry: &WindowsProcessEntry) -> bool { - crate::detect::identify_agent(&entry.name).is_some() - || crate::detect::identify_agent_in_job(&foreground_job_from_entry(entry)).is_some() +fn process_entry_identifies_agent( + registry: &crate::agents::AgentRegistry, + entry: &WindowsProcessEntry, +) -> bool { + crate::detect::identify_agent_with_registry(registry, &entry.name).is_some() + || crate::detect::identify_agent_in_job_with_registry( + registry, + &foreground_job_from_entry(entry), + ) + .is_some() +} + +#[cfg(test)] +fn select_pane_foreground_job_from_snapshot_with_runtime_inspection( + shell_pid: u32, + snapshot: &ProcessSnapshot, + shell_is_git_bash: impl FnOnce(&WindowsProcessEntry) -> bool, + runtime_marker: impl FnMut(&WindowsProcessEntry) -> Option, +) -> Option { + select_pane_foreground_job_from_snapshot_with_registry( + &crate::agents::registry(), + shell_pid, + snapshot, + shell_is_git_bash, + runtime_marker, + ) +} + +fn foreground_job_from_selection( + selected: &WindowsProcessEntry, + candidates: &[&WindowsProcessEntry], + snapshot: &ProcessSnapshot, +) -> ForegroundJob { + let processes = std::iter::once(selected) + .chain(candidates.iter().copied().filter(|entry| { + entry.pid != selected.pid && process_is_ancestor(selected.pid, entry.pid, snapshot) + })) + .map(foreground_process_from_entry) + .collect(); + ForegroundJob { + process_group_id: selected.pid, + processes, + } } fn foreground_job_from_entry(entry: &WindowsProcessEntry) -> ForegroundJob { @@ -1407,6 +1569,18 @@ fn fresh_foreground_processes() -> Arc { cache.snapshot(Duration::ZERO, snapshot_processes) } +fn foreground_selection_descendants( + shell_pid: u32, + selected_pid: u32, + snapshot: &ProcessSnapshot, +) -> Vec<&WindowsProcessEntry> { + let mut entries = descendant_entries(shell_pid, snapshot); + if selected_pid != shell_pid && !process_is_ancestor(shell_pid, selected_pid, snapshot) { + entries.extend(descendant_entries(selected_pid, snapshot)); + } + entries +} + fn prepare_cached_foreground_selection( shell_pid: u32, snapshot: &ProcessSnapshot, @@ -1417,7 +1591,10 @@ fn prepare_cached_foreground_selection( } let shell_identity = ProcessIdentity::open(shell_pid)?; let selected_identity = ProcessIdentity::open(job.process_group_id)?; - let descendants = snapshot.descendant_signatures(shell_pid); + let descendants = foreground_selection_descendants(shell_pid, job.process_group_id, snapshot) + .into_iter() + .map(ProcessSignature::from_entry) + .collect::>(); let descendant_identities = descendants .iter() .map(|entry| ProcessIdentity::open(entry.pid)) @@ -1488,9 +1665,36 @@ impl CachedForegroundSelection { } impl ForegroundSelectionCache { - fn get(&mut self, shell_pid: u32, snapshot: &ProcessSnapshot) -> Option { + fn job_for_selected( + &mut self, + generation: u64, + selected_pid: u32, + snapshot: &ProcessSnapshot, + ) -> Option { + self.sync_generation(generation); + let shell_pid = self.entries.iter().find_map(|(shell, cached)| { + (cached.job.process_group_id == selected_pid).then_some(*shell) + })?; + self.get_with_generation(generation, shell_pid, snapshot) + } + + fn sync_generation(&mut self, generation: u64) { + if self.registry_generation != Some(generation) { + self.entries.clear(); + self.registry_generation = Some(generation); + } + } + + fn get_with_generation( + &mut self, + generation: u64, + shell_pid: u32, + snapshot: &ProcessSnapshot, + ) -> Option { + self.sync_generation(generation); if let Some(cached) = self.entries.get_mut(&shell_pid) { - let current_descendants = descendant_entries(shell_pid, snapshot); + let current_descendants = + foreground_selection_descendants(shell_pid, cached.job.process_group_id, snapshot); let topology_matches = current_descendants.len() == cached.descendants.len() && cached .descendants @@ -1509,15 +1713,36 @@ impl ForegroundSelectionCache { .matches(snapshot.entry(cached.job.process_group_id)) && topology_matches; if valid { + let processes = cached + .job + .processes + .iter() + .map(|process| { + snapshot + .entry(process.pid) + .map(foreground_process_from_entry) + }) + .collect::>>()?; cached.last_used = Instant::now(); - return Some(cached.job.clone()); + return Some(ForegroundJob { + process_group_id: cached.job.process_group_id, + processes, + }); } } self.entries.remove(&shell_pid); None } - fn remember(&mut self, shell_pid: u32, cached: Option) { + fn remember_with_generation( + &mut self, + generation: u64, + shell_pid: u32, + cached: Option, + ) { + // A computation can finish after another generation has used the cache. + // Tag on insertion as well as lookup; never admit mixed-generation entries. + self.sync_generation(generation); let Some(cached) = cached else { self.entries.remove(&shell_pid); return; @@ -1530,6 +1755,16 @@ impl ForegroundSelectionCache { self.entries.insert(shell_pid, cached); } + #[cfg(test)] + fn get(&mut self, shell_pid: u32, snapshot: &ProcessSnapshot) -> Option { + self.get_with_generation(1, shell_pid, snapshot) + } + + #[cfg(test)] + fn remember(&mut self, shell_pid: u32, cached: Option) { + self.remember_with_generation(1, shell_pid, cached); + } + #[cfg(test)] fn remember_for_test( &mut self, @@ -1537,7 +1772,11 @@ impl ForegroundSelectionCache { snapshot: &ProcessSnapshot, job: &ForegroundJob, ) { - let descendants = snapshot.descendant_signatures(shell_pid); + let descendants = + foreground_selection_descendants(shell_pid, job.process_group_id, snapshot) + .into_iter() + .map(ProcessSignature::from_entry) + .collect::>(); let descendant_identities = descendants .iter() .map(|_| ProcessIdentity::Stub { @@ -1782,6 +2021,10 @@ fn process_runtime_marker(pid: u32) -> Option { } fn process_creation_time(process: HANDLE) -> Option { + process_creation_and_exit_time(process).map(|(created, _)| created) +} + +fn process_creation_and_exit_time(process: HANDLE) -> Option<(u64, u64)> { let mut creation_time = FILETIME::default(); let mut exit_time = FILETIME::default(); let mut kernel_time = FILETIME::default(); @@ -1798,7 +2041,9 @@ fn process_creation_time(process: HANDLE) -> Option { { return None; } - Some((u64::from(creation_time.dwHighDateTime) << 32) | u64::from(creation_time.dwLowDateTime)) + let ticks = + |time: FILETIME| (u64::from(time.dwHighDateTime) << 32) | u64::from(time.dwLowDateTime); + Some((ticks(creation_time), ticks(exit_time))) } fn process_runtime_marker_from_handle(process: HANDLE) -> Option> { @@ -2004,6 +2249,19 @@ pub fn signal_processes(pids: &[u32], signal: Signal) { } } +/// Query only on bound-process acquisition/revalidation, not per process +/// candidate. The FILETIME birth token is a value, not a retained OS handle. +pub(crate) fn process_identity(pid: u32) -> Option { + if pid == 0 { + return None; + } + let process = ProcessHandle::open(pid, PROCESS_QUERY_LIMITED_INFORMATION)?; + let (birth_token, exited_at) = process_creation_and_exit_time(process.0)?; + // Exit code 259 is legal, so GetExitCodeProcess == STILL_ACTIVE alone is + // not proof of liveness. GetProcessTimes reports the actual exit timestamp. + (exited_at == 0).then_some(super::ProcessIdentity { pid, birth_token }) +} + pub fn process_exists(pid: u32) -> bool { let Some(process) = ProcessHandle::open(pid, PROCESS_QUERY_LIMITED_INFORMATION) else { return false; @@ -3391,7 +3649,7 @@ mod tests { test_entry(50, 98, "claude.exe", &["claude.exe"]), ]); let mut inspected = Vec::new(); - assert!(snapshot.agent_indices.get().is_none()); + assert!(snapshot.agent_indices.lock().unwrap().is_none()); let marker = |entry: &super::WindowsProcessEntry| match entry.pid { 12 | 50 => Some("pane-b".to_string()), _ => Some("pane-a".to_string()), @@ -3407,7 +3665,14 @@ mod tests { }, ) .unwrap(); - let indices = snapshot.agent_indices.get().unwrap(); + let indices = snapshot + .agent_indices + .lock() + .unwrap() + .as_ref() + .unwrap() + .1 + .clone(); let second = super::select_pane_foreground_job_from_snapshot_with_runtime_inspection( 12, &snapshot, @@ -3419,8 +3684,11 @@ mod tests { assert_eq!(first.process_group_id, 20); assert_eq!(first.processes[0].name, "sh.exe"); assert_eq!(second.process_group_id, 50); - assert_eq!(indices, &[3, 4, 5, 6]); - assert!(std::ptr::eq(indices, snapshot.agent_indices.get().unwrap())); + assert_eq!(indices.as_slice(), &[3, 4, 5, 6]); + assert!(Arc::ptr_eq( + &indices, + &snapshot.agent_indices.lock().unwrap().as_ref().unwrap().1 + )); assert_eq!(inspected, vec![10, 20, 30, 40, 50]); } @@ -3558,6 +3826,129 @@ mod tests { assert_eq!(refreshed.entries[0].pid, 20); } + fn selection_registry_fixture( + generation: u64, + name: &str, + ) -> Arc { + crate::agents::store::snapshot_for_test(vec![ + ("agents/windows-selection-agent/agent.toml".into(), "schema = 1\nid = 'windows-selection-agent'\nname = 'Selection'\naliases = []\nstartable = true\n[launch]\nunix = 'windows-selection-agent'\nwindows = 'windows-selection-agent'\n".into()), + ("agents/windows-selection-agent/process.toml".into(), format!("names = ['{name}']\n")), + ], generation).unwrap() + } + + #[test] + fn windows_agent_indices_invalidate_positive_and_negative_generation_caches() { + let old = selection_registry_fixture(1, "other-worker"); + let new = selection_registry_fixture(2, "new-worker"); + let snapshot = super::ProcessSnapshot::new(vec![ + test_entry(10, 1, "bash.exe", &["bash.exe"]), + test_entry(20, 99, "new-worker.exe", &["new-worker.exe"]), + ]); + let negative = snapshot.agent_indices(&old); + assert!(negative.is_empty()); + assert!(Arc::ptr_eq(&negative, &snapshot.agent_indices(&old))); + let select = |registry: &crate::agents::RegistrySnapshot| { + super::select_pane_foreground_job_from_snapshot_with_registry( + registry, + 10, + &snapshot, + |_| true, + |_| Some("same-pane".into()), + ) + .unwrap() + .process_group_id + }; + assert_eq!(select(&old), 10); + // No new OS snapshot or terminal output is required for acquisition. + assert_eq!(select(&new), 20); + assert_eq!(snapshot.agent_indices(&new).as_slice(), &[1]); + // A now-invalid positive result is discarded on the same OS snapshot. + assert_eq!(select(&old), 10); + assert!(snapshot.agent_indices(&old).is_empty()); + } + + #[test] + fn windows_foreground_selection_cache_generation_is_checked_on_lookup_and_insertion() { + let snapshot = super::ProcessSnapshot::new(vec![ + test_entry(10, 1, "powershell.exe", &["powershell.exe"]), + test_entry(20, 10, "codex.exe", &["codex.exe"]), + ]); + let job = super::foreground_job_from_entry(snapshot.entry(20).unwrap()); + let mut cache = super::ForegroundSelectionCache::default(); + cache.remember_for_test(10, &snapshot, &job); + assert_eq!( + cache.get_with_generation(1, 10, &snapshot), + Some(job.clone()) + ); + assert_eq!(cache.get_with_generation(2, 10, &snapshot), None); + assert!(cache.entries.is_empty()); + // A late old-generation result may not pollute a newer lookup. + cache.remember_for_test(10, &snapshot, &job); + assert_eq!(cache.get_with_generation(2, 10, &snapshot), None); + } + + #[test] + fn windows_removed_bound_process_is_retained_only_while_live_and_owned() { + let snapshot = super::ProcessSnapshot::new(vec![ + test_entry(10, 1, "bash.exe", &["bash.exe"]), + test_entry(20, 10, "removed-worker.exe", &["removed-worker.exe"]), + ]); + let bound = super::foreground_process_from_entry(snapshot.entry(20).unwrap()); + let probe = |bound: &super::super::ForegroundProcess, live| { + super::retained_foreground_job( + &crate::agents::registry(), + 10, + &snapshot, + bound, + |_| false, + |_| None, + |_| live, + ) + }; + assert_eq!(probe(&bound, true).unwrap().process_group_id, 20); + assert_eq!(probe(&bound, false), None); + let mut replacement = bound.clone(); + replacement.pid = 21; + assert_eq!(probe(&replacement, true), None); + replacement = bound.clone(); + replacement.name = "renamed-worker.exe".into(); + replacement.argv = Some(vec!["updated process title".into()]); + // The snapshot is an observation, not a claim that this PID has the old + // lifetime. Caller-side birth-token validation decides retention. + assert_eq!(probe(&replacement, true).unwrap().process_group_id, 20); + + let escaped = super::ProcessSnapshot::new(vec![ + test_entry(10, 1, "bash.exe", &["bash.exe"]), + test_entry(20, 99, "removed-worker.exe", &["removed-worker.exe"]), + ]); + assert_eq!( + super::retained_foreground_job( + &crate::agents::registry(), + 10, + &escaped, + &bound, + |_| true, + |_| Some("same-pane".into()), + |_| true + ) + .unwrap() + .process_group_id, + 20 + ); + assert_eq!( + super::retained_foreground_job( + &crate::agents::registry(), + 10, + &escaped, + &bound, + |_| true, + |entry| Some(entry.pid.to_string()), + |_| true + ), + None + ); + } + #[test] fn windows_foreground_selection_cache_reuses_live_agent_and_invalidates_changes() { let snapshot = super::ProcessSnapshot::new(vec![ @@ -3804,6 +4195,96 @@ mod tests { assert_eq!(job.processes[0].name, "cmd.exe"); } + #[test] + fn windows_resume_argv_preserves_quotes_spaces_and_empty_values() { + assert_eq!( + super::command_line_to_argv(r#"agent.exe --model "two words" "" "a'b""#).unwrap(), + ["agent.exe", "--model", "two words", "", "a'b"] + ); + } + + #[test] + fn windows_resume_options_keep_descendants_through_selection_cache_and_retention() { + let snapshot = super::ProcessSnapshot::new(vec![ + test_entry(10, 1, "powershell.exe", &["powershell.exe"]), + test_entry( + 20, + 10, + "cmd.exe", + &["cmd.exe", "/C", "codex --model opaque"], + ), + test_entry( + 30, + 20, + "node.exe", + &["node.exe", "C:\\tools\\codex.js", "--model", "exact value"], + ), + ]); + let registry = crate::agents::registry(); + let job = super::select_pane_foreground_job_from_snapshot_with_registry( + ®istry, + 10, + &snapshot, + |_| false, + |_| None, + ) + .unwrap(); + assert_eq!( + job.processes + .iter() + .map(|process| process.pid) + .collect::>(), + [20, 30] + ); + assert!(crate::detect::structured_resume_args( + ®istry, + &job.processes[0], + crate::detect::Agent::Codex + ) + .is_none()); + assert_eq!( + crate::detect::structured_resume_args( + ®istry, + &job.processes[1], + crate::detect::Agent::Codex + ) + .unwrap(), + ["--model", "exact value"] + ); + let mut cache = super::ForegroundSelectionCache::default(); + cache.remember_for_test(10, &snapshot, &job); + assert_eq!(cache.job_for_selected(1, 20, &snapshot), Some(job.clone())); + let mut unreadable = snapshot.entries.clone(); + unreadable[2].command = super::OnceLock::from(super::WindowsProcessCommand { + creation_time: None, + argv0: None, + argv: None, + cmdline: None, + }); + let unreadable = super::ProcessSnapshot::new(unreadable); + let refreshed = cache.job_for_selected(1, 20, &unreadable).unwrap(); + assert!(refreshed.processes[1].argv.is_none()); + cache.entries.get_mut(&10).unwrap().descendant_identities[1] = + super::ProcessIdentity::Stub { + running: false, + creation_time: None, + }; + assert!(cache.job_for_selected(1, 20, &snapshot).is_none()); + cache.remember_for_test(10, &snapshot, &job); + assert!(cache.job_for_selected(2, 20, &snapshot).is_none()); + let retained = super::retained_foreground_job( + ®istry, + 10, + &snapshot, + &job.processes[0], + |_| false, + |_| None, + |_| true, + ) + .unwrap(); + assert_eq!(retained.processes, job.processes); + } + #[test] fn windows_process_tree_selects_topmost_codex_process_in_single_agent_chain() { let entries = vec![ diff --git a/src/protocol/endpoint.rs b/src/protocol/endpoint.rs index a70ef568b1..3a3c8ca26f 100644 --- a/src/protocol/endpoint.rs +++ b/src/protocol/endpoint.rs @@ -32,6 +32,37 @@ fn default_true() -> bool { true } +pub const ENDPOINT_NOTIFICATION_KIND: &str = "shell.notification.v1"; + +/// Package metadata only: the receiving client still owns all user sound policy. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct NotificationSoundProfile { + pub config_key: String, + pub default_off: bool, +} + +/// Atomic notification and resolved package metadata. `None` is authoritative: +/// this event has no package sound profile, regardless of the client's registry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EndpointNotification { + pub notification: super::SemanticNotification, + #[serde(default)] + pub sound_profile: Option, +} + +pub fn notification_message( + notification: &super::SemanticNotification, + sound_profile: Option, +) -> serde_json::Result { + Ok(ServerMessage::EndpointControl { + kind: ENDPOINT_NOTIFICATION_KIND.into(), + data: serde_json::to_string(&EndpointNotification { + notification: notification.clone(), + sound_profile, + })?, + }) +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct EndpointClientHello { pub generation: u32, @@ -44,6 +75,9 @@ pub struct EndpointClientHello { pub mouse_capture: bool, #[serde(default = "default_true")] pub surface_active: bool, + /// Supports atomic named notifications with resolved package sound metadata. + #[serde(default)] + pub notification_sound_profile: bool, #[serde(default)] pub snapshot_codecs: Vec, #[serde(default)] @@ -152,6 +186,7 @@ mod tests { endpoint_keybindings: false, mouse_capture: true, surface_active: true, + notification_sound_profile: true, snapshot_codecs: vec![SNAPSHOT_CODEC_V1.into()], surface_codecs: vec![SURFACE_CODEC_V1.into()], input_codecs: vec![INPUT_CODEC_V1.into()], @@ -203,6 +238,7 @@ mod tests { ))) .unwrap(); assert!(hello.supports_required_codecs()); + assert!(!hello.notification_sound_profile); let welcome: EndpointServerWelcome = serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), @@ -267,6 +303,73 @@ mod tests { ); } + fn notification() -> super::super::SemanticNotification { + super::super::SemanticNotification { + kind: super::super::SemanticNotificationKind::Custom, + title: "notice".into(), + body: None, + sound: Some(super::super::SemanticNotificationSound::Done), + agent: Some("droid".into()), + workspace_id: None, + tab_id: None, + pane_id: None, + position: None, + } + } + + #[test] + fn legacy_notification_binary_layout_remains_frozen() { + let bytes = + bincode::serde::encode_to_vec(notification(), bincode::config::standard()).unwrap(); + assert_eq!( + bytes, + b"\x03\x06notice\x00\x01\x00\x01\x05droid\x00\x00\x00\x00" + ); + } + + #[test] + fn resolved_notification_uses_atomic_extensible_json_inside_frozen_control() { + let profile = Some(NotificationSoundProfile { + config_key: "remote_key".into(), + default_off: true, + }); + let message = notification_message(¬ification(), profile.clone()).unwrap(); + let mut bytes = Vec::new(); + super::super::write_message(&mut bytes, &message).unwrap(); + let decoded: ServerMessage = + super::super::read_message(&mut bytes.as_slice(), super::super::MAX_FRAME_SIZE) + .unwrap(); + assert_eq!(message, decoded); + let ServerMessage::EndpointControl { kind, data } = decoded else { + panic!("named notification") + }; + assert_eq!(kind, ENDPOINT_NOTIFICATION_KIND); + let mut value: serde_json::Value = serde_json::from_str(&data).unwrap(); + value["future_field"] = serde_json::json!(true); + let envelope: EndpointNotification = serde_json::from_value(value.clone()).unwrap(); + assert_eq!(envelope.notification, notification()); + assert_eq!(envelope.sound_profile, profile); + value.as_object_mut().unwrap().remove("sound_profile"); + assert!(serde_json::from_value::(value) + .unwrap() + .sound_profile + .is_none()); + } + + #[test] + fn notification_capability_is_optional_without_changing_endpoint_core() { + let mut value = serde_json::to_value(hello()).unwrap(); + value + .as_object_mut() + .unwrap() + .remove("notification_sound_profile"); + let legacy: EndpointClientHello = serde_json::from_value(value).unwrap(); + assert!(!legacy.notification_sound_profile); + assert!(legacy.supports_required_codecs()); + assert!(hello().notification_sound_profile); + assert_eq!(ENDPOINT_PROTOCOL_GENERATION, 1); + } + #[test] fn legacy_hello_defaults_to_an_active_surface() { let mut value = serde_json::to_value(hello()).unwrap(); diff --git a/src/remote/attach.rs b/src/remote/attach.rs index 4f8d2b7c74..47070d93f5 100644 --- a/src/remote/attach.rs +++ b/src/remote/attach.rs @@ -2324,7 +2324,7 @@ fn write_managed_ssh_config() -> io::Result { contents.push_str(" ServerAliveCountMax 4\n"); let write_result = (|| { - let mut file = crate::platform::create_remote_ssh_config_file(&path)?; + let mut file = crate::platform::create_private_file(&path)?; file.write_all(contents.as_bytes()) })(); if let Err(err) = write_result { diff --git a/src/server/client_shell.rs b/src/server/client_shell.rs index 3747069388..c6c26b6e9c 100644 --- a/src/server/client_shell.rs +++ b/src/server/client_shell.rs @@ -592,8 +592,8 @@ mod tests { app.state.integration_recommendations = vec![crate::integration::IntegrationRecommendation { target: crate::api::schema::IntegrationTarget::Claude, - label: "claude", - command: "claude", + label: "claude".into(), + command: "claude".into(), available: true, path: std::path::PathBuf::from("claude-hook"), state: crate::integration::IntegrationStatusKind::NotInstalled, diff --git a/src/server/client_transport.rs b/src/server/client_transport.rs index c57d0f8646..28b03dd7df 100644 --- a/src/server/client_transport.rs +++ b/src/server/client_transport.rs @@ -395,6 +395,7 @@ pub(crate) enum ServerEvent { endpoint_keybindings: bool, mouse_capture: bool, surface_active: bool, + notification_sound_profile: bool, writer: ClientWriter, }, /// A client sent an input message. @@ -760,6 +761,7 @@ pub(crate) fn handle_client_handshake( hello.endpoint_keybindings, hello.mouse_capture, hello.surface_active, + hello.notification_sound_profile, )), ) } @@ -854,6 +856,7 @@ pub(crate) fn handle_client_handshake( endpoint_keybindings, mouse_capture, surface_active, + notification_sound_profile, )) = shell_options { ServerEvent::ClientShellConnected { @@ -867,6 +870,7 @@ pub(crate) fn handle_client_handshake( endpoint_keybindings, mouse_capture, surface_active, + notification_sound_profile, writer, } } else { @@ -1417,6 +1421,7 @@ mod tests { endpoint_keybindings: true, mouse_capture: true, surface_active: true, + notification_sound_profile: true, snapshot_codecs: vec![crate::protocol::endpoint::SNAPSHOT_CODEC_V1.into()], surface_codecs: vec![crate::protocol::endpoint::SURFACE_CODEC_V1.into()], input_codecs: vec![crate::protocol::endpoint::INPUT_CODEC_V1.into()], @@ -1829,6 +1834,7 @@ mod tests { endpoint_keybindings, mouse_capture, surface_active, + notification_sound_profile, writer, } => { assert_eq!(client_id, 43); @@ -1839,6 +1845,7 @@ mod tests { assert!(endpoint_keybindings); assert!(mouse_capture); assert!(surface_active); + assert!(notification_sound_profile); drop(writer); } other => panic!("expected ClientShellConnected, got {other:?}"), diff --git a/src/server/clients.rs b/src/server/clients.rs index 972342ac9b..f36889cd86 100644 --- a/src/server/clients.rs +++ b/src/server/clients.rs @@ -189,6 +189,7 @@ pub(crate) struct ClientConnection { pub(crate) shell_deferred_navigation_response: Option>, /// Whether this shell uses the endpoint-owned keymap rather than a client-owned keymap. pub(crate) shell_uses_endpoint_keybindings: bool, + pub(crate) shell_notification_sound_profile: bool, /// Channels for sending framed ServerMessage data to the client writer thread. pub(crate) writer: Option, } @@ -250,6 +251,7 @@ impl ClientConnection { shell_deferred_navigation_request_id: None, shell_deferred_navigation_response: None, shell_uses_endpoint_keybindings: false, + shell_notification_sound_profile: false, writer, } } diff --git a/src/server/headless.rs b/src/server/headless.rs index 2d65237834..ea75cf94a2 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -194,7 +194,6 @@ enum AltScreenReadConflict { /// The headless server — runs the herdr event loop without a real terminal. pub struct HeadlessServer { app: app::App, - #[cfg(unix)] api_tx: Option, // Kept on every platform so dropping HeadlessServer owns API server shutdown. #[cfg_attr(windows, allow(dead_code))] @@ -336,11 +335,8 @@ impl HeadlessServer { let headless_size = app.state.headless_size; let (server_config_diagnostic, server_config_diagnostic_without_keybindings) = server_config_diagnostic_summaries(config_diagnostics); - #[cfg(not(unix))] - let _ = api_tx; Ok(Self { app, - #[cfg(unix)] api_tx, api_server, #[cfg(unix)] @@ -1651,6 +1647,20 @@ impl HeadlessServer { } } + fn resolved_notification_sound_profile( + registry: &crate::agents::RegistrySnapshot, + notification: &protocol::SemanticNotification, + ) -> Option { + let sound = registry + .profile_by_id(notification.agent.as_deref()?)? + .sound()?; + Some(protocol::endpoint::NotificationSoundProfile { + config_key: sound.config_key().to_owned(), + default_off: sound.default_policy() + == crate::agents::presentation::SoundDefaultPolicy::Off, + }) + } + /// Sends an ephemeral semantic event to every connected client-rendered shell. fn send_to_client_shells(&mut self, msg: ServerMessage) -> bool { let serialized = match Self::frame_server_message(&msg) { @@ -1660,6 +1670,24 @@ impl HeadlessServer { return false; } }; + // Capture package metadata once for this event, not per client. Keep the + // generation-1 binary payload exact for clients without the capability. + let resolved = if let ServerMessage::SemanticNotification(notification) = &msg { + let registry = crate::agents::registry(); + let sound_profile = Self::resolved_notification_sound_profile(®istry, notification); + let framed = protocol::endpoint::notification_message(notification, sound_profile) + .map_err(|err| protocol::FramingError::Bincode(err.to_string())) + .and_then(|message| Self::frame_server_message(&message)); + match framed { + Ok(framed) => Some(framed), + Err(err) => { + warn!(%err, "failed to serialize resolved shell notification"); + return false; + } + } + } else { + None + }; let client_ids = self .clients .iter() @@ -1675,7 +1703,12 @@ impl HeadlessServer { let Some(writer) = &client.writer else { continue; }; - if writer.control.send(serialized.clone()).is_ok() { + let payload = if client.shell_notification_sound_profile { + resolved.as_ref().unwrap_or(&serialized) + } else { + &serialized + }; + if writer.control.send(payload.clone()).is_ok() { sent = true; } else { self.remove_client_and_resize_if_needed(client_id); @@ -1946,6 +1979,7 @@ impl HeadlessServer { endpoint_keybindings, mouse_capture, surface_active, + notification_sound_profile, writer, } => { if self.handoff_in_progress { @@ -1991,6 +2025,7 @@ impl HeadlessServer { connection.shell_uses_endpoint_keybindings = endpoint_keybindings; connection.shell_mouse_capture = mouse_capture; connection.shell_surface_active = surface_active; + connection.shell_notification_sound_profile = notification_sound_profile; connection.shell_projection_revision = 1; let config_diagnostic = if endpoint_keybindings { self.server_config_diagnostic.as_deref() @@ -3330,6 +3365,15 @@ impl HeadlessServer { } } + if self + .app + .state + .next_managed_agent_deadline() + .is_some_and(|deadline| now >= deadline) + { + changed |= self.app.reconcile_due_managed_agents(now); + } + if self.has_app_client() { self.app.start_git_status_refresh_if_due(now); } @@ -3344,10 +3388,11 @@ impl HeadlessServer { if self .app - .next_agent_manifest_update_check + .next_agent_registry_update_check .is_some_and(|deadline| now >= deadline) { - self.app.run_agent_manifest_update_check(); + self.app + .run_agent_registry_update_check(now, self.api_tx.as_ref()); } if self diff --git a/src/server/headless/notifications.rs b/src/server/headless/notifications.rs index ddf9a9b392..1d8400824f 100644 --- a/src/server/headless/notifications.rs +++ b/src/server/headless/notifications.rs @@ -109,6 +109,7 @@ impl HeadlessServer { let context = crate::app::actions::notification_context(workspace, &workspace_label, ws_idx, pane_id); let agent = known_agent + .as_ref() .map(crate::detect::agent_label) .map(str::to_owned); self.send_to_client_shells(ServerMessage::SemanticNotification( @@ -320,7 +321,10 @@ impl HeadlessServer { /// in the headless server — use this method instead. /// /// Returns true if the event changed visual state (requiring a re-render). - pub(super) fn handle_internal_event_with_forwarding(&mut self, mut ev: AppEvent) -> bool { + pub(super) fn handle_internal_event_with_forwarding(&mut self, ev: AppEvent) -> bool { + let Some(mut ev) = ev.into_current_detection(crate::agents::store::generation()) else { + return false; + }; let mut focused_worktree_response = if let AppEvent::WorktreeAddFinished(result) = &mut ev { result .api_request diff --git a/src/server/headless/tests/mod.rs b/src/server/headless/tests/mod.rs index d666ae2248..18bb84367f 100644 --- a/src/server/headless/tests/mod.rs +++ b/src/server/headless/tests/mod.rs @@ -56,7 +56,6 @@ fn test_headless_server_with_event_hub(event_hub: api::EventHub) -> HeadlessServ HeadlessServer { app, - #[cfg(unix)] api_tx: None, api_server: None, #[cfg(unix)] @@ -601,6 +600,7 @@ async fn client_shell_attach_seeds_workspace() { assert!( server.handle_server_event(ServerEvent::ClientShellConnected { client_id: 6, + notification_sound_profile: false, surface_cols: 80, surface_rows: 23, cell_width_px: 0, @@ -631,6 +631,7 @@ async fn client_shell_endpoint_request_uses_the_selected_connection() { assert!( server.handle_server_event(ServerEvent::ClientShellConnected { client_id, + notification_sound_profile: false, surface_cols: 80, surface_rows: 23, cell_width_px: 0, @@ -767,6 +768,7 @@ async fn client_shell_receives_metadata_then_shell_free_pane_surface() { assert!( server.handle_server_event(ServerEvent::ClientShellConnected { client_id: 7, + notification_sound_profile: false, surface_cols: 80, surface_rows: 23, cell_width_px: 10, @@ -934,6 +936,7 @@ fn connect_test_shell( assert!( server.handle_server_event(ServerEvent::ClientShellConnected { client_id, + notification_sound_profile: false, surface_cols, surface_rows, cell_width_px: 0, @@ -1367,6 +1370,7 @@ async fn client_shell_config_diagnostics_follow_keybinding_ownership() { assert!( server.handle_server_event(ServerEvent::ClientShellConnected { client_id: 13, + notification_sound_profile: false, surface_cols: 80, surface_rows: 23, cell_width_px: 0, @@ -1391,6 +1395,7 @@ async fn client_shell_config_diagnostics_follow_keybinding_ownership() { assert!( server.handle_server_event(ServerEvent::ClientShellConnected { client_id: 14, + notification_sound_profile: false, surface_cols: 80, surface_rows: 23, cell_width_px: 0, @@ -2293,6 +2298,7 @@ async fn public_api_focus_replaces_every_client_shell_projection() { assert!( server.handle_server_event(ServerEvent::ClientShellConnected { client_id: 9, + notification_sound_profile: false, surface_cols: 80, surface_rows: 23, cell_width_px: 0, @@ -2543,6 +2549,7 @@ async fn client_shell_streams_and_targets_popup_terminal_content() { assert!( server.handle_server_event(ServerEvent::ClientShellConnected { client_id: 12, + notification_sound_profile: false, surface_cols: 80, surface_rows: 23, cell_width_px: 10, @@ -4939,13 +4946,13 @@ fn headless_scheduled_tasks_expire_agent_metadata() { } #[test] -fn headless_scheduled_tasks_clears_disabled_agent_manifest_update_deadline() { +fn headless_scheduled_tasks_do_not_activate_registry_updates() { let mut server = test_headless_server(); - let now = Instant::now(); - server.app.next_agent_manifest_update_check = Some(now - Duration::from_millis(1)); + let before = crate::agents::registry(); - assert!(!server.handle_scheduled_tasks_headless(now, false)); - assert_eq!(server.app.next_agent_manifest_update_check, None); + assert!(!server.handle_scheduled_tasks_headless(Instant::now(), false)); + assert!(std::sync::Arc::ptr_eq(&before, &crate::agents::registry())); + assert_eq!(before.generation, crate::agents::registry().generation); } #[cfg(unix)] @@ -4967,7 +4974,9 @@ async fn headless_scheduled_tasks_start_pending_agent_resume_without_foreground_ .pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { agent: "codex".into(), argv: vec!["/bin/sh".into(), "-c".into(), "sleep 5".into()], + resume_options: Vec::new(), dedupe_key: "herdr:codex\0codex\0Id\0codex-session".into(), + strict_input_readiness: false, }); server.render_and_stream(); @@ -5890,6 +5899,100 @@ fn semantic_notifications_broadcast_only_to_client_shells() { .is_err()); } +#[test] +fn semantic_notifications_negotiate_resolved_sound_without_changing_legacy_bytes() { + let mut server = test_headless_server(); + server.app.state.sound.enabled = false; + let mut receivers = Vec::new(); + for client_id in 1..=3 { + let (writer, control, _frames) = test_client_writer(); + let mut connection = ClientConnection::new_with_mode( + ClientConnectionMode::ClientShell, + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + client_id, + RenderEncoding::SemanticFrame, + Some(writer), + ); + connection.shell_notification_sound_profile = client_id != 1; + server.clients.insert(client_id, connection); + receivers.push(control); + } + let event = protocol::SemanticNotification { + kind: protocol::SemanticNotificationKind::NeedsAttention, + title: "droid needs attention".into(), + body: None, + sound: Some(protocol::SemanticNotificationSound::Request), + agent: Some("droid".into()), + workspace_id: None, + tab_id: None, + pane_id: None, + position: None, + }; + let legacy = + HeadlessServer::frame_server_message(&ServerMessage::SemanticNotification(event.clone())) + .unwrap(); + assert!(server.send_to_client_shells(ServerMessage::SemanticNotification(event.clone()))); + let received = receivers + .iter() + .map(|receiver| receiver.recv_timeout(Duration::from_millis(100)).unwrap()) + .collect::>(); + assert_eq!(received[0], legacy); + assert_eq!(received[1], received[2]); + let ServerMessage::EndpointControl { kind, data } = read_server_message(received[1].clone()) + else { + panic!("capable shells receive a named notification"); + }; + assert_eq!(kind, protocol::endpoint::ENDPOINT_NOTIFICATION_KIND); + let envelope: protocol::endpoint::EndpointNotification = serde_json::from_str(&data).unwrap(); + assert_eq!(envelope.notification, event); + assert_eq!( + envelope.sound_profile, + Some(protocol::endpoint::NotificationSoundProfile { + config_key: "droid".into(), + default_off: true, + }) + ); + assert!(receivers + .iter() + .all(|receiver| receiver.try_recv().is_err())); +} + +#[test] +fn notification_sound_metadata_tracks_package_snapshot_changes_and_removal() { + let snapshot = |sound: &str, generation| { + crate::agents::store::snapshot_for_test(vec![( + "agents/future-agent/agent.toml".into(), + format!("schema = 1\nid = 'future-agent'\nname = 'future'\naliases = []\nstartable = true\n[launch]\nunix = 'shared-cli'\nwindows = 'shared-cli'\n{sound}"), + )], generation).unwrap() + }; + let first = snapshot("[sound]\nkey = 'old_key'\ndefault = 'off'\n", 1); + let second = snapshot("[sound]\nkey = 'new_key'\ndefault = 'default'\n", 2); + let absent = snapshot("", 3); + let event = protocol::SemanticNotification { + kind: protocol::SemanticNotificationKind::NeedsAttention, + title: "future needs attention".into(), + body: None, + sound: Some(protocol::SemanticNotificationSound::Request), + agent: Some("future-agent".into()), + workspace_id: None, + tab_id: None, + pane_id: None, + position: None, + }; + let old = HeadlessServer::resolved_notification_sound_profile(&first, &event).unwrap(); + let new = HeadlessServer::resolved_notification_sound_profile(&second, &event).unwrap(); + assert_eq!( + (old.config_key.as_str(), old.default_off), + ("old_key", true) + ); + assert_eq!( + (new.config_key.as_str(), new.default_off), + ("new_key", false) + ); + assert!(HeadlessServer::resolved_notification_sound_profile(&absent, &event).is_none()); +} + #[test] fn notification_show_uses_client_shell_policy_independent_of_server_delivery() { let mut server = test_headless_server(); @@ -6465,6 +6568,7 @@ fn startup_idle_does_not_forward_completion() { pane_id, agent: Some(crate::detect::Agent::Pi), state: crate::detect::AgentState::Idle, + visible_idle: false, visible_blocker: false, visible_working: false, process_exited: false, diff --git a/src/server/headless/tests/surface_interest.rs b/src/server/headless/tests/surface_interest.rs index df06dc03ad..558e55ee3d 100644 --- a/src/server/headless/tests/surface_interest.rs +++ b/src/server/headless/tests/surface_interest.rs @@ -74,6 +74,7 @@ async fn metadata_only_shell_is_isolated_until_surface_activation() { endpoint_keybindings: true, mouse_capture: true, surface_active: false, + notification_sound_profile: false, writer, }) ); @@ -290,6 +291,7 @@ async fn background_surface_activation_preserves_focused_viewer_geometry() { endpoint_keybindings: false, mouse_capture: false, surface_active: false, + notification_sound_profile: false, writer, }) ); @@ -401,6 +403,7 @@ async fn presentation_sync_epoch_replays_modes_and_title() { endpoint_keybindings: true, mouse_capture: true, surface_active: true, + notification_sound_profile: false, writer, }) ); @@ -515,6 +518,7 @@ async fn two_headless_servers_drive_atomic_endpoint_handoff() { endpoint_keybindings: true, mouse_capture: true, surface_active: true, + notification_sound_profile: false, writer: source_writer, }) ); @@ -540,6 +544,7 @@ async fn two_headless_servers_drive_atomic_endpoint_handoff() { endpoint_keybindings: true, mouse_capture: true, surface_active: false, + notification_sound_profile: false, writer: target_writer, }) ); diff --git a/src/terminal/metadata.rs b/src/terminal/metadata.rs index 91b1d582e7..048c86c25d 100644 --- a/src/terminal/metadata.rs +++ b/src/terminal/metadata.rs @@ -66,13 +66,16 @@ impl TerminalState { agent_label .and_then(crate::detect::parse_agent_label) .or_else(|| { - crate::detect::Agent::ALL.iter().copied().find(|agent| { - let agent_label = crate::detect::agent_label(*agent); - crate::agent_resume::is_official_agent_source(source, agent_label) + let registry = crate::agents::registry(); + let agent = registry.known_profiles().find_map(|profile| { + let id = profile.canonical_id(); + (registry.profile_for_exact_report_pair(source, id).is_some() || applies_to_source.is_some_and(|source| { - crate::agent_resume::is_official_agent_source(source, agent_label) - }) - }) + registry.profile_for_exact_report_pair(source, id).is_some() + })) + .then(|| profile.legacy_agent()) + }); + agent }) } @@ -85,7 +88,7 @@ impl TerminalState { let Some(exit) = self.recent_agent_process_exit else { return false; }; - let exited_agent_label = crate::detect::agent_label(exit.agent); + let exited_agent_label = crate::detect::agent_label(&exit.agent); agent_label.and_then(crate::detect::parse_agent_label) == Some(exit.agent) || crate::agent_resume::is_official_agent_source(source, exited_agent_label) || applies_to_source.is_some_and(|source| { diff --git a/src/terminal/state.rs b/src/terminal/state.rs index dc28af1065..fe6ba5ac84 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -60,10 +60,12 @@ struct StaleFullLifecycleHookSession { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ManagedAgentPhase { + /// Persisted identity is queued, but no command has reached a live PTY yet. + Queued, Pending { + injected_at: Instant, ready_after: Option, deadline: Instant, - observed_expected: bool, }, Blocked, Active, @@ -72,9 +74,24 @@ enum ManagedAgentPhase { #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct ManagedAgent { kind: Agent, + strict_input_readiness: bool, phase: ManagedAgentPhase, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ManagedStartupProcessEvidence { + agent: Agent, + observed_at: Instant, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ManagedStartupScreenEvidence { + agent: Agent, + observed_at: Instant, + visible_idle: bool, + visible_blocker: bool, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct EffectiveStateChange { pub previous_agent_label: Option, @@ -128,11 +145,17 @@ pub struct TerminalState { pub agent_metadata: HashMap, pub metadata_tokens: crate::metadata_tokens::MetadataTokens, pub persisted_agent_session: Option, + pub(crate) live_agent_resume_binding: Option, + pending_report_resume_bindings: HashMap, + pub pinned_agent_resume_recipe: Option, + restored_resume_options: Option<(crate::agent_resume::PersistedAgentSession, Vec)>, pub terminal_title: Option, pub manual_label: Option, pub agent_name: Option, agent_name_owner: Option, managed_agent: Option, + managed_startup_process_evidence: Option, + managed_startup_screen_evidence: Option, managed_agent_launch_session: Option, hook_report_sequences: HashMap, suppressed_full_lifecycle_hook_reports: HashMap, @@ -163,11 +186,17 @@ impl TerminalState { agent_metadata: HashMap::new(), metadata_tokens: crate::metadata_tokens::MetadataTokens::default(), persisted_agent_session: None, + live_agent_resume_binding: None, + pending_report_resume_bindings: HashMap::new(), + pinned_agent_resume_recipe: None, + restored_resume_options: None, terminal_title: None, manual_label: None, agent_name: None, agent_name_owner: None, managed_agent: None, + managed_startup_process_evidence: None, + managed_startup_screen_evidence: None, managed_agent_launch_session: None, hook_report_sequences: HashMap::new(), suppressed_full_lifecycle_hook_reports: HashMap::new(), @@ -191,6 +220,15 @@ impl TerminalState { agent: Agent, now: Instant, ) -> TerminalStateMutation { + if self + .managed_startup_process_evidence + .is_none_or(|evidence| now >= evidence.observed_at) + { + self.managed_startup_process_evidence = Some(ManagedStartupProcessEvidence { + agent, + observed_at: now, + }); + } let starts_acquisition = !self .should_ignore_detected_state_under_full_lifecycle_hook(Some(agent), false) && !self.detected_state_observed_before_release_suppression(Some(agent), now); @@ -316,11 +354,28 @@ impl TerminalState { agent: Option, fallback_state: AgentState, visible_blocker: bool, - _visible_idle: bool, - _visible_working: bool, + visible_idle: bool, + visible_working: bool, process_exited: bool, now: Instant, ) -> TerminalStateMutation { + if !process_exited { + if let Some(agent) = + agent.filter(|_| visible_idle || visible_blocker || visible_working) + { + if self + .managed_startup_screen_evidence + .is_none_or(|evidence| now >= evidence.observed_at) + { + self.managed_startup_screen_evidence = Some(ManagedStartupScreenEvidence { + agent, + observed_at: now, + visible_idle, + visible_blocker, + }); + } + } + } let previous_agent_label = self.effective_agent_label().map(str::to_string); let previous_known_agent = self.effective_known_agent(); let previous_state = self.state; @@ -329,7 +384,7 @@ impl TerminalState { let previous_session = self.current_session_identity_for_persistence(); let newer_custom_authority = process_exited && self.hook_authority.as_ref().is_some_and(|authority| { - crate::detect::parse_agent_label(&authority.agent_label) == agent + self.known_agent_for_label(&authority.agent_label) == agent && !crate::agent_resume::is_official_agent_source( &authority.source, &authority.agent_label, @@ -337,13 +392,16 @@ impl TerminalState { && authority.reported_at > now }); let agent_released = process_exited + && self + .managed_agent + .is_none_or(|managed| Some(managed.kind) == agent) && !newer_custom_authority && (previous_agent_label.is_some() || self.agent_name.is_some()); if self.should_ignore_detected_state_under_full_lifecycle_hook(agent, process_exited) { if self .hook_authority .as_ref() - .and_then(|authority| crate::detect::parse_agent_label(&authority.agent_label)) + .and_then(|authority| self.known_agent_for_label(&authority.agent_label)) == agent { self.detected_agent = agent; @@ -382,7 +440,7 @@ impl TerminalState { } self.detected_agent = agent; if let Some(agent) = agent { - let agent_label = crate::detect::agent_label(agent); + let agent_label = crate::detect::agent_label(&agent); self.reconcile_agent_name_owner(agent_label, None); } if !process_exited { @@ -412,7 +470,7 @@ impl TerminalState { let mut reset_sources = Vec::new(); let mut stale_sessions = Vec::new(); for (source, suppressed) in &mut self.suppressed_full_lifecycle_hook_reports { - if crate::detect::parse_agent_label(&suppressed.agent_label) != agent + if !agent_label_matches(&suppressed.agent_label, agent) || suppressed.reason == FullLifecycleHookSuppressionReason::HookClear { continue; @@ -457,7 +515,7 @@ impl TerminalState { crate::agent_resume::is_official_agent_source( &authority.source, &authority.agent_label, - ) && crate::detect::parse_agent_label(&authority.agent_label) == agent + ) && self.known_agent_for_label(&authority.agent_label) == agent }) .map(|authority| { ( @@ -471,7 +529,7 @@ impl TerminalState { (crate::agent_resume::is_official_agent_source( &session.source, &session.agent, - ) && crate::detect::parse_agent_label(&session.agent) == agent) + ) && self.known_agent_for_label(&session.agent) == agent) .then(|| { ( session.source.clone(), @@ -492,7 +550,7 @@ impl TerminalState { ); } let cleared_hook_source = self.hook_authority.as_ref().and_then(|authority| { - (crate::detect::parse_agent_label(&authority.agent_label) == agent + (self.known_agent_for_label(&authority.agent_label) == agent && !newer_custom_authority) .then(|| authority.source.clone()) }); @@ -504,14 +562,12 @@ impl TerminalState { && self .persisted_agent_session .as_ref() - .is_some_and(|session| { - crate::detect::parse_agent_label(&session.agent) == agent - }) + .is_some_and(|session| self.known_agent_for_label(&session.agent) == agent) { self.persisted_agent_session = None; } if let Some(agent) = agent { - let agent_label = crate::detect::agent_label(agent); + let agent_label = crate::detect::agent_label(&agent); let mut cleared_metadata_sources = Vec::new(); self.agent_metadata.retain(|source, metadata| { let official_metadata = crate::agent_resume::is_official_agent_source( @@ -555,7 +611,7 @@ impl TerminalState { || (previous_detected_agent.is_some() && agent != previous_detected_agent && self.hook_authority.as_ref().is_some_and(|authority| { - crate::detect::parse_agent_label(&authority.agent_label) + self.known_agent_for_label(&authority.agent_label) == previous_detected_agent }))) { @@ -642,13 +698,20 @@ impl TerminalState { seq: Option, now: Instant, ) -> Option { - if crate::detect::session_identity_only_integration(&source, &agent_label) { + let session_ref = session_ref.filter(|session_ref| { + !crate::agents::bundled_report_pair(&source, &agent_label) + || self.bound_report_accepts_reference(&source, &agent_label, session_ref) + }); + // Launch provenance is internal state, never a reportable source. + if source == "herdr:launch" + || crate::detect::session_identity_only_integration(&source, &agent_label) + { return None; } if !crate::detect::full_lifecycle_hook_authority(&source, &agent_label) - && self.recent_agent_process_exit.is_some_and(|exit| { - crate::detect::parse_agent_label(&agent_label) == Some(exit.agent) - }) + && self + .recent_agent_process_exit + .is_some_and(|exit| self.known_agent_for_label(&agent_label) == Some(exit.agent)) { return None; } @@ -726,6 +789,12 @@ impl TerminalState { } } self.persisted_agent_session = None; + if let Some(reference) = session_ref.as_ref() { + self.pin_report_resume_recipe(&source, &agent_label, reference); + } + if session_ref.is_none() { + self.restored_resume_options = None; + } self.hook_authority = Some(HookAuthority { source, agent_label, @@ -766,7 +835,7 @@ impl TerminalState { return false; }; self.hook_authority.as_ref().is_some_and(|authority| { - crate::detect::parse_agent_label(&authority.agent_label) + self.known_agent_for_label(&authority.agent_label) .is_some_and(|hook_agent| hook_agent != detected_agent) }) } @@ -877,7 +946,7 @@ impl TerminalState { return FullLifecycleHookReportRoute::Ignore; } - let known_agent = crate::detect::parse_agent_label(agent_label); + let known_agent = self.known_agent_for_label(agent_label); let process_present = known_agent.is_some() && self.detected_agent == known_agent && self.recent_agent_process_exit.is_none(); @@ -1087,13 +1156,13 @@ impl TerminalState { if previous_detected_agent == Some(detected_agent) { return; } - let detected_label = crate::detect::agent_label(detected_agent); + let detected_label = crate::detect::agent_label(&detected_agent); let mut stale_sessions = Vec::new(); let mut validated_replacement_sessions = Vec::new(); self.suppressed_full_lifecycle_hook_reports .retain(|source, suppressed| { - let should_clear = crate::detect::parse_agent_label(&suppressed.agent_label) - == Some(detected_agent); + let should_clear = + agent_label_matches(&suppressed.agent_label, Some(detected_agent)); if !should_clear { return true; } @@ -1159,6 +1228,7 @@ impl TerminalState { for (source, agent_label, session_ref, pending) in validated_replacement_sessions { self.forget_stale_full_lifecycle_hook_session(&source, &agent_label, &session_ref); self.reconcile_agent_name_owner(&agent_label, Some(&session_ref)); + self.pin_report_resume_recipe(&source, &agent_label, &session_ref); self.persisted_agent_session = Some(crate::agent_resume::PersistedAgentSession { source: source.clone(), agent: agent_label, @@ -1224,7 +1294,7 @@ impl TerminalState { self.suppressed_full_lifecycle_hook_reports .values() .any(|suppressed| { - crate::detect::parse_agent_label(&suppressed.agent_label) == Some(detected_agent) + self.known_agent_for_label(&suppressed.agent_label) == Some(detected_agent) && observed_at <= suppressed.observed_at }) } @@ -1299,7 +1369,8 @@ impl TerminalState { session_ref: &crate::agent_resume::AgentSessionRef, ) -> bool { self.hook_authority.is_none() - && (source, agent_label) == ("herdr:mastracode", "mastracode") + && crate::agents::registry() + .initial_lifecycle_report_replaces_session(source, agent_label) && self .persisted_agent_session .as_ref() @@ -1317,31 +1388,10 @@ impl TerminalState { agent_label: &str, session_start_source: Option<&str>, ) -> bool { - matches!( - (source, agent_label, session_start_source), - ( - "herdr:claude", - "claude", - Some("clear" | "resume" | "compact") - ) | ( - "herdr:codex", - "codex", - Some("startup" | "clear" | "resume" | "compact") - ) | ("herdr:mastracode", "mastracode", Some("startup")) - | ("herdr:hermes", "hermes", Some("startup" | "new" | "resume")) - | ("herdr:opencode", "opencode", Some("select")) - | ("herdr:pi", "pi", Some("new" | "resume" | "fork")) - | ( - "herdr:omp", - "omp", - Some("startup" | "new" | "resume" | "fork") - ) - | ( - "herdr:qwen", - "qwen", - Some("startup" | "clear" | "resume" | "compact" | "branch") - ) - | ("herdr:antigravity_cli", "agy", None) + crate::agents::registry().session_report_allows_replacement( + source, + agent_label, + session_start_source, ) } @@ -1352,20 +1402,421 @@ impl TerminalState { ) } - fn is_unsequenced_opencode_selection( + fn is_unsequenced_session_replacement( source: &str, agent_label: &str, session_start_source: Option<&str>, seq: Option, ) -> bool { - (source, agent_label, session_start_source, seq) - == ("herdr:opencode", "opencode", Some("select"), None) + seq.is_none() + && crate::agents::registry().session_replacement_allows_unsequenced_report( + source, + agent_label, + session_start_source, + ) + } + + pub(crate) fn admit_agent_resume_recipe( + &mut self, + agent: Agent, + recipe: Option, + admitted_session: Option, + now: Instant, + ) { + self.pending_report_resume_bindings.remove(&agent); + self.live_agent_resume_binding = Some(crate::agent_resume::LiveAgentResumeBinding { + agent, + recipe, + process: None, + process_identity: None, + observed_at: now, + managed_admission: true, + resume_options_owner: admitted_session, + report_proof: None, + resume_options: None, + }); + } + + pub(crate) fn bind_agent_resume_process( + &mut self, + binding: crate::agent_resume::LiveAgentResumeBinding, + ) -> bool { + let owner = + self.current_session_identity_for_persistence() + .map( + |(source, agent, kind, value)| crate::agent_resume::PersistedAgentSession { + source, + agent, + session_ref: crate::agent_resume::AgentSessionRef { kind, value }, + }, + ); + let before = owner + .as_ref() + .map(|owner| self.resume_options_for_session(owner).to_vec()); + self.apply_agent_resume_process_binding(binding); + before + != owner + .as_ref() + .map(|owner| self.resume_options_for_session(owner).to_vec()) + } + + fn apply_agent_resume_process_binding( + &mut self, + mut binding: crate::agent_resume::LiveAgentResumeBinding, + ) { + if self.recent_agent_process_exit.is_some_and(|exit| { + exit.agent == binding.agent && binding.observed_at <= exit.observed_at + }) { + return; + } + if self + .live_agent_resume_binding + .as_ref() + .is_none_or(|current| current.agent != binding.agent) + { + if let Some(pending) = self.pending_report_resume_bindings.remove(&binding.agent) { + self.live_agent_resume_binding = Some(pending); + } + } + let historical_session = self.current_session_identity_for_persistence(); + let mut reanchor_queued_report = false; + let mut verified_historical_report = false; + let mut verified_report_proof = None; + if let Some(current) = &mut self.live_agent_resume_binding { + if current.managed_admission + && current.process.is_none() + && binding.observed_at < current.observed_at + { + return; + } + if current.process.is_some() && binding.observed_at < current.observed_at { + return; + } + if current.agent == binding.agent { + let same_report_process = current + .report_proof + .as_ref() + .is_some_and(|(identity, _, _)| Some(*identity) == binding.process_identity); + if same_report_process { + verified_report_proof = current.report_proof.clone(); + } + verified_historical_report = same_report_process + && current + .report_proof + .as_ref() + .is_some_and(|(_, reference, accepted)| { + *accepted + && historical_session.as_ref().is_some_and( + |(_, agent, kind, value)| { + agent == current.agent.as_str() + && *kind == reference.kind + && value == &reference.value + }, + ) + }); + if current.process.is_none() && verified_historical_report { + if let Some((source, agent, kind, value)) = &historical_session { + current.resume_options_owner = + Some(crate::agent_resume::PersistedAgentSession { + source: source.clone(), + agent: agent.clone(), + session_ref: crate::agent_resume::AgentSessionRef { + kind: *kind, + value: value.clone(), + }, + }); + } + } + if same_report_process { + binding.resume_options_owner = current.resume_options_owner.clone(); + } + let same_process = current.process.is_some() + && current.process_identity.is_some() + && current.process_identity == binding.process_identity; + if same_process + || (current.process.is_none() + && binding.process_identity.is_some() + && (current.managed_admission + || (same_report_process && current.observed_at <= binding.observed_at))) + { + let first_process = current.process.is_none(); + self.restored_resume_options = None; + current.process = binding.process; + current.process_identity = binding.process_identity; + if !first_process + && current.resume_options.as_ref().is_some_and(|previous| { + binding + .resume_options + .as_ref() + .is_none_or(|next| previous != next) + }) + { + current.resume_options_owner = None; + current.report_proof = None; + } + if let Some(options) = binding.resume_options { + current.resume_options = Some(options); + } else if let Some(previous) = &mut current.resume_options { + // Remember the last argv birth even across unreadability, + // so a later replacement cannot inherit its session owner. + previous.options.clear(); + } + current.observed_at = binding.observed_at; + if first_process + && verified_historical_report + && self + .pinned_agent_resume_recipe + .as_ref() + .is_some_and(|recipe| { + recipe.agent == current.agent.as_str() + && recipe.strategy == "unavailable" + }) + { + self.pinned_agent_resume_recipe = + Some(current.recipe.clone().unwrap_or_else(|| { + crate::agent_resume::PinnedAgentResumeRecipe::unavailable( + current.agent.as_str(), + ) + })); + } + return; + } + // The report was processed before the already-acquired process + // observation reached the reducer. Its global provisional recipe + // must not win over the older, exact acquisition snapshot. + reanchor_queued_report = current.process.is_none(); + } + } + if reanchor_queued_report { + self.pinned_agent_resume_recipe = Some(if verified_historical_report { + binding.recipe.clone().unwrap_or_else(|| { + crate::agent_resume::PinnedAgentResumeRecipe::unavailable( + binding.agent.as_str(), + ) + }) + } else { + crate::agent_resume::PinnedAgentResumeRecipe::unavailable(binding.agent.as_str()) + }); + } + binding.report_proof = verified_report_proof; + self.restored_resume_options = None; + self.live_agent_resume_binding = Some(binding); + } + + pub(crate) fn session_ref_from_bound_report( + &mut self, + source: &str, + agent: &str, + id: Option, + path: Option, + ) -> Option { + if !crate::agents::bundled_report_pair(source, agent) { + return None; + } + self.ensure_report_resume_binding(source, agent); + self.report_resume_binding(agent)? + .recipe + .as_ref()? + .select_report_reference(id, path) + } + + pub(crate) fn record_report_process_proof( + &mut self, + agent: &str, + reference: &crate::agent_resume::AgentSessionRef, + identity: crate::platform::ProcessIdentity, + ) { + if let Some(binding) = self.report_resume_binding_mut(agent) { + binding.report_proof = Some((identity, reference.clone(), false)); + } + } + + fn report_resume_binding_mut( + &mut self, + agent: &str, + ) -> Option<&mut crate::agent_resume::LiveAgentResumeBinding> { + if self + .live_agent_resume_binding + .as_ref() + .is_some_and(|binding| binding.agent.as_str() == agent) + { + self.live_agent_resume_binding.as_mut() + } else { + Agent::parse(agent) + .ok() + .and_then(|id| self.pending_report_resume_bindings.get_mut(&id)) + } + } + + fn report_resume_binding( + &self, + agent: &str, + ) -> Option<&crate::agent_resume::LiveAgentResumeBinding> { + self.live_agent_resume_binding + .as_ref() + .filter(|binding| binding.agent.as_str() == agent) + .or_else(|| { + Agent::parse(agent) + .ok() + .and_then(|id| self.pending_report_resume_bindings.get(&id)) + }) + } + + fn ensure_report_resume_binding(&mut self, source: &str, agent: &str) { + if !crate::agents::bundled_report_pair(source, agent) + || self.report_resume_binding(agent).is_some() + { + return; + } + let Ok(agent_id) = Agent::parse(agent) else { + return; + }; + // A different agent's trusted startup reports may precede acquisition. + // Keep their capability separate; they cannot replace the live process. + let binding = crate::agent_resume::LiveAgentResumeBinding { + agent: agent_id, + recipe: crate::agent_resume::recipe_for_report(source, agent), + process: None, + process_identity: None, + observed_at: Instant::now(), + managed_admission: false, + resume_options_owner: None, + report_proof: None, + resume_options: None, + }; + if self.live_agent_resume_binding.is_none() { + self.live_agent_resume_binding = Some(binding); + } else { + // Only exact compiled reporter pairs reach here, bounding this map + // by the small core-owned reporter set, never package-supplied IDs. + self.pending_report_resume_bindings + .insert(agent_id, binding); + } + } + + fn bound_report_accepts_reference( + &mut self, + source: &str, + agent: &str, + session_ref: &crate::agent_resume::AgentSessionRef, + ) -> bool { + if !crate::agents::bundled_report_pair(source, agent) { + return false; + } + self.ensure_report_resume_binding(source, agent); + let accepted = self + .report_resume_binding(agent) + .and_then(|binding| binding.recipe.as_ref()) + .is_some_and(|recipe| recipe.accepted_references.contains(&session_ref.kind)); + if accepted { + if let Some(identity) = self + .report_resume_binding(agent) + .and_then(|binding| binding.process_identity) + { + self.record_report_process_proof(agent, session_ref, identity); + } + } + accepted + } + + fn pin_report_resume_recipe( + &mut self, + source: &str, + agent: &str, + reference: &crate::agent_resume::AgentSessionRef, + ) { + if !crate::agents::bundled_report_pair(source, agent) { + return; + } + self.ensure_report_resume_binding(source, agent); + if self + .restored_resume_options + .as_ref() + .is_some_and(|(owner, _)| { + owner.source != source || owner.agent != agent || &owner.session_ref != reference + }) + { + self.restored_resume_options = None; + } + // Only an accepted/validated session mutation may authorize its early + // evidence. An ignored stale report cannot promote historical metadata. + if let Some(binding) = self.report_resume_binding_mut(agent) { + if let Some((_, reported, accepted)) = &mut binding.report_proof { + if reported == reference { + *accepted = true; + } + } + } + self.pinned_agent_resume_recipe = self.report_resume_binding_mut(agent).map(|binding| { + let verified = binding.process_identity.is_some() + && binding + .report_proof + .as_ref() + .is_some_and(|(identity, reported, accepted)| { + *accepted + && Some(*identity) == binding.process_identity + && reported == reference + }); + if verified { + binding.resume_options_owner = Some(crate::agent_resume::PersistedAgentSession { + source: source.into(), + agent: agent.into(), + session_ref: reference.clone(), + }); + } + if !verified { + crate::agent_resume::PinnedAgentResumeRecipe::unavailable(agent) + } else { + binding.recipe.clone().unwrap_or_else(|| { + crate::agent_resume::PinnedAgentResumeRecipe::unavailable(agent) + }) + } + }); + } + + pub(crate) fn restore_agent_session( + &mut self, + session: crate::agent_resume::PersistedAgentSession, + options: Vec, + ) { + self.set_persisted_agent_session(session.clone()); + self.restored_resume_options = (!options.is_empty()).then_some((session, options)); + } + + pub(crate) fn resume_options_for_session( + &self, + owner: &crate::agent_resume::PersistedAgentSession, + ) -> &[String] { + if let Some(binding) = self + .live_agent_resume_binding + .as_ref() + .filter(|binding| binding.process.is_some()) + { + let verified = binding.agent.as_str() == owner.agent + && binding.process_identity.is_some() + && binding.resume_options_owner.as_ref() == Some(owner); + return if verified && self.recent_agent_process_exit.is_none() { + binding + .resume_options + .as_ref() + .map_or(&[], |options| options.options.as_slice()) + } else { + &[] + }; + } + self.restored_resume_options + .as_ref() + .filter(|(saved_owner, _)| { + saved_owner == owner && self.recent_agent_process_exit.is_none() + }) + .map_or(&[], |(_, options)| options.as_slice()) } pub fn set_persisted_agent_session( &mut self, session: crate::agent_resume::PersistedAgentSession, ) { + self.restored_resume_options = None; self.persisted_agent_session = Some(session); } @@ -1373,6 +1824,7 @@ impl TerminalState { &mut self, session: crate::agent_resume::PersistedAgentSession, ) { + self.restored_resume_options = None; self.persisted_agent_session = Some(session.clone()); self.managed_agent_launch_session = Some(session); } @@ -1395,8 +1847,14 @@ impl TerminalState { seq: Option, session_start_source: Option, ) -> Option { + if source == "herdr:launch" { + return None; + } let session_ref = session_ref?; - let known_agent = crate::detect::parse_agent_label(&agent_label); + if !self.bound_report_accepts_reference(&source, &agent_label, &session_ref) { + return None; + } + let known_agent = self.known_agent_for_label(&agent_label); let process_present = known_agent.is_some() && self.detected_agent == known_agent && self.recent_agent_process_exit.is_none(); @@ -1414,7 +1872,7 @@ impl TerminalState { && authority.agent_label == agent_label && authority.session_ref.is_some() }) || self.persisted_agent_session_matches(&source, &agent_label); - let unsequenced_selection = Self::is_unsequenced_opencode_selection( + let unsequenced_selection = Self::is_unsequenced_session_replacement( &source, &agent_label, session_start_source.as_deref(), @@ -1605,6 +2063,11 @@ impl TerminalState { if self.managed_agent_launch_session.as_ref() == Some(&persisted_session) { self.managed_agent_launch_session = None; } + self.pin_report_resume_recipe( + &persisted_session.source, + &persisted_session.agent, + &persisted_session.session_ref, + ); self.persisted_agent_session = Some(persisted_session); let current_session = self.current_session_identity_for_persistence(); Some(TerminalStateMutation { @@ -1620,11 +2083,26 @@ impl TerminalState { }) } + fn known_agent_for_label(&self, label: &str) -> Option { + [ + self.detected_agent, + self.live_agent_resume_binding + .as_ref() + .map(|binding| binding.agent), + self.managed_agent.map(|managed| managed.kind), + self.recent_agent_process_exit.map(|exit| exit.agent), + ] + .into_iter() + .flatten() + .find(|agent| agent.as_str() == label) + .or_else(|| crate::detect::parse_agent_label(label)) + } + fn known_agent_label_conflicts_with_detected_agent(&self, agent_label: &str) -> bool { let Some(detected_agent) = self.detected_agent else { return false; }; - crate::detect::parse_agent_label(agent_label) + self.known_agent_for_label(agent_label) .is_some_and(|hook_agent| hook_agent != detected_agent) } @@ -1659,8 +2137,12 @@ impl TerminalState { let Some(detected_agent) = self.detected_agent else { return false; }; - crate::detect::parse_agent_label(agent_label) == Some(detected_agent) - && crate::agent_resume::plan(source, agent_label, session_ref).is_some() + self.known_agent_for_label(agent_label) == Some(detected_agent) + && crate::agents::bundled_report_pair(source, agent_label) + && self + .report_resume_binding(agent_label) + .and_then(|binding| binding.recipe.as_ref()) + .is_some_and(|recipe| recipe.accepted_references.contains(&session_ref.kind)) } fn accept_hook_report(&mut self, source: &str, seq: Option) -> bool { @@ -1724,6 +2206,7 @@ impl TerminalState { ); self.hook_authority = None; self.persisted_agent_session = None; + self.restored_resume_options = None; Some(TerminalStateMutation { effective_state_change: self.recompute_effective_state( previous_agent_label, @@ -1761,8 +2244,9 @@ impl TerminalState { .persisted_agent_session .as_ref() .is_some_and(|session| session.source != source || session.agent != agent_label); - let process_owns_agent = - crate::detect::parse_agent_label(agent_label).is_some_and(|agent| { + let process_owns_agent = self + .known_agent_for_label(agent_label) + .is_some_and(|agent| { self.detected_agent == Some(agent) && self.recent_agent_process_exit.is_none() }); @@ -1786,6 +2270,7 @@ impl TerminalState { } self.hook_authority = None; if !preserve_foreign_persisted_session { + self.restored_resume_options = None; self.persisted_agent_session = None; } let current_session = self.current_session_identity_for_persistence(); @@ -1804,9 +2289,11 @@ impl TerminalState { fn hook_authority_is_effective(&self, authority: &HookAuthority) -> bool { !crate::detect::full_lifecycle_hook_authority(&authority.source, &authority.agent_label) - || crate::detect::parse_agent_label(&authority.agent_label).is_none_or(|agent| { - self.detected_agent == Some(agent) && self.recent_agent_process_exit.is_none() - }) + || self + .known_agent_for_label(&authority.agent_label) + .is_none_or(|agent| { + self.detected_agent == Some(agent) && self.recent_agent_process_exit.is_none() + }) } pub fn effective_agent_label(&self) -> Option<&str> { @@ -1817,14 +2304,14 @@ impl TerminalState { .or_else(|| { self.recent_agent_process_exit .is_none() - .then(|| self.detected_agent.map(crate::detect::agent_label)) + .then(|| self.detected_agent.as_ref().map(crate::detect::agent_label)) .flatten() }) } pub fn effective_known_agent(&self) -> Option { self.effective_agent_label() - .and_then(crate::detect::parse_agent_label) + .and_then(|label| self.known_agent_for_label(label)) } pub(crate) fn unchanged_effective_state_change_at(&self, now: Instant) -> EffectiveStateChange { @@ -1856,8 +2343,7 @@ impl TerminalState { && self.fallback_not_older_than_hook() && self.hook_authority.as_ref().is_some_and(|authority| { authority.state != AgentState::Blocked - && crate::detect::parse_agent_label(&authority.agent_label) - == self.detected_agent + && self.known_agent_for_label(&authority.agent_label) == self.detected_agent }) } @@ -1907,6 +2393,7 @@ impl TerminalState { }); } + #[cfg(test)] pub fn begin_managed_agent( &mut self, name: String, @@ -1915,33 +2402,118 @@ impl TerminalState { settle_delay: Duration, timeout: Duration, ) { - self.set_agent_name(name); - self.agent_name_owner = Some(AgentNameOwner { - agent_label: crate::detect::agent_label(kind).to_string(), - session_ref: None, + let strict_input_readiness = + crate::detect::manifest::requires_screen_visible_idle(&crate::agents::registry(), kind); + self.begin_managed_agent_with_readiness( + Some(name), + kind, + strict_input_readiness, + now, + settle_delay, + timeout, + ); + } + + pub(crate) fn begin_managed_agent_with_readiness( + &mut self, + name: Option, + kind: Agent, + strict_input_readiness: bool, + now: Instant, + settle_delay: Duration, + timeout: Duration, + ) { + if let Some(name) = name { + self.set_agent_name(name); + self.agent_name_owner = Some(AgentNameOwner { + agent_label: crate::detect::agent_label(&kind).to_string(), + session_ref: None, + }); + } + self.managed_startup_process_evidence = None; + self.managed_startup_screen_evidence = None; + self.managed_agent = Some(ManagedAgent { + kind, + strict_input_readiness, + phase: ManagedAgentPhase::Pending { + injected_at: now, + ready_after: Some(now.checked_add(settle_delay).unwrap_or(now)), + deadline: now.checked_add(timeout).unwrap_or(now), + }, }); + } + + pub(crate) fn queue_managed_agent( + &mut self, + name: Option, + kind: Agent, + strict_input_readiness: bool, + ) { + if let Some(name) = name { + self.set_agent_name(name); + self.agent_name_owner = Some(AgentNameOwner { + agent_label: crate::detect::agent_label(&kind).to_string(), + session_ref: None, + }); + } self.managed_agent = Some(ManagedAgent { kind, + strict_input_readiness, + phase: ManagedAgentPhase::Queued, + }); + } + + pub(crate) fn mark_queued_agent_injected( + &mut self, + now: Instant, + settle_delay: Duration, + timeout: Duration, + ) { + let Some(managed) = self + .managed_agent + .filter(|managed| managed.phase == ManagedAgentPhase::Queued) + else { + return; + }; + self.managed_startup_process_evidence = None; + self.managed_startup_screen_evidence = None; + self.managed_agent = Some(ManagedAgent { phase: ManagedAgentPhase::Pending { + injected_at: now, ready_after: Some(now.checked_add(settle_delay).unwrap_or(now)), deadline: now.checked_add(timeout).unwrap_or(now), - observed_expected: false, }, + ..managed }); } pub fn managed_agent_launch_pending(&self) -> bool { - self.managed_agent.is_some_and(|managed| { - matches!( - managed.phase, - ManagedAgentPhase::Pending { .. } | ManagedAgentPhase::Blocked - ) - }) + self.pending_agent_resume_plan.is_some() + || self.managed_agent.is_some_and(|managed| { + matches!( + managed.phase, + ManagedAgentPhase::Queued + | ManagedAgentPhase::Pending { .. } + | ManagedAgentPhase::Blocked + ) + }) } pub fn managed_agent_interactive_ready(&self) -> bool { - self.managed_agent - .is_some_and(|managed| matches!(managed.phase, ManagedAgentPhase::Active)) + self.pending_agent_resume_plan.is_none() + && self + .managed_agent + .is_some_and(|managed| matches!(managed.phase, ManagedAgentPhase::Active)) + } + + pub(crate) fn screen_detection_required_for_managed_startup(&self) -> bool { + self.managed_agent.is_some_and(|managed| { + managed.strict_input_readiness + && matches!( + managed.phase, + ManagedAgentPhase::Pending { .. } | ManagedAgentPhase::Blocked + ) + }) } pub fn managed_agent_kind(&self) -> Option { @@ -1949,97 +2521,146 @@ impl TerminalState { } pub fn next_managed_agent_deadline(&self) -> Option { + let managed = self.managed_agent?; let ManagedAgentPhase::Pending { ready_after, deadline, .. - } = self.managed_agent?.phase + } = managed.phase else { return None; }; - Some(ready_after.unwrap_or(deadline).min(deadline)) - } + let waiting_for_legacy_settle = !managed.strict_input_readiness + && self.detected_agent == Some(managed.kind) + && self.state == AgentState::Idle; + Some(if waiting_for_legacy_settle { + ready_after.unwrap_or(deadline).min(deadline) + } else { + deadline + }) + } pub fn reconcile_managed_agent_at(&mut self, now: Instant, process_exited: bool) -> bool { let Some(managed) = self.managed_agent else { return false; }; - let known_agent = self.effective_known_agent(); - let observed_expected = match managed.phase { - ManagedAgentPhase::Pending { - observed_expected, .. - } => observed_expected || known_agent == Some(managed.kind), - ManagedAgentPhase::Blocked | ManagedAgentPhase::Active => false, - }; - let clear = process_exited - || known_agent.is_some_and(|agent| agent != managed.kind) - || matches!(managed.phase, ManagedAgentPhase::Pending { .. }) - && observed_expected - && known_agent.is_none(); - if clear { + if managed.phase == ManagedAgentPhase::Queued { + return false; + } + if process_exited { self.clear_agent_name(); return true; } - if managed.phase == ManagedAgentPhase::Blocked { - if known_agent == Some(managed.kind) && self.state == AgentState::Idle { - self.managed_agent = Some(ManagedAgent { - kind: managed.kind, - phase: ManagedAgentPhase::Active, - }); - self.managed_agent_launch_session = None; - return true; - } - return false; - } - if let ManagedAgentPhase::Pending { - ready_after, - deadline, - observed_expected: previous_observed_expected, - } = managed.phase - { - if known_agent == Some(managed.kind) && self.state == AgentState::Blocked { - self.managed_agent = Some(ManagedAgent { - kind: managed.kind, - phase: ManagedAgentPhase::Blocked, - }); - return true; - } - if now >= deadline { - self.clear_agent_name(); - return true; - } - if ready_after.is_none_or(|ready_after| now >= ready_after) { - if known_agent == Some(managed.kind) && self.state == AgentState::Idle { + + let (injected_at, ready_after, deadline) = match managed.phase { + ManagedAgentPhase::Pending { + injected_at, + ready_after, + deadline, + } => (injected_at, ready_after, Some(deadline)), + ManagedAgentPhase::Blocked => { + if !managed.strict_input_readiness { + if self.effective_known_agent() == Some(managed.kind) + && self.state == AgentState::Idle + { + self.managed_agent = Some(ManagedAgent { + phase: ManagedAgentPhase::Active, + ..managed + }); + self.managed_agent_launch_session = None; + return true; + } + return false; + } + let Some(screen) = self.managed_startup_screen_evidence else { + return false; + }; + let process_ready = self + .managed_startup_process_evidence + .is_some_and(|process| { + process.agent == managed.kind && process.observed_at <= screen.observed_at + }); + if process_ready + && screen.agent == managed.kind + && screen.visible_idle + && self.state != AgentState::Blocked + { self.managed_agent = Some(ManagedAgent { - kind: managed.kind, phase: ManagedAgentPhase::Active, + ..managed }); self.managed_agent_launch_session = None; return true; } - if ready_after.is_some() { - self.managed_agent = Some(ManagedAgent { - kind: managed.kind, - phase: ManagedAgentPhase::Pending { - ready_after: None, - deadline, - observed_expected, - }, - }); - return true; - } + return false; } - if observed_expected != previous_observed_expected { + ManagedAgentPhase::Active | ManagedAgentPhase::Queued => return false, + }; + + let process_ready = self + .managed_startup_process_evidence + .is_some_and(|process| { + process.agent == managed.kind && process.observed_at >= injected_at + }); + let fresh_screen = self + .managed_startup_screen_evidence + .filter(|screen| screen.agent == managed.kind && screen.observed_at >= injected_at); + if process_ready + && (fresh_screen.is_some_and(|screen| screen.visible_blocker) + || (self.effective_known_agent() == Some(managed.kind) + && self.state == AgentState::Blocked)) + { + self.managed_agent = Some(ManagedAgent { + phase: ManagedAgentPhase::Blocked, + ..managed + }); + return true; + } + if managed.strict_input_readiness { + let visible_idle_after_process = self + .managed_startup_process_evidence + .zip(fresh_screen) + .is_some_and(|(process, screen)| { + process.agent == managed.kind + && screen.visible_idle + && screen.observed_at >= process.observed_at + }); + if process_ready && visible_idle_after_process && self.state != AgentState::Blocked { self.managed_agent = Some(ManagedAgent { - kind: managed.kind, - phase: ManagedAgentPhase::Pending { - ready_after, - deadline, - observed_expected, - }, + phase: ManagedAgentPhase::Active, + ..managed }); + self.managed_agent_launch_session = None; return true; } + } else if ready_after.is_none_or(|ready_after| now >= ready_after) + && process_ready + && self.effective_known_agent() == Some(managed.kind) + && self.state == AgentState::Idle + { + self.managed_agent = Some(ManagedAgent { + phase: ManagedAgentPhase::Active, + ..managed + }); + self.managed_agent_launch_session = None; + return true; + } + + let deadline = deadline.expect("pending managed agent has a deadline"); + if now >= deadline { + self.clear_agent_name(); + return true; + } + if ready_after.is_some_and(|ready_after| now >= ready_after) { + self.managed_agent = Some(ManagedAgent { + phase: ManagedAgentPhase::Pending { + injected_at, + ready_after: None, + deadline, + }, + ..managed + }); + return true; } false } @@ -2047,11 +2668,12 @@ impl TerminalState { pub fn restore_managed_agent(&mut self, name: String, kind: Agent) { self.set_agent_name(name); self.agent_name_owner = Some(AgentNameOwner { - agent_label: crate::detect::agent_label(kind).to_string(), + agent_label: crate::detect::agent_label(&kind).to_string(), session_ref: None, }); self.managed_agent = Some(ManagedAgent { kind, + strict_input_readiness: false, phase: ManagedAgentPhase::Active, }); } @@ -2063,14 +2685,20 @@ impl TerminalState { .as_ref() .is_some_and(|session| self.persisted_agent_session.as_ref() == Some(session)) { + self.restored_resume_options = None; self.persisted_agent_session = None; } self.agent_name = None; self.agent_name_owner = None; self.managed_agent = None; + self.managed_startup_process_evidence = None; + self.managed_startup_screen_evidence = None; } pub fn clear_agent_runtime_identity_after_respawn(&mut self) { + self.restored_resume_options = None; + self.live_agent_resume_binding = None; + self.pending_report_resume_bindings.clear(); self.detected_agent = None; self.fallback_state = AgentState::Unknown; self.fallback_visible_blocker = false; @@ -2088,11 +2716,16 @@ impl TerminalState { self.recent_agent_process_exit = None; self.agent_process_acquisition_pending = false; self.pending_agent_resume_plan = None; + self.managed_startup_process_evidence = None; + self.managed_startup_screen_evidence = None; self.clear_agent_name(); } pub fn is_agent_terminal(&self) -> bool { - self.agent_name.is_some() || self.effective_agent_label().is_some() + self.agent_name.is_some() + || self.effective_agent_label().is_some() + || self.managed_agent.is_some() + || self.pending_agent_resume_plan.is_some() } fn reconcile_agent_name_owner( @@ -2104,7 +2737,8 @@ impl TerminalState { return; } if self.managed_agent.is_some_and(|managed| { - crate::detect::parse_agent_label(agent_label) == Some(managed.kind) + self.known_agent_for_label(agent_label) == Some(managed.kind) + || !matches!(managed.phase, ManagedAgentPhase::Active) }) { return; } @@ -2169,33 +2803,819 @@ impl TerminalState { let presentation = self.effective_presentation_for_state_at(state, now); self.clear_expiry_pending_for_hidden_metadata(); - if previous_agent_label == agent_label - && previous_state == state - && previous_presentation == presentation - { - return None; - } + if previous_agent_label == agent_label + && previous_state == state + && previous_presentation == presentation + { + return None; + } + + self.state = state; + Some(EffectiveStateChange { + previous_agent_label, + previous_known_agent, + previous_state, + previous_presentation, + agent_label, + known_agent, + state, + presentation, + }) + } +} + +pub(crate) fn stabilize_agent_detection(detection: crate::detect::AgentDetection) -> AgentState { + detection.state +} + +fn agent_label_matches(label: &str, agent: Option) -> bool { + agent.is_some_and(|agent| agent.as_str() == label) + || crate::detect::parse_agent_label(label) == agent +} + +#[cfg(test)] +mod tests { + fn resume_test_binding( + recipe: Option, + pid: u32, + at: std::time::Instant, + ) -> crate::agent_resume::LiveAgentResumeBinding { + crate::agent_resume::LiveAgentResumeBinding { + agent: crate::detect::Agent::Codex, + recipe, + process: Some(( + pid, + crate::platform::ForegroundProcess { + pid, + name: "worker".into(), + argv0: None, + argv: None, + cmdline: None, + }, + )), + observed_at: at, + process_identity: Some(crate::platform::ProcessIdentity { + pid, + birth_token: 1, + }), + managed_admission: false, + report_proof: None, + resume_options_owner: None, + resume_options: None, + } + } + + #[test] + fn resume_options_follow_verified_session_and_process_birth_not_agent_name() { + use crate::agent_resume::*; + let mut terminal = test_terminal(); + let now = Instant::now(); + let recipe = + crate::agents::bundled_profile("codex").and_then(PinnedAgentResumeRecipe::capture); + let mut binding = resume_test_binding(recipe.clone(), 101, now); + let options = vec!["--model".into(), "model name".into()]; + binding.resume_options = Some(ProcessResumeOptions { + argv_owner: binding.process_identity.unwrap(), + options: options.clone(), + }); + terminal.bind_agent_resume_process(binding.clone()); + let owner = PersistedAgentSession { + source: "herdr:codex".into(), + agent: "codex".into(), + session_ref: AgentSessionRef::id("session").unwrap(), + }; + assert!(terminal.resume_options_for_session(&owner).is_empty()); + terminal + .set_agent_session_ref( + owner.source.clone(), + owner.agent.clone(), + Some(owner.session_ref.clone()), + Some(1), + ) + .unwrap(); + assert_eq!(terminal.resume_options_for_session(&owner), options); + let mut stale = binding.clone(); + stale.observed_at = now - std::time::Duration::from_secs(1); + stale.resume_options = None; + assert!(!terminal.bind_agent_resume_process(stale)); + assert_eq!(terminal.resume_options_for_session(&owner), options); + let mut replacement = + resume_test_binding(recipe, 102, now + std::time::Duration::from_secs(1)); + replacement.resume_options = Some(ProcessResumeOptions { + argv_owner: replacement.process_identity.unwrap(), + options: vec!["--model=new".into()], + }); + assert!(terminal.bind_agent_resume_process(replacement.clone())); + assert!(terminal.resume_options_for_session(&owner).is_empty()); + terminal + .set_agent_session_ref( + owner.source.clone(), + owner.agent.clone(), + Some(owner.session_ref.clone()), + Some(2), + ) + .unwrap(); + assert_eq!(terminal.resume_options_for_session(&owner), ["--model=new"]); + replacement.resume_options = None; + replacement.observed_at += std::time::Duration::from_secs(1); + assert!(terminal.bind_agent_resume_process(replacement)); + assert!(terminal.resume_options_for_session(&owner).is_empty()); + } + + #[test] + fn resume_options_follow_accepted_conversation_switches_in_the_same_process() { + use crate::agent_resume::*; + for admitted in [false, true] { + let mut terminal = test_terminal(); + let now = Instant::now(); + let recipe = + crate::agents::bundled_profile("codex").and_then(PinnedAgentResumeRecipe::capture); + let first = PersistedAgentSession { + source: "herdr:codex".into(), + agent: "codex".into(), + session_ref: AgentSessionRef::id("first").unwrap(), + }; + let mut binding = resume_test_binding(recipe.clone(), 101, now); + binding.resume_options = Some(ProcessResumeOptions { + argv_owner: binding.process_identity.unwrap(), + options: vec![ + "--model=chosen".into(), + "--dangerously-bypass-approvals-and-sandbox".into(), + ], + }); + if admitted { + terminal.set_managed_agent_launch_session(first.clone()); + terminal.admit_agent_resume_recipe(Agent::Codex, recipe, Some(first.clone()), now); + } + terminal.bind_agent_resume_process(binding.clone()); + terminal.set_detected_state(Some(Agent::Codex), AgentState::Idle); + terminal + .set_agent_session_ref( + first.source.clone(), + first.agent.clone(), + Some(first.session_ref.clone()), + Some(1), + ) + .unwrap(); + let original_options = binding.resume_options.as_ref().unwrap().options.clone(); + assert_eq!( + terminal.resume_options_for_session(&first), + original_options + ); + for (seq, reference) in [(2, "second"), (3, "first")] { + let owner = PersistedAgentSession { + session_ref: AgentSessionRef::id(reference).unwrap(), + ..first.clone() + }; + terminal + .set_agent_session_ref_for_session_start( + owner.source.clone(), + owner.agent.clone(), + Some(owner.session_ref.clone()), + Some(seq), + Some("resume".into()), + ) + .unwrap(); + assert_eq!( + terminal.resume_options_for_session(&owner), + original_options + ); + assert_eq!(terminal.persisted_agent_session.as_ref(), Some(&owner)); + binding.observed_at += std::time::Duration::from_secs(1); + terminal.bind_agent_resume_process(binding.clone()); + assert_eq!( + terminal.resume_options_for_session(&owner), + original_options + ); + assert_eq!( + terminal + .live_agent_resume_binding + .as_ref() + .unwrap() + .resume_options_owner + .as_ref(), + Some(&owner) + ); + } + } + } + + #[test] + fn resume_options_source_changes_require_fresh_session_evidence() { + use crate::agent_resume::*; + for change in ["birth", "argv", "unreadable"] { + let mut terminal = test_terminal(); + let now = Instant::now(); + let recipe = + crate::agents::bundled_profile("codex").and_then(PinnedAgentResumeRecipe::capture); + let mut binding = resume_test_binding(recipe, 101, now); + binding.resume_options = Some(ProcessResumeOptions { + argv_owner: binding.process_identity.unwrap(), + options: vec!["--model=old".into()], + }); + let owner = PersistedAgentSession { + source: "herdr:codex".into(), + agent: "codex".into(), + session_ref: AgentSessionRef::id("native").unwrap(), + }; + terminal.bind_agent_resume_process(binding.clone()); + terminal + .set_agent_session_ref( + owner.source.clone(), + owner.agent.clone(), + Some(owner.session_ref.clone()), + Some(1), + ) + .unwrap(); + match change { + "birth" => { + binding + .resume_options + .as_mut() + .unwrap() + .argv_owner + .birth_token += 1 + } + "argv" => { + binding.resume_options.as_mut().unwrap().options = vec!["--model=new".into()] + } + "unreadable" => binding.resume_options = None, + _ => unreachable!(), + } + binding.observed_at += std::time::Duration::from_secs(1); + assert!(terminal.bind_agent_resume_process(binding.clone())); + assert!(terminal.resume_options_for_session(&owner).is_empty()); + assert!(terminal + .live_agent_resume_binding + .as_ref() + .unwrap() + .report_proof + .is_none()); + binding.observed_at += std::time::Duration::from_secs(1); + terminal.bind_agent_resume_process(binding.clone()); + assert!(terminal.resume_options_for_session(&owner).is_empty()); + assert!(terminal + .set_agent_session_ref( + owner.source.clone(), + owner.agent.clone(), + Some(owner.session_ref.clone()), + Some(1) + ) + .is_none()); + assert!(terminal.resume_options_for_session(&owner).is_empty()); + if change == "unreadable" { + binding.resume_options = Some(ProcessResumeOptions { + argv_owner: binding.process_identity.unwrap(), + options: vec!["--model=recovered".into()], + }); + binding.observed_at += std::time::Duration::from_secs(1); + terminal.bind_agent_resume_process(binding.clone()); + assert!(terminal.resume_options_for_session(&owner).is_empty()); + } + terminal + .set_agent_session_ref( + owner.source.clone(), + owner.agent.clone(), + Some(owner.session_ref.clone()), + Some(2), + ) + .unwrap(); + assert_eq!( + terminal.resume_options_for_session(&owner), + binding.resume_options.as_ref().unwrap().options + ); + } + } + + #[test] + fn resume_options_early_report_requires_matching_birth_proof() { + use crate::agent_resume::*; + for (prove_birth, acquired_before_report) in + [(false, false), (false, true), (true, false), (true, true)] + { + let mut terminal = test_terminal(); + let now = Instant::now(); + let recipe = + crate::agents::bundled_profile("codex").and_then(PinnedAgentResumeRecipe::capture); + let observed_at = if acquired_before_report { + now - std::time::Duration::from_secs(1) + } else { + now + std::time::Duration::from_secs(1) + }; + let mut binding = resume_test_binding(recipe, 101, observed_at); + binding.resume_options = Some(ProcessResumeOptions { + argv_owner: binding.process_identity.unwrap(), + options: vec!["--model=kept".into()], + }); + let owner = PersistedAgentSession { + source: "herdr:codex".into(), + agent: "codex".into(), + session_ref: AgentSessionRef::id("session").unwrap(), + }; + terminal + .session_ref_from_bound_report( + &owner.source, + &owner.agent, + Some("session".into()), + None, + ) + .unwrap(); + if prove_birth { + terminal.record_report_process_proof( + "codex", + &owner.session_ref, + binding.process_identity.unwrap(), + ); + } + terminal + .set_agent_session_ref( + owner.source.clone(), + owner.agent.clone(), + Some(owner.session_ref.clone()), + Some(1), + ) + .unwrap(); + assert!(terminal.resume_options_for_session(&owner).is_empty()); + terminal.bind_agent_resume_process(binding); + assert_eq!( + !terminal.resume_options_for_session(&owner).is_empty(), + prove_birth + ); + } + } + + #[test] + fn restored_resume_options_are_consumed_by_unreadable_reacquisition() { + use crate::agent_resume::*; + let mut terminal = test_terminal(); + let owner = PersistedAgentSession { + source: "herdr:codex".into(), + agent: "codex".into(), + session_ref: AgentSessionRef::id("session").unwrap(), + }; + terminal.restore_agent_session(owner.clone(), vec!["--model=saved".into()]); + assert_eq!( + terminal.resume_options_for_session(&owner), + ["--model=saved"] + ); + let recipe = + crate::agents::bundled_profile("codex").and_then(PinnedAgentResumeRecipe::capture); + let now = Instant::now(); + terminal.admit_agent_resume_recipe(Agent::Codex, recipe.clone(), Some(owner.clone()), now); + assert!(terminal.bind_agent_resume_process(resume_test_binding(recipe, 101, now))); + assert!(terminal.resume_options_for_session(&owner).is_empty()); + assert!(terminal.restored_resume_options.is_none()); + } + + #[test] + fn trusted_foreign_startup_reference_waits_without_replacing_live_capability() { + use crate::agent_resume::*; + let mut terminal = test_terminal(); + let now = std::time::Instant::now(); + terminal.bind_agent_resume_process(resume_test_binding( + PinnedAgentResumeRecipe::capture(crate::agents::bundled_profile("codex").unwrap()), + 101, + now, + )); + let reference = terminal + .session_ref_from_bound_report( + "herdr:claude", + "claude", + Some("queued-native".into()), + None, + ) + .unwrap(); + assert_eq!( + terminal.live_agent_resume_binding.as_ref().unwrap().agent, + crate::detect::Agent::Codex + ); + terminal.record_report_process_proof( + "claude", + &reference, + crate::platform::ProcessIdentity { + pid: 102, + birth_token: 1, + }, + ); + let pending = terminal.report_resume_binding("claude").unwrap(); + let expected = pending.recipe.clone(); + let acquired_at = pending.observed_at + std::time::Duration::from_millis(1); + let changed = test_registry("claude", "new-cli", "subcommand", "resume"); + let mut acquired = resume_test_binding( + changed + .profile_by_id("claude") + .and_then(PinnedAgentResumeRecipe::capture), + 102, + acquired_at, + ); + acquired.agent = crate::detect::Agent::Claude; + terminal.bind_agent_resume_process(acquired); + assert_eq!( + terminal.live_agent_resume_binding.as_ref().unwrap().recipe, + expected + ); + terminal.set_detected_agent_process_at(crate::detect::Agent::Claude, acquired_at); + terminal + .set_agent_session_ref_for_session_start( + "herdr:claude".into(), + "claude".into(), + Some(reference), + Some(1), + Some("startup".into()), + ) + .unwrap(); + assert_eq!(terminal.pinned_agent_resume_recipe, expected); + } + + #[test] + fn early_session_requires_matching_report_lifetime_proof_or_a_fresh_report() { + use crate::agent_resume::*; + for proof_birth in [None, Some(1), Some(2)] { + let mut terminal = test_terminal(); + let reference = terminal + .session_ref_from_bound_report( + "herdr:codex", + "codex", + Some("early-session".into()), + None, + ) + .unwrap(); + if let Some(birth_token) = proof_birth { + terminal.record_report_process_proof( + "codex", + &reference, + crate::platform::ProcessIdentity { + pid: 101, + birth_token, + }, + ); + } + terminal + .set_agent_session_ref( + "herdr:codex".into(), + "codex".into(), + Some(reference.clone()), + None, + ) + .unwrap(); + assert_eq!( + terminal + .pinned_agent_resume_recipe + .as_ref() + .unwrap() + .strategy, + "unavailable" + ); + let recipe = + PinnedAgentResumeRecipe::capture(crate::agents::bundled_profile("codex").unwrap()) + .unwrap(); + terminal.bind_agent_resume_process(resume_test_binding( + Some(recipe.clone()), + 101, + std::time::Instant::now(), + )); + let persisted = terminal.persisted_agent_session.as_ref().unwrap(); + assert_eq!( + pinned_plan( + &crate::agents::registry(), + persisted, + terminal.pinned_agent_resume_recipe.as_ref() + ) + .is_ok(), + proof_birth == Some(1) + ); + if proof_birth != Some(1) { + assert_eq!( + terminal + .pinned_agent_resume_recipe + .as_ref() + .unwrap() + .strategy, + "unavailable", + "mere later same-ID acquisition cannot authorize old metadata" + ); + terminal + .set_agent_session_ref( + "herdr:codex".into(), + "codex".into(), + Some(reference), + None, + ) + .unwrap(); + assert_eq!(terminal.pinned_agent_resume_recipe.as_ref(), Some(&recipe)); + } + } + } + + #[test] + fn ignored_early_report_cannot_attach_foreground_proof_to_old_session() { + use crate::agent_resume::*; + let mut terminal = test_terminal(); + let reference = terminal + .session_ref_from_bound_report("herdr:codex", "codex", Some("old-session".into()), None) + .unwrap(); + terminal + .set_agent_session_ref( + "herdr:codex".into(), + "codex".into(), + Some(reference.clone()), + Some(10), + ) + .unwrap(); + terminal.record_report_process_proof( + "codex", + &reference, + crate::platform::ProcessIdentity { + pid: 101, + birth_token: 1, + }, + ); + assert!(terminal + .set_agent_session_ref( + "herdr:codex".into(), + "codex".into(), + Some(reference), + Some(9) + ) + .is_none()); + terminal.bind_agent_resume_process(resume_test_binding( + PinnedAgentResumeRecipe::capture(crate::agents::bundled_profile("codex").unwrap()), + 101, + std::time::Instant::now(), + )); + assert_eq!( + terminal + .pinned_agent_resume_recipe + .as_ref() + .unwrap() + .strategy, + "unavailable" + ); + } + + #[test] + fn process_group_change_updates_foreground_tuple_without_refreshing_resume_recipe() { + use crate::agent_resume::*; + let now = std::time::Instant::now(); + let old = test_registry("codex", "old-cli", "subcommand", "resume"); + let new = test_registry("codex", "new-cli", "subcommand", "continue"); + let original = old + .profile_by_id("codex") + .and_then(PinnedAgentResumeRecipe::capture) + .unwrap(); + let mut terminal = test_terminal(); + terminal.bind_agent_resume_process(resume_test_binding(Some(original.clone()), 101, now)); + let mut regrouped = resume_test_binding( + new.profile_by_id("codex") + .and_then(PinnedAgentResumeRecipe::capture), + 101, + now + std::time::Duration::from_secs(1), + ); + regrouped.process.as_mut().unwrap().0 = 999; + terminal.bind_agent_resume_process(regrouped); + let retained = terminal.live_agent_resume_binding.as_ref().unwrap(); + assert_eq!(retained.recipe.as_ref(), Some(&original)); + assert_eq!(retained.process.as_ref().unwrap().0, 999); + } + + #[test] + fn report_reference_kind_uses_bound_capability_not_active_builtin_selection() { + use crate::agent_resume::*; + let mut recipe = + PinnedAgentResumeRecipe::capture(crate::agents::bundled_profile("codex").unwrap()) + .unwrap(); + recipe.accepted_references = vec![AgentSessionRefKind::Path]; + recipe.preferred_reference = AgentSessionRefKind::Path; + let mut terminal = test_terminal(); + terminal.bind_agent_resume_process(resume_test_binding( + Some(recipe), + 101, + std::time::Instant::now(), + )); + let path = std::env::current_dir() + .unwrap() + .join("native-session") + .display() + .to_string(); + let reference = terminal + .session_ref_from_bound_report( + "herdr:codex", + "codex", + Some("not-selected".into()), + Some(path.clone()), + ) + .unwrap(); + assert_eq!(reference, AgentSessionRef::path(path).unwrap()); + assert!(terminal + .session_ref_from_bound_report("herdr:codex", "codex", Some("id-only".into()), None) + .is_none()); + } + + #[test] + fn retained_canonical_identity_survives_membership_removal_without_promoting_custom_labels() { + let mut terminal = test_terminal(); + let retained = crate::detect::Agent::parse("removed-agent").unwrap(); + assert!(crate::agents::registry() + .profile_by_agent(retained) + .is_none()); + terminal.set_detected_state(Some(retained), crate::detect::AgentState::Idle); + terminal.restore_managed_agent("reviewer".into(), retained); + assert_eq!(terminal.effective_known_agent(), Some(retained)); + assert_eq!( + terminal.known_agent_for_label("removed-agent"), + Some(retained) + ); + assert_eq!( + terminal.known_agent_for_label("arbitrary-unregistered-label"), + None + ); + terminal.reconcile_agent_name_owner("removed-agent", None); + assert_eq!(terminal.managed_agent_kind(), Some(retained)); + terminal.release_agent_with_mutation("custom:removed", "removed-agent", None); + assert_eq!(terminal.detected_agent, Some(retained)); + assert_eq!(terminal.effective_known_agent(), Some(retained)); + } + + #[test] + fn resume_capability_follows_process_acquisition_not_report_time_or_reload() { + use crate::agent_resume::*; + let old = test_registry("codex", "old-cli", "separate_flag", "--old-session"); + let new = test_registry("codex", "new-cli", "subcommand", "continue"); + let recipe = |registry: &crate::agents::RegistrySnapshot| { + PinnedAgentResumeRecipe::capture(registry.profile_by_id("codex").unwrap()).unwrap() + }; + let old_recipe = recipe(&old); + let new_recipe = recipe(&new); + let now = std::time::Instant::now(); + let mut terminal = test_terminal(); + terminal.bind_agent_resume_process(resume_test_binding(Some(old_recipe.clone()), 101, now)); + terminal.set_detected_agent_process_at(crate::detect::Agent::Codex, now); + let reference = terminal + .session_ref_from_bound_report("herdr:codex", "codex", Some("session".into()), None) + .unwrap(); + terminal + .set_agent_session_ref("herdr:codex".into(), "codex".into(), Some(reference), None) + .unwrap(); + assert_eq!( + terminal.pinned_agent_resume_recipe.as_ref(), + Some(&old_recipe) + ); + let session = terminal.persisted_agent_session.as_ref().unwrap(); + assert!(pinned_plan(&new, session, terminal.pinned_agent_resume_recipe.as_ref()).is_err()); + // A refreshed observation of the same exact process cannot refresh its recipe. + terminal.bind_agent_resume_process(resume_test_binding( + Some(new_recipe.clone()), + 101, + now + std::time::Duration::from_secs(1), + )); + assert_eq!( + terminal + .live_agent_resume_binding + .as_ref() + .unwrap() + .recipe + .as_ref(), + Some(&old_recipe) + ); + // A verified same-ID replacement can acquire new instructions, without rewriting history yet. + terminal.bind_agent_resume_process(resume_test_binding( + Some(new_recipe.clone()), + 102, + now + std::time::Duration::from_secs(2), + )); + assert_eq!( + terminal.pinned_agent_resume_recipe.as_ref(), + Some(&old_recipe) + ); + terminal + .set_agent_session_ref( + "herdr:codex".into(), + "codex".into(), + AgentSessionRef::id("session"), + None, + ) + .unwrap(); + assert_eq!( + terminal.pinned_agent_resume_recipe.as_ref(), + Some(&new_recipe) + ); + } + + #[test] + fn acquired_no_resume_cannot_backfill_and_managed_admission_survives_reload() { + use crate::agent_resume::*; + let registry = test_registry("codex", "new-cli", "subcommand", "continue"); + let recipe = + PinnedAgentResumeRecipe::capture(registry.profile_by_id("codex").unwrap()).unwrap(); + let now = std::time::Instant::now(); + let mut terminal = test_terminal(); + terminal.bind_agent_resume_process(resume_test_binding(None, 101, now)); + terminal.bind_agent_resume_process(resume_test_binding( + Some(recipe.clone()), + 101, + now + std::time::Duration::from_secs(1), + )); + assert!(terminal + .session_ref_from_bound_report("herdr:codex", "codex", Some("session".into()), None) + .is_none()); + assert!(terminal + .set_agent_session_ref( + "herdr:codex".into(), + "codex".into(), + AgentSessionRef::id("session"), + None + ) + .is_none()); + assert!(terminal.persisted_agent_session.is_none()); + let original = + PinnedAgentResumeRecipe::capture(crate::agents::bundled_profile("codex").unwrap()) + .unwrap(); + terminal.admit_agent_resume_recipe( + crate::detect::Agent::Codex, + Some(original.clone()), + None, + now, + ); + terminal.bind_agent_resume_process(resume_test_binding( + Some(recipe.clone()), + 999, + now - std::time::Duration::from_secs(1), + )); + assert!(terminal + .live_agent_resume_binding + .as_ref() + .unwrap() + .process + .is_none()); - self.state = state; - Some(EffectiveStateChange { - previous_agent_label, - previous_known_agent, - previous_state, - previous_presentation, - agent_label, - known_agent, - state, - presentation, - }) + terminal.bind_agent_resume_process(resume_test_binding( + Some(recipe), + 102, + now + std::time::Duration::from_secs(2), + )); + assert_eq!( + terminal + .live_agent_resume_binding + .as_ref() + .unwrap() + .recipe + .as_ref(), + Some(&original) + ); } -} -pub(crate) fn stabilize_agent_detection(detection: crate::detect::AgentDetection) -> AgentState { - detection.state -} + #[test] + fn queued_acquisition_wins_over_later_report_and_unverified_reports_cannot_auto_resume() { + use crate::agent_resume::*; + let mut terminal = test_terminal(); + let acquired_at = std::time::Instant::now() - std::time::Duration::from_secs(1); + let reference = terminal + .session_ref_from_bound_report("herdr:codex", "codex", Some("session".into()), None) + .unwrap(); + terminal + .set_agent_session_ref("herdr:codex".into(), "codex".into(), Some(reference), None) + .unwrap(); + assert_eq!( + terminal + .pinned_agent_resume_recipe + .as_ref() + .unwrap() + .strategy, + "unavailable" + ); + let session = terminal.persisted_agent_session.as_ref().unwrap(); + assert!(pinned_plan( + &crate::agents::registry(), + session, + terminal.pinned_agent_resume_recipe.as_ref() + ) + .is_err()); + terminal.bind_agent_resume_process(resume_test_binding(None, 101, acquired_at)); + assert!(terminal + .live_agent_resume_binding + .as_ref() + .unwrap() + .recipe + .is_none()); + assert_eq!( + terminal + .pinned_agent_resume_recipe + .as_ref() + .unwrap() + .strategy, + "unavailable" + ); + assert_eq!( + terminal + .persisted_agent_session + .as_ref() + .unwrap() + .session_ref + .value, + "session" + ); + } -#[cfg(test)] -mod tests { use super::*; use crate::detect::AgentDetection; @@ -2237,6 +3657,7 @@ mod tests { Duration::from_millis(100), Duration::from_secs(1), ); + terminal.set_detected_agent_process_at(Agent::Pi, now); terminal.set_detected_state(Some(Agent::Pi), AgentState::Unknown); assert!(terminal.managed_agent_launch_pending()); @@ -2270,7 +3691,7 @@ mod tests { } #[test] - fn managed_agent_mismatch_and_timeout_release_name() { + fn managed_agent_wrong_agent_evidence_waits_for_timeout() { let now = Instant::now(); let mut mismatch = test_terminal(); mismatch.begin_managed_agent( @@ -2280,8 +3701,34 @@ mod tests { Duration::ZERO, Duration::from_secs(1), ); + let launch_session = crate::agent_resume::PersistedAgentSession { + source: "herdr:launch".into(), + agent: "pi".into(), + session_ref: crate::agent_resume::AgentSessionRef::id("pending-pi-session").unwrap(), + }; + mismatch.set_managed_agent_launch_session(launch_session.clone()); + mismatch.set_detected_agent_process_at(Agent::Codex, now); mismatch.set_detected_state(Some(Agent::Codex), AgentState::Idle); assert!(mismatch.reconcile_managed_agent_at(now, false)); + assert!(!mismatch.managed_agent_interactive_ready()); + assert_eq!(mismatch.agent_name.as_deref(), Some("reviewer")); + assert_eq!(mismatch.managed_agent_kind(), Some(Agent::Pi)); + let exited = mismatch.set_detected_state_with_screen_signals_at( + Some(Agent::Codex), + AgentState::Idle, + false, + false, + false, + true, + now + Duration::from_millis(1), + ); + assert!(!exited.agent_released); + mismatch.reconcile_managed_agent_at(now + Duration::from_millis(1), false); + assert_eq!(mismatch.agent_name.as_deref(), Some("reviewer")); + assert_eq!(mismatch.managed_agent_kind(), Some(Agent::Pi)); + assert_eq!(mismatch.managed_agent_launch_session, Some(launch_session)); + assert!(!mismatch.managed_agent_interactive_ready()); + assert!(mismatch.reconcile_managed_agent_at(now + Duration::from_secs(1), false)); assert_eq!(mismatch.agent_name, None); assert_eq!(mismatch.managed_agent_kind(), None); @@ -2304,12 +3751,267 @@ mod tests { assert!(timed_out.persisted_agent_session.is_none()); } + #[test] + fn queued_restore_has_no_deadline_or_synthetic_process_until_injection() { + let mut terminal = test_terminal(); + let session = crate::agent_resume::PersistedAgentSession { + source: "herdr:launch".into(), + agent: "opencode".into(), + session_ref: crate::agent_resume::AgentSessionRef::id("native-session").unwrap(), + }; + let recipe = crate::agent_resume::PinnedAgentResumeRecipe::capture( + crate::agents::bundled_profile("opencode").unwrap(), + ); + terminal.set_persisted_agent_session(session.clone()); + terminal.pinned_agent_resume_recipe = recipe.clone(); + terminal.pending_agent_resume_plan = Some(crate::agent_resume::AgentResumePlan { + resume_options: Vec::new(), + agent: "opencode".into(), + argv: vec![ + "opencode".into(), + "--session".into(), + "native-session".into(), + ], + dedupe_key: "native-session".into(), + strict_input_readiness: true, + }); + terminal.queue_managed_agent(Some("reviewer".into()), Agent::OpenCode, true); + + assert!(terminal.is_agent_terminal()); + assert!(terminal.managed_agent_launch_pending()); + assert!(!terminal.managed_agent_interactive_ready()); + assert_eq!(terminal.next_managed_agent_deadline(), None); + assert_eq!(terminal.detected_agent, None); + assert_eq!(terminal.state, AgentState::Unknown); + assert_eq!(terminal.agent_name.as_deref(), Some("reviewer")); + assert_eq!(terminal.managed_agent_kind(), Some(Agent::OpenCode)); + assert_eq!(terminal.persisted_agent_session.as_ref(), Some(&session)); + assert_eq!(terminal.pinned_agent_resume_recipe, recipe); + + let injected_at = Instant::now(); + terminal.mark_queued_agent_injected( + injected_at, + Duration::from_secs(3), + Duration::from_secs(30), + ); + terminal.pending_agent_resume_plan = None; + assert!(terminal.managed_agent_launch_pending()); + assert_eq!( + terminal.next_managed_agent_deadline(), + injected_at.checked_add(Duration::from_secs(30)) + ); + assert!(!terminal.managed_agent_interactive_ready()); + } + + fn begin_strict_opencode(terminal: &mut TerminalState, injected_at: Instant) { + terminal.begin_managed_agent_with_readiness( + Some("reviewer".into()), + Agent::OpenCode, + true, + injected_at, + Duration::from_secs(3), + Duration::from_secs(30), + ); + } + + #[test] + fn hook_idle_before_paint_does_not_activate_strict_startup_but_visible_idle_does() { + let injected_at = Instant::now(); + let mut terminal = test_terminal(); + begin_strict_opencode(&mut terminal, injected_at); + terminal.set_detected_agent_process_at(Agent::OpenCode, injected_at); + terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { + source: "herdr:opencode".into(), + agent: "opencode".into(), + session_ref: crate::agent_resume::AgentSessionRef::id("native-session").unwrap(), + }); + terminal.set_hook_authority( + "herdr:opencode".into(), + "opencode".into(), + AgentState::Idle, + None, + None, + ); + assert!(terminal.full_lifecycle_hook_authority_active()); + assert!(!terminal.reconcile_managed_agent_at(injected_at, false)); + assert!(terminal.managed_agent_launch_pending()); + + let painted_at = injected_at + Duration::from_millis(20); + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::OpenCode), + AgentState::Idle, + false, + true, + false, + false, + painted_at, + ); + assert_eq!( + terminal.state, + AgentState::Idle, + "hook remains state authority" + ); + assert!(terminal.reconcile_managed_agent_at(painted_at, false)); + assert!(terminal.managed_agent_interactive_ready()); + assert!(!terminal.screen_detection_required_for_managed_startup()); + } + + #[test] + fn strict_startup_rejects_preinjection_stale_and_wrong_agent_evidence() { + let injected_at = Instant::now(); + let old = injected_at - Duration::from_secs(1); + let mut terminal = test_terminal(); + terminal.queue_managed_agent(None, Agent::OpenCode, true); + terminal.set_detected_agent_process_at(Agent::OpenCode, old); + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::OpenCode), + AgentState::Idle, + false, + true, + false, + false, + old, + ); + terminal.mark_queued_agent_injected( + injected_at, + Duration::from_secs(3), + Duration::from_secs(30), + ); + terminal.set_detected_agent_process_at(Agent::OpenCode, old); + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::OpenCode), + AgentState::Idle, + false, + true, + false, + false, + old, + ); + assert!(!terminal.reconcile_managed_agent_at(injected_at, false)); + + let fresh = injected_at + Duration::from_millis(1); + terminal.set_detected_agent_process_at(Agent::Pi, fresh); + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), + AgentState::Idle, + false, + true, + false, + false, + fresh, + ); + assert!(!terminal.reconcile_managed_agent_at(fresh, false)); + assert!(terminal.managed_agent_launch_pending()); + } + + #[test] + fn strict_startup_requires_visible_idle_at_or_after_process_acquisition() { + let injected_at = Instant::now(); + let mut terminal = test_terminal(); + begin_strict_opencode(&mut terminal, injected_at); + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::OpenCode), + AgentState::Idle, + false, + true, + false, + false, + injected_at + Duration::from_millis(1), + ); + terminal + .set_detected_agent_process_at(Agent::OpenCode, injected_at + Duration::from_millis(2)); + assert!( + !terminal.reconcile_managed_agent_at(injected_at + Duration::from_millis(2), false,) + ); + + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::OpenCode), + AgentState::Idle, + false, + true, + false, + false, + injected_at + Duration::from_millis(3), + ); + assert!(terminal.reconcile_managed_agent_at(injected_at + Duration::from_millis(3), false,)); + assert!(terminal.managed_agent_interactive_ready()); + } + + #[test] + fn legacy_startup_keeps_process_idle_and_settle_compatibility() { + let injected_at = Instant::now(); + let mut terminal = test_terminal(); + terminal.begin_managed_agent_with_readiness( + Some("reviewer".into()), + Agent::Pi, + false, + injected_at, + Duration::from_secs(3), + Duration::from_secs(30), + ); + terminal.set_detected_agent_process_at(Agent::Pi, injected_at); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + assert!(!terminal.reconcile_managed_agent_at(injected_at + Duration::from_secs(2), false)); + assert!(terminal.reconcile_managed_agent_at(injected_at + Duration::from_secs(3), false)); + assert!(terminal.managed_agent_interactive_ready()); + } + + #[test] + fn strict_visible_blocker_holds_startup_until_a_later_visible_idle() { + let injected_at = Instant::now(); + let mut terminal = test_terminal(); + begin_strict_opencode(&mut terminal, injected_at); + terminal.set_detected_agent_process_at(Agent::OpenCode, injected_at); + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::OpenCode), + AgentState::Blocked, + true, + false, + false, + false, + injected_at + Duration::from_millis(1), + ); + assert!(terminal.reconcile_managed_agent_at(injected_at + Duration::from_millis(1), false)); + assert!(terminal.managed_agent_launch_pending()); + assert_eq!(terminal.next_managed_agent_deadline(), None); + terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { + source: "herdr:opencode".into(), + agent: "opencode".into(), + session_ref: crate::agent_resume::AgentSessionRef::id("native-session").unwrap(), + }); + terminal.set_hook_authority( + "herdr:opencode".into(), + "opencode".into(), + AgentState::Blocked, + None, + None, + ); + assert!(terminal.full_lifecycle_hook_authority_active()); + + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::OpenCode), + AgentState::Idle, + false, + true, + false, + false, + injected_at + Duration::from_millis(2), + ); + assert!(!terminal.reconcile_managed_agent_at(injected_at + Duration::from_millis(2), false)); + assert_eq!(terminal.state, AgentState::Blocked); + terminal.hook_authority.as_mut().unwrap().state = AgentState::Idle; + terminal.state = AgentState::Idle; + assert!(terminal.reconcile_managed_agent_at(injected_at + Duration::from_millis(3), false)); + assert!(terminal.managed_agent_interactive_ready()); + } + #[test] fn stabilization_uses_raw_policy_state() { let detection = AgentDetection { state: AgentState::Idle, skip_state_update: false, visible_idle: false, + screen_visible_idle: false, visible_blocker: false, visible_working: false, }; @@ -5014,7 +6716,16 @@ mod tests { Duration::ZERO, Duration::from_secs(1), ); - terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle); + terminal.set_detected_agent_process_at(Agent::OpenCode, now); + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::OpenCode), + AgentState::Idle, + false, + true, + false, + false, + now, + ); assert!(terminal.reconcile_managed_agent_at(now, false)); for session in ["opencode-old", "opencode-new"] { diff --git a/tests/cli/agent_transport.rs b/tests/cli/agent_transport.rs index a052f69720..9ffe788507 100644 --- a/tests/cli/agent_transport.rs +++ b/tests/cli/agent_transport.rs @@ -39,7 +39,8 @@ fn agent_start_waits_through_unknown_then_rejects_blocked() { "agent": { "pane_id": "w1:p1", "terminal_id": "term_1", - "name": "reviewer" + "name": "reviewer", + "agent": "opencode" }, "argv": ["opencode"] } @@ -116,6 +117,123 @@ fn agent_start_waits_through_unknown_then_rejects_blocked() { cleanup_test_base(&base); } +#[test] +fn agent_start_uses_novel_server_canonical_identity_not_alias_or_executable() { + for (detected_kind, expected_error) in [ + ("future-agent-42", None), + ("other-agent-42", Some("agent_kind_mismatch")), + ] { + let base = unique_test_dir(); + fs::create_dir_all(&base).unwrap(); + let socket_path = base.join("herdr.sock"); + let listener = UnixListener::bind(&socket_path).unwrap(); + + let server = thread::spawn(move || { + let (mut stream, line) = accept_fake_cli_operation(&listener); + let request: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(request["method"], "pane.get"); + writeln!( + stream, + "{}", + serde_json::json!({ + "id": request["id"], + "result": { + "type": "pane_info", + "pane": { "terminal_id": "term_1" } + } + }) + ) + .unwrap(); + stream.flush().unwrap(); + + let (mut stream, line) = accept_fake_cli_operation(&listener); + let request: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(request["method"], "agent.start"); + assert_eq!(request["params"]["kind"], " Remote Alias "); + writeln!( + stream, + "{}", + serde_json::json!({ + "id": request["id"], + "result": { + "type": "agent_started", + "agent": { + "pane_id": "w1:p1", + "terminal_id": "term_1", + "name": "reviewer", + "agent": "future-agent-42", + "agent_status": "unknown", + "launch_pending": true, + "interactive_ready": false + }, + "argv": ["shared-cli"] + } + }) + ) + .unwrap(); + stream.flush().unwrap(); + + let (mut stream, line) = accept_fake_cli_operation(&listener); + let request: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(request["method"], "agent.get"); + assert_eq!(request["params"]["target"], "reviewer"); + writeln!( + stream, + "{}", + serde_json::json!({ + "id": request["id"], + "result": { + "type": "agent_info", + "agent": { + "pane_id": "w1:p1", + "terminal_id": "term_1", + "name": "reviewer", + "agent": detected_kind, + "agent_status": "idle", + "launch_pending": false, + "interactive_ready": true + } + } + }) + ) + .unwrap(); + stream.flush().unwrap(); + }); + + let started = run_cli( + &socket_path, + &[ + "agent", + "start", + "reviewer", + "--kind", + " Remote Alias ", + "--pane", + "w1:p1", + ], + ); + if let Some(expected_error) = expected_error { + assert_eq!(started.status.code(), Some(1)); + let error: serde_json::Value = serde_json::from_slice(&started.stderr).unwrap(); + assert_eq!(error["error"]["code"], expected_error); + } else { + assert!( + started.status.success(), + "{}", + String::from_utf8_lossy(&started.stderr) + ); + let response: serde_json::Value = serde_json::from_slice(&started.stdout).unwrap(); + assert_eq!(response["result"]["agent"]["agent"], "future-agent-42"); + assert_eq!( + response["result"]["argv"], + serde_json::json!(["shared-cli"]) + ); + } + server.join().unwrap(); + cleanup_test_base(&base); + } +} + #[test] fn agent_start_does_not_retry_after_the_target_terminal_changes() { let base = unique_test_dir(); diff --git a/tests/cli/agents.rs b/tests/cli/agents.rs index cc5af0613e..0442960997 100644 --- a/tests/cli/agents.rs +++ b/tests/cli/agents.rs @@ -621,7 +621,7 @@ fn agent_start_timeout_releases_the_name_for_reuse() { } #[test] -fn agent_start_reports_detected_kind_mismatch_before_released_name() { +fn agent_start_reports_detected_kind_mismatch_but_preserves_name_until_timeout() { use std::os::unix::fs::PermissionsExt; let base = unique_test_dir(); @@ -691,8 +691,18 @@ fn agent_start_reports_detected_kind_mismatch_before_released_name() { ) .status .success()); - let reused = run_cli(&socket_path, &["agent", "rename", &reuse_pane_id, "worker"]); - assert!(reused.status.success()); + let still_reserved = run_cli(&socket_path, &["agent", "rename", &reuse_pane_id, "worker"]); + assert_eq!(still_reserved.status.code(), Some(1)); + let reserved_error: serde_json::Value = serde_json::from_slice(&still_reserved.stderr).unwrap(); + assert_eq!(reserved_error["error"]["code"], "agent_name_taken"); + + assert!(wait_until( + Duration::from_secs(7), + Duration::from_millis(100), + || run_cli(&socket_path, &["agent", "rename", &reuse_pane_id, "worker"]) + .status + .success() + )); cleanup_spawned_herdr(herdr, base); } diff --git a/tests/cli/harness.rs b/tests/cli/harness.rs index 91dab149a2..c506e532d6 100644 --- a/tests/cli/harness.rs +++ b/tests/cli/harness.rs @@ -164,6 +164,15 @@ pub(super) fn spawn_named_server( config_home: &Path, runtime_dir: &Path, session: &str, +) -> SpawnedServerProcess { + spawn_named_server_with_home(config_home, runtime_dir, session, None) +} + +pub(super) fn spawn_named_server_with_home( + config_home: &Path, + runtime_dir: &Path, + session: &str, + home: Option<&Path>, ) -> SpawnedServerProcess { fs::create_dir_all(config_home.join(app_dir_name())).unwrap(); fs::create_dir_all(runtime_dir).unwrap(); @@ -186,6 +195,12 @@ pub(super) fn spawn_named_server( .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()); + if let Some(home) = home { + command + .env("HOME", home) + .env("PI_CODING_AGENT_DIR", home.join(".pi/agent")); + command.env_remove("HERDR_AGENT_REGISTRY_SOURCE"); + } let child = command.spawn().unwrap(); register_spawned_herdr_pid(Some(child.id())); SpawnedServerProcess { child } @@ -230,6 +245,7 @@ pub(super) fn run_named_cli_with_env_and_socket_override( .env("XDG_CONFIG_HOME", config_home) .env("XDG_RUNTIME_DIR", runtime_dir) .env_remove("HERDR_CLIENT_SOCKET_PATH") + .env_remove("HERDR_AGENT_REGISTRY_SOURCE") .env_remove("HERDR_ENV"); for (key, value) in envs { command.env(key, value); diff --git a/tests/cli/hooks.rs b/tests/cli/hooks.rs index de2bb271b9..07c18fb939 100644 --- a/tests/cli/hooks.rs +++ b/tests/cli/hooks.rs @@ -2,7 +2,7 @@ use super::harness::*; fn run_claude_hook(action: &str, hook_input: &str) -> Option { run_shell_hook( - "src/integration/assets/claude/herdr-agent-state.sh", + "vendor/agent-registry/agents/claude/assets/herdr-agent-state.sh", &[action], hook_input, ) @@ -10,7 +10,7 @@ fn run_claude_hook(action: &str, hook_input: &str) -> Option fn run_codex_hook(action: &str, hook_input: &str) -> Option { run_shell_hook( - "src/integration/assets/codex/herdr-agent-state.sh", + "vendor/agent-registry/agents/codex/assets/herdr-agent-state.sh", &[action], hook_input, ) @@ -18,7 +18,7 @@ fn run_codex_hook(action: &str, hook_input: &str) -> Option { fn run_copilot_hook(hook_input: &str) -> Option { run_shell_hook( - "src/integration/assets/copilot/herdr-agent-state.sh", + "vendor/agent-registry/agents/copilot/assets/herdr-agent-state.sh", &[], hook_input, ) @@ -30,7 +30,7 @@ fn run_devin_hook( envs: &[(&str, &str)], ) -> Option { run_shell_hook_with_env( - "src/integration/assets/devin/herdr-agent-state.sh", + "vendor/agent-registry/agents/devin/assets/herdr-agent-state.sh", &[action], hook_input, envs, @@ -167,7 +167,7 @@ fn claude_hook_ignores_cursor_compatibility_payloads() { for cursor_version in ["2026.08.11-e8db854", ""] { assert!(run_shell_hook_with_env( - "src/integration/assets/claude/herdr-agent-state.sh", + "vendor/agent-registry/agents/claude/assets/herdr-agent-state.sh", &["session"], r#"{"hook_event_name":"SessionStart","session_id":"cursor-session"}"#, &[("CURSOR_VERSION", cursor_version)], @@ -189,7 +189,7 @@ fn codex_hook_reports_persisted_root_session_and_ignores_ephemeral_or_nested_ses assert!(request["params"].get("state").is_none()); let matching_request = run_shell_hook_with_env( - "src/integration/assets/codex/herdr-agent-state.sh", + "vendor/agent-registry/agents/codex/assets/herdr-agent-state.sh", &["session"], r#"{"hook_event_name":"SessionStart","session_id":"codex-session","transcript_path":"/tmp/codex-session.jsonl"}"#, &[("CODEX_THREAD_ID", "codex-session")], @@ -207,7 +207,7 @@ fn codex_hook_reports_persisted_root_session_and_ignores_ephemeral_or_nested_ses .is_none()); assert!(run_shell_hook_with_env( - "src/integration/assets/codex/herdr-agent-state.sh", + "vendor/agent-registry/agents/codex/assets/herdr-agent-state.sh", &["session"], r#"{"hook_event_name":"SessionStart","session_id":"nested-session","transcript_path":"/tmp/nested-session.jsonl"}"#, &[("CODEX_THREAD_ID", "parent-session")], diff --git a/tests/cli/sessions.rs b/tests/cli/sessions.rs index 5d87f73b9f..c58e605179 100644 --- a/tests/cli/sessions.rs +++ b/tests/cli/sessions.rs @@ -311,6 +311,109 @@ fn integration_commands_run_locally_when_server_is_missing() { cleanup_test_base(&base); } +#[test] +fn named_registry_update_supplies_explicit_api_and_offline_cli_integration_installs() { + let base = unique_test_dir(); + let home = base.join("home"); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let agent_dir = home.join(".pi/agent"); + let installed = agent_dir.join("extensions/herdr-agent-state.ts"); + fs::create_dir_all(installed.parent().unwrap()).unwrap(); + let source = base.join("registry"); + let package = source.join("agents/pi"); + fs::create_dir_all(package.join("assets")).unwrap(); + let vendored = Path::new(concat!( + env!("CARGO_MANIFEST_DIR"), + "/vendor/agent-registry/agents/pi" + )); + for name in [ + "agent.toml", + "process.toml", + "detection.toml", + "resume.toml", + "integration.toml", + "assets/herdr-agent-state.ts", + ] { + fs::copy(vendored.join(name), package.join(name)).unwrap(); + } + let old_asset = fs::read_to_string(vendored.join("assets/herdr-agent-state.ts")).unwrap(); + fs::write(&installed, &old_asset).unwrap(); + let mut metadata: toml::Value = + toml::from_str(&fs::read_to_string(package.join("integration.toml")).unwrap()).unwrap(); + let old_version = metadata["versions"]["unix"].as_integer().unwrap(); + assert_eq!( + metadata["versions"]["windows"].as_integer(), + Some(old_version) + ); + metadata["versions"]["unix"] = (old_version + 1).into(); + metadata["versions"]["windows"] = (old_version + 1).into(); + fs::write( + package.join("integration.toml"), + toml::to_string(&metadata).unwrap(), + ) + .unwrap(); + let new_asset = old_asset.replace( + &format!("HERDR_INTEGRATION_VERSION={old_version}"), + &format!("HERDR_INTEGRATION_VERSION={}", old_version + 1), + ); + fs::write(package.join("assets/herdr-agent-state.ts"), &new_asset).unwrap(); + + let session = "integration-assets"; + let socket = named_session_socket(&config_home, session); + let server = spawn_named_server_with_home(&config_home, &runtime_dir, session, Some(&home)); + wait_for_socket(&socket, Duration::from_secs(10)); + let run = |args: &[&str]| { + let output = run_named_cli_with_env( + &config_home, + &runtime_dir, + args, + &[("HOME", &home), ("PI_CODING_AGENT_DIR", &agent_dir)], + ); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + output + }; + run(&["--session", session, "workspace", "list"]); + run(&[ + "--session", + session, + "registry", + "reload", + source.to_str().unwrap(), + ]); + assert_eq!( + fs::read_to_string(&installed).unwrap(), + old_asset, + "reload must not install executable files" + ); + let status = run(&["--session", session, "integration", "status"]); + assert!(String::from_utf8_lossy(&status.stdout).contains("pi: outdated")); + let applied = send_request( + &socket, + r#"{"id":"install","method":"integration.install","params":{"target":"pi"}}"#, + ); + assert!(applied.get("error").is_none(), "{applied}"); + assert_eq!(fs::read_to_string(&installed).unwrap(), new_asset); + let status = run(&["--session", session, "integration", "status"]); + assert!(String::from_utf8_lossy(&status.stdout) + .contains(&format!("pi: current (v{})", old_version + 1))); + drop(server); + fs::remove_dir_all(&source).unwrap(); + fs::write(&installed, &old_asset).unwrap(); + run(&["--session", session, "integration", "install", "pi"]); + assert_eq!( + fs::read_to_string(&installed).unwrap(), + new_asset, + "fresh offline CLI must use the selected session's saved registry" + ); + cleanup_test_base(&base); +} + #[test] fn integration_status_outdated_only_prints_action_for_legacy_install() { let base = unique_test_dir(); diff --git a/vendor/agent-registry/agents/agy/agent.toml b/vendor/agent-registry/agents/agy/agent.toml new file mode 100644 index 0000000000..f882990e4e --- /dev/null +++ b/vendor/agent-registry/agents/agy/agent.toml @@ -0,0 +1,13 @@ +schema = 1 +id = "agy" +name = "agy" +aliases = ["antigravity", "antigravity-cli"] +startable = true + +[launch] +unix = "agy" +windows = "agy" + +[sound] +key = "agy" +default = "default" diff --git a/src/integration/assets/antigravity_cli/herdr-agent-state.ps1 b/vendor/agent-registry/agents/agy/assets/herdr-agent-state.ps1 similarity index 100% rename from src/integration/assets/antigravity_cli/herdr-agent-state.ps1 rename to vendor/agent-registry/agents/agy/assets/herdr-agent-state.ps1 diff --git a/src/integration/assets/antigravity_cli/herdr-agent-state.sh b/vendor/agent-registry/agents/agy/assets/herdr-agent-state.sh similarity index 100% rename from src/integration/assets/antigravity_cli/herdr-agent-state.sh rename to vendor/agent-registry/agents/agy/assets/herdr-agent-state.sh diff --git a/src/detect/manifests/antigravity.toml b/vendor/agent-registry/agents/agy/detection.toml similarity index 100% rename from src/detect/manifests/antigravity.toml rename to vendor/agent-registry/agents/agy/detection.toml diff --git a/vendor/agent-registry/agents/agy/integration.toml b/vendor/agent-registry/agents/agy/integration.toml new file mode 100644 index 0000000000..0b2f094a42 --- /dev/null +++ b/vendor/agent-registry/agents/agy/integration.toml @@ -0,0 +1,26 @@ +cli_name = "antigravity-cli" +aliases = ["antigravity_cli"] + +[commands] +unix = ["agy"] +windows = ["agy"] + +[supported] +unix = true +windows = true + +[versions] +unix = 3 +windows = 3 + +[[assets]] +path = "assets/herdr-agent-state.ps1" +platform = "windows" +role = "reporter" +install_name = "herdr-agent-state.ps1" + +[[assets]] +path = "assets/herdr-agent-state.sh" +platform = "unix" +role = "reporter" +install_name = "herdr-agent-state.sh" diff --git a/vendor/agent-registry/agents/agy/process.toml b/vendor/agent-registry/agents/agy/process.toml new file mode 100644 index 0000000000..385dd8c5c0 --- /dev/null +++ b/vendor/agent-registry/agents/agy/process.toml @@ -0,0 +1 @@ +names = ["agy", "antigravity", "antigravity-cli"] diff --git a/vendor/agent-registry/agents/agy/resume.toml b/vendor/agent-registry/agents/agy/resume.toml new file mode 100644 index 0000000000..795fa60a4d --- /dev/null +++ b/vendor/agent-registry/agents/agy/resume.toml @@ -0,0 +1,4 @@ +accepted_references = ["id"] +preferred_reference = "id" +strategy = "separate_flag" +token = "--conversation" diff --git a/vendor/agent-registry/agents/amp/agent.toml b/vendor/agent-registry/agents/amp/agent.toml new file mode 100644 index 0000000000..2bd3e96314 --- /dev/null +++ b/vendor/agent-registry/agents/amp/agent.toml @@ -0,0 +1,13 @@ +schema = 1 +id = "amp" +name = "amp" +aliases = ["amp-local"] +startable = true + +[launch] +unix = "amp" +windows = "amp" + +[sound] +key = "amp" +default = "default" diff --git a/src/detect/manifests/amp.toml b/vendor/agent-registry/agents/amp/detection.toml similarity index 100% rename from src/detect/manifests/amp.toml rename to vendor/agent-registry/agents/amp/detection.toml diff --git a/vendor/agent-registry/agents/amp/process.toml b/vendor/agent-registry/agents/amp/process.toml new file mode 100644 index 0000000000..99bb98d955 --- /dev/null +++ b/vendor/agent-registry/agents/amp/process.toml @@ -0,0 +1 @@ +names = ["amp", "amp-local"] diff --git a/vendor/agent-registry/agents/claude/agent.toml b/vendor/agent-registry/agents/claude/agent.toml new file mode 100644 index 0000000000..ad8bfbb0db --- /dev/null +++ b/vendor/agent-registry/agents/claude/agent.toml @@ -0,0 +1,13 @@ +schema = 1 +id = "claude" +name = "claude" +aliases = ["claude-code"] +startable = true + +[launch] +unix = "claude" +windows = "claude" + +[sound] +key = "claude" +default = "default" diff --git a/src/integration/assets/claude/herdr-agent-state.ps1 b/vendor/agent-registry/agents/claude/assets/herdr-agent-state.ps1 similarity index 100% rename from src/integration/assets/claude/herdr-agent-state.ps1 rename to vendor/agent-registry/agents/claude/assets/herdr-agent-state.ps1 diff --git a/src/integration/assets/claude/herdr-agent-state.sh b/vendor/agent-registry/agents/claude/assets/herdr-agent-state.sh similarity index 100% rename from src/integration/assets/claude/herdr-agent-state.sh rename to vendor/agent-registry/agents/claude/assets/herdr-agent-state.sh diff --git a/src/detect/manifests/claude.toml b/vendor/agent-registry/agents/claude/detection.toml similarity index 100% rename from src/detect/manifests/claude.toml rename to vendor/agent-registry/agents/claude/detection.toml diff --git a/vendor/agent-registry/agents/claude/integration.toml b/vendor/agent-registry/agents/claude/integration.toml new file mode 100644 index 0000000000..54557ff70b --- /dev/null +++ b/vendor/agent-registry/agents/claude/integration.toml @@ -0,0 +1,26 @@ +cli_name = "claude" +aliases = [] + +[commands] +unix = ["claude"] +windows = ["claude"] + +[supported] +unix = true +windows = true + +[versions] +unix = 9 +windows = 9 + +[[assets]] +path = "assets/herdr-agent-state.ps1" +platform = "windows" +role = "reporter" +install_name = "herdr-agent-state.ps1" + +[[assets]] +path = "assets/herdr-agent-state.sh" +platform = "unix" +role = "reporter" +install_name = "herdr-agent-state.sh" diff --git a/vendor/agent-registry/agents/claude/process.toml b/vendor/agent-registry/agents/claude/process.toml new file mode 100644 index 0000000000..92977403ac --- /dev/null +++ b/vendor/agent-registry/agents/claude/process.toml @@ -0,0 +1 @@ +names = ["claude", "claude-code"] diff --git a/vendor/agent-registry/agents/claude/resume.toml b/vendor/agent-registry/agents/claude/resume.toml new file mode 100644 index 0000000000..5b74d10a6d --- /dev/null +++ b/vendor/agent-registry/agents/claude/resume.toml @@ -0,0 +1,18 @@ +accepted_references = ["id"] +preferred_reference = "id" +strategy = "separate_flag" +token = "--resume" + +[resume_options] +flags = [ + "--dangerously-skip-permissions", + "--allow-dangerously-skip-permissions", + "--bare", + "--safe-mode", + "--chrome", + "--no-chrome", + "--disable-slash-commands", + "--ide", + "--verbose", +] +options = ["--agent", "--autocompact", "--effort", "--model", "--name", "--permission-mode"] diff --git a/vendor/agent-registry/agents/cline/agent.toml b/vendor/agent-registry/agents/cline/agent.toml new file mode 100644 index 0000000000..02320cca51 --- /dev/null +++ b/vendor/agent-registry/agents/cline/agent.toml @@ -0,0 +1,13 @@ +schema = 1 +id = "cline" +name = "cline" +aliases = [] +startable = true + +[launch] +unix = "cline" +windows = "cline" + +[sound] +key = "cline" +default = "default" diff --git a/src/detect/manifests/cline.toml b/vendor/agent-registry/agents/cline/detection.toml similarity index 100% rename from src/detect/manifests/cline.toml rename to vendor/agent-registry/agents/cline/detection.toml diff --git a/vendor/agent-registry/agents/cline/process.toml b/vendor/agent-registry/agents/cline/process.toml new file mode 100644 index 0000000000..35617939ce --- /dev/null +++ b/vendor/agent-registry/agents/cline/process.toml @@ -0,0 +1,2 @@ +names = ["cline", ".cline"] +secondary_runtime_argv_fallback = true diff --git a/vendor/agent-registry/agents/codex/agent.toml b/vendor/agent-registry/agents/codex/agent.toml new file mode 100644 index 0000000000..828d86337c --- /dev/null +++ b/vendor/agent-registry/agents/codex/agent.toml @@ -0,0 +1,13 @@ +schema = 1 +id = "codex" +name = "codex" +aliases = [] +startable = true + +[launch] +unix = "codex" +windows = "codex" + +[sound] +key = "codex" +default = "default" diff --git a/src/integration/assets/codex/herdr-agent-state.ps1 b/vendor/agent-registry/agents/codex/assets/herdr-agent-state.ps1 similarity index 100% rename from src/integration/assets/codex/herdr-agent-state.ps1 rename to vendor/agent-registry/agents/codex/assets/herdr-agent-state.ps1 diff --git a/src/integration/assets/codex/herdr-agent-state.sh b/vendor/agent-registry/agents/codex/assets/herdr-agent-state.sh similarity index 100% rename from src/integration/assets/codex/herdr-agent-state.sh rename to vendor/agent-registry/agents/codex/assets/herdr-agent-state.sh diff --git a/src/detect/manifests/codex.toml b/vendor/agent-registry/agents/codex/detection.toml similarity index 100% rename from src/detect/manifests/codex.toml rename to vendor/agent-registry/agents/codex/detection.toml diff --git a/vendor/agent-registry/agents/codex/integration.toml b/vendor/agent-registry/agents/codex/integration.toml new file mode 100644 index 0000000000..b2fce2b402 --- /dev/null +++ b/vendor/agent-registry/agents/codex/integration.toml @@ -0,0 +1,26 @@ +cli_name = "codex" +aliases = [] + +[commands] +unix = ["codex"] +windows = ["codex"] + +[supported] +unix = true +windows = true + +[versions] +unix = 8 +windows = 8 + +[[assets]] +path = "assets/herdr-agent-state.ps1" +platform = "windows" +role = "reporter" +install_name = "herdr-agent-state.ps1" + +[[assets]] +path = "assets/herdr-agent-state.sh" +platform = "unix" +role = "reporter" +install_name = "herdr-agent-state.sh" diff --git a/vendor/agent-registry/agents/codex/process.toml b/vendor/agent-registry/agents/codex/process.toml new file mode 100644 index 0000000000..05fdf85934 --- /dev/null +++ b/vendor/agent-registry/agents/codex/process.toml @@ -0,0 +1 @@ +names = ["codex"] diff --git a/vendor/agent-registry/agents/codex/resume.toml b/vendor/agent-registry/agents/codex/resume.toml new file mode 100644 index 0000000000..1be5b19216 --- /dev/null +++ b/vendor/agent-registry/agents/codex/resume.toml @@ -0,0 +1,16 @@ +accepted_references = ["id"] +preferred_reference = "id" +strategy = "subcommand" +token = "resume" + +[resume_options] +flags = [ + "--approve-for-me", + "--dangerously-bypass-approvals-and-sandbox", + "--dangerously-bypass-hook-trust", + "--oss", + "--search", + "--strict-config", + "--no-alt-screen", +] +options = ["-m", "--model", "-s", "--sandbox", "-a", "--ask-for-approval", "--local-provider"] diff --git a/vendor/agent-registry/agents/copilot/agent.toml b/vendor/agent-registry/agents/copilot/agent.toml new file mode 100644 index 0000000000..bec3d481ea --- /dev/null +++ b/vendor/agent-registry/agents/copilot/agent.toml @@ -0,0 +1,13 @@ +schema = 1 +id = "copilot" +name = "copilot" +aliases = ["github-copilot", "ghcs"] +startable = true + +[launch] +unix = "copilot" +windows = "copilot" + +[sound] +key = "github_copilot" +default = "default" diff --git a/src/integration/assets/copilot/herdr-agent-state.ps1 b/vendor/agent-registry/agents/copilot/assets/herdr-agent-state.ps1 similarity index 100% rename from src/integration/assets/copilot/herdr-agent-state.ps1 rename to vendor/agent-registry/agents/copilot/assets/herdr-agent-state.ps1 diff --git a/src/integration/assets/copilot/herdr-agent-state.sh b/vendor/agent-registry/agents/copilot/assets/herdr-agent-state.sh similarity index 100% rename from src/integration/assets/copilot/herdr-agent-state.sh rename to vendor/agent-registry/agents/copilot/assets/herdr-agent-state.sh diff --git a/src/detect/manifests/github-copilot.toml b/vendor/agent-registry/agents/copilot/detection.toml similarity index 100% rename from src/detect/manifests/github-copilot.toml rename to vendor/agent-registry/agents/copilot/detection.toml diff --git a/vendor/agent-registry/agents/copilot/integration.toml b/vendor/agent-registry/agents/copilot/integration.toml new file mode 100644 index 0000000000..5395e931ca --- /dev/null +++ b/vendor/agent-registry/agents/copilot/integration.toml @@ -0,0 +1,26 @@ +cli_name = "copilot" +aliases = [] + +[commands] +unix = ["copilot"] +windows = ["copilot"] + +[supported] +unix = true +windows = true + +[versions] +unix = 3 +windows = 3 + +[[assets]] +path = "assets/herdr-agent-state.ps1" +platform = "windows" +role = "reporter" +install_name = "herdr-agent-state.ps1" + +[[assets]] +path = "assets/herdr-agent-state.sh" +platform = "unix" +role = "reporter" +install_name = "herdr-agent-state.sh" diff --git a/vendor/agent-registry/agents/copilot/process.toml b/vendor/agent-registry/agents/copilot/process.toml new file mode 100644 index 0000000000..c0012e2f6b --- /dev/null +++ b/vendor/agent-registry/agents/copilot/process.toml @@ -0,0 +1 @@ +names = ["copilot", "github-copilot", "ghcs"] diff --git a/vendor/agent-registry/agents/copilot/resume.toml b/vendor/agent-registry/agents/copilot/resume.toml new file mode 100644 index 0000000000..a2852c1eef --- /dev/null +++ b/vendor/agent-registry/agents/copilot/resume.toml @@ -0,0 +1,22 @@ +accepted_references = ["id"] +preferred_reference = "id" +strategy = "joined_flag" +token = "--resume=" + +[resume_options] +flags = [ + "--allow-all", + "--allow-all-tools", + "--allow-all-urls", + "--autopilot", + "--plan", + "--disallow-temp-dir", + "--enable-memory", + "--enable-reasoning-summaries", + "--experimental", + "--no-experimental", + "--no-ask-user", + "--no-custom-instructions", + "--yolo", +] +options = ["--agent", "--effort", "--reasoning-effort", "--max-ai-credits", "--max-autopilot-continues", "--mode", "--model", "--stream"] diff --git a/vendor/agent-registry/agents/cursor/agent.toml b/vendor/agent-registry/agents/cursor/agent.toml new file mode 100644 index 0000000000..d8fe27b9c2 --- /dev/null +++ b/vendor/agent-registry/agents/cursor/agent.toml @@ -0,0 +1,13 @@ +schema = 1 +id = "cursor" +name = "cursor" +aliases = ["cursor-agent"] +startable = true + +[launch] +unix = "cursor-agent" +windows = "cursor-agent.cmd" + +[sound] +key = "cursor" +default = "default" diff --git a/src/integration/assets/cursor/herdr-agent-state.ps1 b/vendor/agent-registry/agents/cursor/assets/herdr-agent-state.ps1 similarity index 100% rename from src/integration/assets/cursor/herdr-agent-state.ps1 rename to vendor/agent-registry/agents/cursor/assets/herdr-agent-state.ps1 diff --git a/src/integration/assets/cursor/herdr-agent-state.sh b/vendor/agent-registry/agents/cursor/assets/herdr-agent-state.sh similarity index 100% rename from src/integration/assets/cursor/herdr-agent-state.sh rename to vendor/agent-registry/agents/cursor/assets/herdr-agent-state.sh diff --git a/src/detect/manifests/cursor.toml b/vendor/agent-registry/agents/cursor/detection.toml similarity index 100% rename from src/detect/manifests/cursor.toml rename to vendor/agent-registry/agents/cursor/detection.toml diff --git a/vendor/agent-registry/agents/cursor/integration.toml b/vendor/agent-registry/agents/cursor/integration.toml new file mode 100644 index 0000000000..2ef8e96eef --- /dev/null +++ b/vendor/agent-registry/agents/cursor/integration.toml @@ -0,0 +1,26 @@ +cli_name = "cursor" +aliases = [] + +[commands] +unix = ["cursor-agent"] +windows = ["cursor-agent"] + +[supported] +unix = true +windows = true + +[versions] +unix = 1 +windows = 1 + +[[assets]] +path = "assets/herdr-agent-state.ps1" +platform = "windows" +role = "reporter" +install_name = "herdr-agent-state.ps1" + +[[assets]] +path = "assets/herdr-agent-state.sh" +platform = "unix" +role = "reporter" +install_name = "herdr-agent-state.sh" diff --git a/vendor/agent-registry/agents/cursor/process.toml b/vendor/agent-registry/agents/cursor/process.toml new file mode 100644 index 0000000000..7cc17d7a5f --- /dev/null +++ b/vendor/agent-registry/agents/cursor/process.toml @@ -0,0 +1,7 @@ +names = ["cursor", "cursor-agent"] + +[bundled_node] +runtime_basename = "node.exe" +entrypoint_basename = "index.js" +package_directory = "cursor-agent" +versions_directory = "versions" diff --git a/vendor/agent-registry/agents/cursor/resume.toml b/vendor/agent-registry/agents/cursor/resume.toml new file mode 100644 index 0000000000..60c4631684 --- /dev/null +++ b/vendor/agent-registry/agents/cursor/resume.toml @@ -0,0 +1,8 @@ +accepted_references = ["id"] +preferred_reference = "id" +strategy = "separate_flag" +token = "--resume" + +[resume_options] +flags = ["-f", "--force", "--yolo", "--auto-review", "--trust"] +options = ["--model", "--mode", "--sandbox"] diff --git a/vendor/agent-registry/agents/devin/agent.toml b/vendor/agent-registry/agents/devin/agent.toml new file mode 100644 index 0000000000..55626c9ac3 --- /dev/null +++ b/vendor/agent-registry/agents/devin/agent.toml @@ -0,0 +1,13 @@ +schema = 1 +id = "devin" +name = "devin" +aliases = ["devin-cli", "devin cli"] +startable = true + +[launch] +unix = "devin" +windows = "devin" + +[sound] +key = "devin" +default = "default" diff --git a/src/integration/assets/devin/herdr-agent-state.ps1 b/vendor/agent-registry/agents/devin/assets/herdr-agent-state.ps1 similarity index 100% rename from src/integration/assets/devin/herdr-agent-state.ps1 rename to vendor/agent-registry/agents/devin/assets/herdr-agent-state.ps1 diff --git a/src/integration/assets/devin/herdr-agent-state.sh b/vendor/agent-registry/agents/devin/assets/herdr-agent-state.sh similarity index 100% rename from src/integration/assets/devin/herdr-agent-state.sh rename to vendor/agent-registry/agents/devin/assets/herdr-agent-state.sh diff --git a/src/detect/manifests/devin.toml b/vendor/agent-registry/agents/devin/detection.toml similarity index 100% rename from src/detect/manifests/devin.toml rename to vendor/agent-registry/agents/devin/detection.toml diff --git a/vendor/agent-registry/agents/devin/integration.toml b/vendor/agent-registry/agents/devin/integration.toml new file mode 100644 index 0000000000..81e7a2ff14 --- /dev/null +++ b/vendor/agent-registry/agents/devin/integration.toml @@ -0,0 +1,26 @@ +cli_name = "devin" +aliases = [] + +[commands] +unix = ["devin"] +windows = ["devin"] + +[supported] +unix = true +windows = true + +[versions] +unix = 2 +windows = 2 + +[[assets]] +path = "assets/herdr-agent-state.ps1" +platform = "windows" +role = "reporter" +install_name = "herdr-agent-state.ps1" + +[[assets]] +path = "assets/herdr-agent-state.sh" +platform = "unix" +role = "reporter" +install_name = "herdr-agent-state.sh" diff --git a/vendor/agent-registry/agents/devin/process.toml b/vendor/agent-registry/agents/devin/process.toml new file mode 100644 index 0000000000..edd5148217 --- /dev/null +++ b/vendor/agent-registry/agents/devin/process.toml @@ -0,0 +1 @@ +names = ["devin", "devin-cli", "devin cli"] diff --git a/vendor/agent-registry/agents/devin/resume.toml b/vendor/agent-registry/agents/devin/resume.toml new file mode 100644 index 0000000000..e4b5668cc5 --- /dev/null +++ b/vendor/agent-registry/agents/devin/resume.toml @@ -0,0 +1,4 @@ +accepted_references = ["id"] +preferred_reference = "id" +strategy = "separate_flag" +token = "--resume" diff --git a/vendor/agent-registry/agents/droid/agent.toml b/vendor/agent-registry/agents/droid/agent.toml new file mode 100644 index 0000000000..8b5283cb61 --- /dev/null +++ b/vendor/agent-registry/agents/droid/agent.toml @@ -0,0 +1,13 @@ +schema = 1 +id = "droid" +name = "droid" +aliases = [] +startable = true + +[launch] +unix = "droid" +windows = "droid" + +[sound] +key = "droid" +default = "off" diff --git a/src/integration/assets/droid/herdr-agent-state.ps1 b/vendor/agent-registry/agents/droid/assets/herdr-agent-state.ps1 similarity index 100% rename from src/integration/assets/droid/herdr-agent-state.ps1 rename to vendor/agent-registry/agents/droid/assets/herdr-agent-state.ps1 diff --git a/src/integration/assets/droid/herdr-agent-state.sh b/vendor/agent-registry/agents/droid/assets/herdr-agent-state.sh similarity index 100% rename from src/integration/assets/droid/herdr-agent-state.sh rename to vendor/agent-registry/agents/droid/assets/herdr-agent-state.sh diff --git a/src/detect/manifests/droid.toml b/vendor/agent-registry/agents/droid/detection.toml similarity index 100% rename from src/detect/manifests/droid.toml rename to vendor/agent-registry/agents/droid/detection.toml diff --git a/vendor/agent-registry/agents/droid/integration.toml b/vendor/agent-registry/agents/droid/integration.toml new file mode 100644 index 0000000000..b64727595a --- /dev/null +++ b/vendor/agent-registry/agents/droid/integration.toml @@ -0,0 +1,26 @@ +cli_name = "droid" +aliases = [] + +[commands] +unix = ["droid"] +windows = ["droid"] + +[supported] +unix = true +windows = true + +[versions] +unix = 3 +windows = 3 + +[[assets]] +path = "assets/herdr-agent-state.ps1" +platform = "windows" +role = "reporter" +install_name = "herdr-agent-state.ps1" + +[[assets]] +path = "assets/herdr-agent-state.sh" +platform = "unix" +role = "reporter" +install_name = "herdr-agent-state.sh" diff --git a/vendor/agent-registry/agents/droid/process.toml b/vendor/agent-registry/agents/droid/process.toml new file mode 100644 index 0000000000..eb8a360176 --- /dev/null +++ b/vendor/agent-registry/agents/droid/process.toml @@ -0,0 +1 @@ +names = ["droid"] diff --git a/vendor/agent-registry/agents/droid/resume.toml b/vendor/agent-registry/agents/droid/resume.toml new file mode 100644 index 0000000000..4b15c4436e --- /dev/null +++ b/vendor/agent-registry/agents/droid/resume.toml @@ -0,0 +1,7 @@ +accepted_references = ["id"] +preferred_reference = "id" +strategy = "separate_flag" +token = "--resume" + +[resume_options] +options = ["--auto"] diff --git a/vendor/agent-registry/agents/gemini/agent.toml b/vendor/agent-registry/agents/gemini/agent.toml new file mode 100644 index 0000000000..be8575c333 --- /dev/null +++ b/vendor/agent-registry/agents/gemini/agent.toml @@ -0,0 +1,13 @@ +schema = 1 +id = "gemini" +name = "gemini" +aliases = [] +startable = true + +[launch] +unix = "gemini" +windows = "gemini" + +[sound] +key = "gemini" +default = "default" diff --git a/src/detect/manifests/gemini.toml b/vendor/agent-registry/agents/gemini/detection.toml similarity index 100% rename from src/detect/manifests/gemini.toml rename to vendor/agent-registry/agents/gemini/detection.toml diff --git a/vendor/agent-registry/agents/gemini/process.toml b/vendor/agent-registry/agents/gemini/process.toml new file mode 100644 index 0000000000..e222655417 --- /dev/null +++ b/vendor/agent-registry/agents/gemini/process.toml @@ -0,0 +1 @@ +names = ["gemini"] diff --git a/vendor/agent-registry/agents/grok/agent.toml b/vendor/agent-registry/agents/grok/agent.toml new file mode 100644 index 0000000000..780639cc4c --- /dev/null +++ b/vendor/agent-registry/agents/grok/agent.toml @@ -0,0 +1,13 @@ +schema = 1 +id = "grok" +name = "grok" +aliases = ["grok-build"] +startable = true + +[launch] +unix = "grok" +windows = "grok" + +[sound] +key = "grok" +default = "default" diff --git a/src/integration/assets/grok/herdr-agent-state.ps1 b/vendor/agent-registry/agents/grok/assets/herdr-agent-state.ps1 similarity index 100% rename from src/integration/assets/grok/herdr-agent-state.ps1 rename to vendor/agent-registry/agents/grok/assets/herdr-agent-state.ps1 diff --git a/src/integration/assets/grok/herdr-agent-state.sh b/vendor/agent-registry/agents/grok/assets/herdr-agent-state.sh similarity index 100% rename from src/integration/assets/grok/herdr-agent-state.sh rename to vendor/agent-registry/agents/grok/assets/herdr-agent-state.sh diff --git a/src/detect/manifests/grok.toml b/vendor/agent-registry/agents/grok/detection.toml similarity index 100% rename from src/detect/manifests/grok.toml rename to vendor/agent-registry/agents/grok/detection.toml diff --git a/vendor/agent-registry/agents/grok/integration.toml b/vendor/agent-registry/agents/grok/integration.toml new file mode 100644 index 0000000000..14f2e27fe7 --- /dev/null +++ b/vendor/agent-registry/agents/grok/integration.toml @@ -0,0 +1,26 @@ +cli_name = "grok" +aliases = [] + +[commands] +unix = ["grok"] +windows = ["grok"] + +[supported] +unix = true +windows = true + +[versions] +unix = 1 +windows = 1 + +[[assets]] +path = "assets/herdr-agent-state.ps1" +platform = "windows" +role = "reporter" +install_name = "herdr-agent-state.ps1" + +[[assets]] +path = "assets/herdr-agent-state.sh" +platform = "unix" +role = "reporter" +install_name = "herdr-agent-state.sh" diff --git a/vendor/agent-registry/agents/grok/process.toml b/vendor/agent-registry/agents/grok/process.toml new file mode 100644 index 0000000000..0c56f70d0a --- /dev/null +++ b/vendor/agent-registry/agents/grok/process.toml @@ -0,0 +1 @@ +names = ["grok", "grok-build"] diff --git a/vendor/agent-registry/agents/grok/resume.toml b/vendor/agent-registry/agents/grok/resume.toml new file mode 100644 index 0000000000..e4b5668cc5 --- /dev/null +++ b/vendor/agent-registry/agents/grok/resume.toml @@ -0,0 +1,4 @@ +accepted_references = ["id"] +preferred_reference = "id" +strategy = "separate_flag" +token = "--resume" diff --git a/vendor/agent-registry/agents/hermes/agent.toml b/vendor/agent-registry/agents/hermes/agent.toml new file mode 100644 index 0000000000..15e016548e --- /dev/null +++ b/vendor/agent-registry/agents/hermes/agent.toml @@ -0,0 +1,13 @@ +schema = 1 +id = "hermes" +name = "hermes" +aliases = ["hermes-agent"] +startable = true + +[launch] +unix = "hermes" +windows = "hermes" + +[sound] +key = "hermes" +default = "default" diff --git a/src/integration/assets/hermes/__init__.py b/vendor/agent-registry/agents/hermes/assets/__init__.py similarity index 100% rename from src/integration/assets/hermes/__init__.py rename to vendor/agent-registry/agents/hermes/assets/__init__.py diff --git a/src/integration/assets/hermes/plugin.yaml b/vendor/agent-registry/agents/hermes/assets/plugin.yaml similarity index 100% rename from src/integration/assets/hermes/plugin.yaml rename to vendor/agent-registry/agents/hermes/assets/plugin.yaml diff --git a/src/detect/manifests/hermes.toml b/vendor/agent-registry/agents/hermes/detection.toml similarity index 100% rename from src/detect/manifests/hermes.toml rename to vendor/agent-registry/agents/hermes/detection.toml diff --git a/vendor/agent-registry/agents/hermes/integration.toml b/vendor/agent-registry/agents/hermes/integration.toml new file mode 100644 index 0000000000..a6d2183c97 --- /dev/null +++ b/vendor/agent-registry/agents/hermes/integration.toml @@ -0,0 +1,26 @@ +cli_name = "hermes" +aliases = [] + +[commands] +unix = ["hermes"] +windows = ["hermes"] + +[supported] +unix = true +windows = true + +[versions] +unix = 5 +windows = 5 + +[[assets]] +path = "assets/__init__.py" +platform = "all" +role = "reporter" +install_name = "__init__.py" + +[[assets]] +path = "assets/plugin.yaml" +platform = "all" +role = "manifest" +install_name = "plugin.yaml" diff --git a/vendor/agent-registry/agents/hermes/process.toml b/vendor/agent-registry/agents/hermes/process.toml new file mode 100644 index 0000000000..e87c6a581c --- /dev/null +++ b/vendor/agent-registry/agents/hermes/process.toml @@ -0,0 +1 @@ +names = ["hermes", "hermes-agent"] diff --git a/vendor/agent-registry/agents/hermes/resume.toml b/vendor/agent-registry/agents/hermes/resume.toml new file mode 100644 index 0000000000..04e0d47546 --- /dev/null +++ b/vendor/agent-registry/agents/hermes/resume.toml @@ -0,0 +1,8 @@ +accepted_references = ["id"] +preferred_reference = "id" +strategy = "separate_flag" +token = "--resume" + +[resume_options] +flags = ["--yolo", "--accept-hooks", "--ignore-user-config", "--ignore-rules", "--safe-mode", "--no-restore-cwd", "--pass-session-id", "--tui", "--cli"] +options = ["-m", "--model", "--provider", "-t", "--toolsets", "--skills", "-s"] diff --git a/vendor/agent-registry/agents/kilo/agent.toml b/vendor/agent-registry/agents/kilo/agent.toml new file mode 100644 index 0000000000..fe71210757 --- /dev/null +++ b/vendor/agent-registry/agents/kilo/agent.toml @@ -0,0 +1,13 @@ +schema = 1 +id = "kilo" +name = "kilo" +aliases = ["kilo-code", "kilo code"] +startable = true + +[launch] +unix = "kilo" +windows = "kilo" + +[sound] +key = "kilo" +default = "default" diff --git a/src/integration/assets/kilo/herdr-agent-state.js b/vendor/agent-registry/agents/kilo/assets/herdr-agent-state.js similarity index 100% rename from src/integration/assets/kilo/herdr-agent-state.js rename to vendor/agent-registry/agents/kilo/assets/herdr-agent-state.js diff --git a/src/detect/manifests/kilo.toml b/vendor/agent-registry/agents/kilo/detection.toml similarity index 100% rename from src/detect/manifests/kilo.toml rename to vendor/agent-registry/agents/kilo/detection.toml diff --git a/vendor/agent-registry/agents/kilo/integration.toml b/vendor/agent-registry/agents/kilo/integration.toml new file mode 100644 index 0000000000..2e7cb884ba --- /dev/null +++ b/vendor/agent-registry/agents/kilo/integration.toml @@ -0,0 +1,20 @@ +cli_name = "kilo" +aliases = [] + +[commands] +unix = ["kilo", "kilo-code"] +windows = ["kilo", "kilo-code"] + +[supported] +unix = true +windows = true + +[versions] +unix = 4 +windows = 4 + +[[assets]] +path = "assets/herdr-agent-state.js" +platform = "all" +role = "reporter" +install_name = "herdr-agent-state.js" diff --git a/vendor/agent-registry/agents/kilo/process.toml b/vendor/agent-registry/agents/kilo/process.toml new file mode 100644 index 0000000000..2f375e1750 --- /dev/null +++ b/vendor/agent-registry/agents/kilo/process.toml @@ -0,0 +1 @@ +names = ["kilo", "kilo-code", "kilo code"] diff --git a/vendor/agent-registry/agents/kilo/resume.toml b/vendor/agent-registry/agents/kilo/resume.toml new file mode 100644 index 0000000000..baaf816413 --- /dev/null +++ b/vendor/agent-registry/agents/kilo/resume.toml @@ -0,0 +1,4 @@ +accepted_references = ["id"] +preferred_reference = "id" +strategy = "separate_flag" +token = "--session" diff --git a/vendor/agent-registry/agents/kimi/agent.toml b/vendor/agent-registry/agents/kimi/agent.toml new file mode 100644 index 0000000000..6dd61a3623 --- /dev/null +++ b/vendor/agent-registry/agents/kimi/agent.toml @@ -0,0 +1,13 @@ +schema = 1 +id = "kimi" +name = "kimi" +aliases = ["kimi-code", "kimi code"] +startable = true + +[launch] +unix = "kimi" +windows = "kimi" + +[sound] +key = "kimi" +default = "default" diff --git a/src/integration/assets/kimi/herdr-agent-state.ps1 b/vendor/agent-registry/agents/kimi/assets/herdr-agent-state.ps1 similarity index 100% rename from src/integration/assets/kimi/herdr-agent-state.ps1 rename to vendor/agent-registry/agents/kimi/assets/herdr-agent-state.ps1 diff --git a/src/integration/assets/kimi/herdr-agent-state.sh b/vendor/agent-registry/agents/kimi/assets/herdr-agent-state.sh similarity index 100% rename from src/integration/assets/kimi/herdr-agent-state.sh rename to vendor/agent-registry/agents/kimi/assets/herdr-agent-state.sh diff --git a/src/detect/manifests/kimi.toml b/vendor/agent-registry/agents/kimi/detection.toml similarity index 100% rename from src/detect/manifests/kimi.toml rename to vendor/agent-registry/agents/kimi/detection.toml diff --git a/vendor/agent-registry/agents/kimi/integration.toml b/vendor/agent-registry/agents/kimi/integration.toml new file mode 100644 index 0000000000..7040341707 --- /dev/null +++ b/vendor/agent-registry/agents/kimi/integration.toml @@ -0,0 +1,26 @@ +cli_name = "kimi" +aliases = [] + +[commands] +unix = ["kimi"] +windows = ["kimi"] + +[supported] +unix = true +windows = true + +[versions] +unix = 7 +windows = 7 + +[[assets]] +path = "assets/herdr-agent-state.ps1" +platform = "windows" +role = "reporter" +install_name = "herdr-agent-state.ps1" + +[[assets]] +path = "assets/herdr-agent-state.sh" +platform = "unix" +role = "reporter" +install_name = "herdr-agent-state.sh" diff --git a/vendor/agent-registry/agents/kimi/process.toml b/vendor/agent-registry/agents/kimi/process.toml new file mode 100644 index 0000000000..4af710f988 --- /dev/null +++ b/vendor/agent-registry/agents/kimi/process.toml @@ -0,0 +1 @@ +names = ["kimi", "kimi-code", "kimi code"] diff --git a/vendor/agent-registry/agents/kimi/resume.toml b/vendor/agent-registry/agents/kimi/resume.toml new file mode 100644 index 0000000000..19d832893f --- /dev/null +++ b/vendor/agent-registry/agents/kimi/resume.toml @@ -0,0 +1,8 @@ +accepted_references = ["id"] +preferred_reference = "id" +strategy = "separate_flag" +token = "--session" + +[resume_options] +flags = ["-y", "--yolo", "--auto", "--plan"] +options = ["-m", "--model", "--agent"] diff --git a/vendor/agent-registry/agents/kiro/agent.toml b/vendor/agent-registry/agents/kiro/agent.toml new file mode 100644 index 0000000000..b04c4a4497 --- /dev/null +++ b/vendor/agent-registry/agents/kiro/agent.toml @@ -0,0 +1,13 @@ +schema = 1 +id = "kiro" +name = "kiro" +aliases = ["kiro-cli"] +startable = true + +[launch] +unix = "kiro-cli" +windows = "kiro-cli" + +[sound] +key = "kiro" +default = "default" diff --git a/src/detect/manifests/kiro.toml b/vendor/agent-registry/agents/kiro/detection.toml similarity index 100% rename from src/detect/manifests/kiro.toml rename to vendor/agent-registry/agents/kiro/detection.toml diff --git a/vendor/agent-registry/agents/kiro/process.toml b/vendor/agent-registry/agents/kiro/process.toml new file mode 100644 index 0000000000..93c7ce1bd2 --- /dev/null +++ b/vendor/agent-registry/agents/kiro/process.toml @@ -0,0 +1 @@ +names = ["kiro", "kiro-cli"] diff --git a/vendor/agent-registry/agents/maki/agent.toml b/vendor/agent-registry/agents/maki/agent.toml new file mode 100644 index 0000000000..a5a105f682 --- /dev/null +++ b/vendor/agent-registry/agents/maki/agent.toml @@ -0,0 +1,13 @@ +schema = 1 +id = "maki" +name = "maki" +aliases = [] +startable = true + +[launch] +unix = "maki" +windows = "maki" + +[sound] +key = "maki" +default = "default" diff --git a/src/detect/manifests/maki.toml b/vendor/agent-registry/agents/maki/detection.toml similarity index 100% rename from src/detect/manifests/maki.toml rename to vendor/agent-registry/agents/maki/detection.toml diff --git a/vendor/agent-registry/agents/maki/process.toml b/vendor/agent-registry/agents/maki/process.toml new file mode 100644 index 0000000000..63bd214344 --- /dev/null +++ b/vendor/agent-registry/agents/maki/process.toml @@ -0,0 +1 @@ +names = ["maki"] diff --git a/vendor/agent-registry/agents/mastracode/agent.toml b/vendor/agent-registry/agents/mastracode/agent.toml new file mode 100644 index 0000000000..f6cc386b1b --- /dev/null +++ b/vendor/agent-registry/agents/mastracode/agent.toml @@ -0,0 +1,9 @@ +schema = 1 +id = "mastracode" +name = "mastracode" +aliases = ["mastra-code", "mastra code"] +startable = true + +[launch] +unix = "mastracode" +windows = "mastracode" diff --git a/src/integration/assets/mastracode/herdr-agent-state.ps1 b/vendor/agent-registry/agents/mastracode/assets/herdr-agent-state.ps1 similarity index 100% rename from src/integration/assets/mastracode/herdr-agent-state.ps1 rename to vendor/agent-registry/agents/mastracode/assets/herdr-agent-state.ps1 diff --git a/src/integration/assets/mastracode/herdr-agent-state.sh b/vendor/agent-registry/agents/mastracode/assets/herdr-agent-state.sh similarity index 100% rename from src/integration/assets/mastracode/herdr-agent-state.sh rename to vendor/agent-registry/agents/mastracode/assets/herdr-agent-state.sh diff --git a/vendor/agent-registry/agents/mastracode/integration.toml b/vendor/agent-registry/agents/mastracode/integration.toml new file mode 100644 index 0000000000..ee9f5feca3 --- /dev/null +++ b/vendor/agent-registry/agents/mastracode/integration.toml @@ -0,0 +1,26 @@ +cli_name = "mastracode" +aliases = [] + +[commands] +unix = ["mastracode"] +windows = ["mastracode"] + +[supported] +unix = true +windows = true + +[versions] +unix = 2 +windows = 2 + +[[assets]] +path = "assets/herdr-agent-state.ps1" +platform = "windows" +role = "reporter" +install_name = "herdr-agent-state.ps1" + +[[assets]] +path = "assets/herdr-agent-state.sh" +platform = "unix" +role = "reporter" +install_name = "herdr-agent-state.sh" diff --git a/vendor/agent-registry/agents/mastracode/process.toml b/vendor/agent-registry/agents/mastracode/process.toml new file mode 100644 index 0000000000..8b30e86646 --- /dev/null +++ b/vendor/agent-registry/agents/mastracode/process.toml @@ -0,0 +1,5 @@ +names = ["mastracode", "mastra-code", "mastra code"] + +[[package_paths]] +kind = "normalized_components" +components = ["node_modules", "mastracode", "dist", "cli"] diff --git a/vendor/agent-registry/agents/mastracode/resume.toml b/vendor/agent-registry/agents/mastracode/resume.toml new file mode 100644 index 0000000000..c0d8d4e66a --- /dev/null +++ b/vendor/agent-registry/agents/mastracode/resume.toml @@ -0,0 +1,4 @@ +accepted_references = ["id"] +preferred_reference = "id" +strategy = "separate_flag" +token = "--thread" diff --git a/vendor/agent-registry/agents/muse/agent.toml b/vendor/agent-registry/agents/muse/agent.toml new file mode 100644 index 0000000000..accc1c0789 --- /dev/null +++ b/vendor/agent-registry/agents/muse/agent.toml @@ -0,0 +1,13 @@ +schema = 1 +id = "muse" +name = "muse" +aliases = ["muse-code", "muse-cli"] +startable = true + +[launch] +unix = "muse" +windows = "muse" + +[sound] +key = "muse" +default = "default" diff --git a/src/detect/manifests/muse.toml b/vendor/agent-registry/agents/muse/detection.toml similarity index 100% rename from src/detect/manifests/muse.toml rename to vendor/agent-registry/agents/muse/detection.toml diff --git a/vendor/agent-registry/agents/muse/process.toml b/vendor/agent-registry/agents/muse/process.toml new file mode 100644 index 0000000000..c8ba40a869 --- /dev/null +++ b/vendor/agent-registry/agents/muse/process.toml @@ -0,0 +1,2 @@ +names = ["muse", "muse-code", "muse-cli"] +versioned_basename_prefix = "muse-bin-" diff --git a/vendor/agent-registry/agents/omp/agent.toml b/vendor/agent-registry/agents/omp/agent.toml new file mode 100644 index 0000000000..b9127a917b --- /dev/null +++ b/vendor/agent-registry/agents/omp/agent.toml @@ -0,0 +1,9 @@ +schema = 1 +id = "omp" +name = "omp" +aliases = [] +startable = true + +[launch] +unix = "omp" +windows = "omp" diff --git a/src/integration/assets/omp/herdr-agent-state.ts b/vendor/agent-registry/agents/omp/assets/herdr-agent-state.ts similarity index 100% rename from src/integration/assets/omp/herdr-agent-state.ts rename to vendor/agent-registry/agents/omp/assets/herdr-agent-state.ts diff --git a/vendor/agent-registry/agents/omp/integration.toml b/vendor/agent-registry/agents/omp/integration.toml new file mode 100644 index 0000000000..3de462e869 --- /dev/null +++ b/vendor/agent-registry/agents/omp/integration.toml @@ -0,0 +1,20 @@ +cli_name = "omp" +aliases = [] + +[commands] +unix = ["omp"] +windows = ["omp"] + +[supported] +unix = true +windows = true + +[versions] +unix = 9 +windows = 9 + +[[assets]] +path = "assets/herdr-agent-state.ts" +platform = "all" +role = "reporter" +install_name = "herdr-omp-agent-state.ts" diff --git a/vendor/agent-registry/agents/omp/process.toml b/vendor/agent-registry/agents/omp/process.toml new file mode 100644 index 0000000000..bf7ea9d462 --- /dev/null +++ b/vendor/agent-registry/agents/omp/process.toml @@ -0,0 +1 @@ +names = ["omp"] diff --git a/vendor/agent-registry/agents/omp/resume.toml b/vendor/agent-registry/agents/omp/resume.toml new file mode 100644 index 0000000000..e77309dd29 --- /dev/null +++ b/vendor/agent-registry/agents/omp/resume.toml @@ -0,0 +1,8 @@ +accepted_references = ["path", "id"] +preferred_reference = "path" +strategy = "joined_flag" +token = "--resume=" + +[resume_options] +flags = ["--prewalk", "--no-prewalk", "--plan-yolo", "--allow-home", "--no-tools", "--no-lsp", "--no-pty", "--hide-thinking", "--advisor", "--no-extensions", "--no-skills", "--no-rules", "--no-title", "--auto-approve"] +options = ["--model", "--smol", "--slow", "--plan", "--provider", "--thinking", "--max-time", "--approval-mode"] diff --git a/vendor/agent-registry/agents/opencode/agent.toml b/vendor/agent-registry/agents/opencode/agent.toml new file mode 100644 index 0000000000..ae561398c9 --- /dev/null +++ b/vendor/agent-registry/agents/opencode/agent.toml @@ -0,0 +1,13 @@ +schema = 1 +id = "opencode" +name = "opencode" +aliases = ["opencode2", "open-code"] +startable = true + +[launch] +unix = "opencode" +windows = "opencode" + +[sound] +key = "open_code" +default = "default" diff --git a/src/integration/assets/opencode/herdr-agent-state.js b/vendor/agent-registry/agents/opencode/assets/herdr-agent-state.js similarity index 100% rename from src/integration/assets/opencode/herdr-agent-state.js rename to vendor/agent-registry/agents/opencode/assets/herdr-agent-state.js diff --git a/src/integration/assets/opencode/herdr-tui-session.js b/vendor/agent-registry/agents/opencode/assets/herdr-tui-session.js similarity index 100% rename from src/integration/assets/opencode/herdr-tui-session.js rename to vendor/agent-registry/agents/opencode/assets/herdr-tui-session.js diff --git a/vendor/agent-registry/agents/opencode/detection.toml b/vendor/agent-registry/agents/opencode/detection.toml new file mode 100644 index 0000000000..ec975937a3 --- /dev/null +++ b/vendor/agent-registry/agents/opencode/detection.toml @@ -0,0 +1,75 @@ +id = "opencode" +version = "2026.09.05.1" +min_engine_version = 1 +updated_at = "2026-09-05T00:00:00Z" +aliases = ["open-code", "herdr:opencode"] + +[[rules]] +id = "permission_required" +state = "blocked" +priority = 300 +region = "bottom_non_empty_lines(20)" +visible_blocker = true +any = [ + { contains = ["△ Permission required"] }, + { contains = ["esc dismiss"], any = [{ contains = ["enter confirm"] }, { contains = ["enter submit"] }, { contains = ["enter toggle"] }], all = [{ any = [{ contains = ["↑↓ select"] }, { contains = ["⇆ tab"] }] }] }, +] + +[[rules]] +id = "command_palette" +state = "unknown" +priority = 250 +region = "bottom_non_empty_lines(20)" +skip_state_update = true +all = [ + { line_regex = ['^\s*Commands\s+esc\s*$'] }, + { line_regex = ['^\s*Search\s*$'] }, + { line_regex = ['^\s*(Suggested|System)\s*$'] }, +] + +[[rules]] +id = "interrupt_hint_working" +state = "working" +priority = 110 +region = "bottom_non_empty_lines(20)" +visible_working = true +any = [ + { contains = ["esc to interrupt"] }, + { contains = ["ctrl+c to interrupt"] }, + { contains = ["press esc to interrupt"] }, + { line_regex = ['(?i).*opencode.*esc (again to )?interrupt'] }, +] + +[[rules]] +id = "progress_bar_working" +state = "working" +priority = 100 +region = "bottom_non_empty_lines(20)" +visible_working = true +regex = ['(■|⬝){4,}'] + +[[rules]] +id = "home_composer_idle" +state = "idle" +priority = 20 +region = "bottom_non_empty_lines(12)" +visible_idle = true +contains = ["agents", "commands"] +line_regex = ['^\s*╹▀{8,}\s*$'] + +[[rules]] +id = "session_composer_idle" +state = "idle" +priority = 10 +region = "bottom_non_empty_lines(12)" +visible_idle = true +contains = ["commands"] +line_regex = ['^\s*╹▀{8,}\s*$'] + +[[rules]] +id = "retain_unknown_surface" +state = "unknown" +priority = -1000 +region = "bottom_lines(1)" +skip_state_update = true +regex = ['(?s).*'] diff --git a/vendor/agent-registry/agents/opencode/integration.toml b/vendor/agent-registry/agents/opencode/integration.toml new file mode 100644 index 0000000000..3747f2c029 --- /dev/null +++ b/vendor/agent-registry/agents/opencode/integration.toml @@ -0,0 +1,26 @@ +cli_name = "opencode" +aliases = [] + +[commands] +unix = ["opencode"] +windows = ["opencode"] + +[supported] +unix = true +windows = true + +[versions] +unix = 12 +windows = 12 + +[[assets]] +path = "assets/herdr-agent-state.js" +platform = "all" +role = "reporter" +install_name = "herdr-agent-state.js" + +[[assets]] +path = "assets/herdr-tui-session.js" +platform = "all" +role = "tui" +install_name = "herdr-tui-session.js" diff --git a/vendor/agent-registry/agents/opencode/process.toml b/vendor/agent-registry/agents/opencode/process.toml new file mode 100644 index 0000000000..b05cf5549c --- /dev/null +++ b/vendor/agent-registry/agents/opencode/process.toml @@ -0,0 +1 @@ +names = ["opencode", "opencode2", "open-code"] diff --git a/vendor/agent-registry/agents/opencode/resume.toml b/vendor/agent-registry/agents/opencode/resume.toml new file mode 100644 index 0000000000..b9ba60be07 --- /dev/null +++ b/vendor/agent-registry/agents/opencode/resume.toml @@ -0,0 +1,8 @@ +accepted_references = ["id"] +preferred_reference = "id" +strategy = "separate_flag" +token = "--session" + +[resume_options] +flags = ["--pure", "--auto", "--mini", "--no-replay"] +options = ["-m", "--model", "--agent", "--replay-limit"] diff --git a/vendor/agent-registry/agents/pi/agent.toml b/vendor/agent-registry/agents/pi/agent.toml new file mode 100644 index 0000000000..1983468c28 --- /dev/null +++ b/vendor/agent-registry/agents/pi/agent.toml @@ -0,0 +1,13 @@ +schema = 1 +id = "pi" +name = "pi" +aliases = [] +startable = true + +[launch] +unix = "pi" +windows = "pi" + +[sound] +key = "pi" +default = "default" diff --git a/src/integration/assets/pi/herdr-agent-state.ts b/vendor/agent-registry/agents/pi/assets/herdr-agent-state.ts similarity index 100% rename from src/integration/assets/pi/herdr-agent-state.ts rename to vendor/agent-registry/agents/pi/assets/herdr-agent-state.ts diff --git a/src/detect/manifests/pi.toml b/vendor/agent-registry/agents/pi/detection.toml similarity index 100% rename from src/detect/manifests/pi.toml rename to vendor/agent-registry/agents/pi/detection.toml diff --git a/vendor/agent-registry/agents/pi/integration.toml b/vendor/agent-registry/agents/pi/integration.toml new file mode 100644 index 0000000000..2eb898cbb2 --- /dev/null +++ b/vendor/agent-registry/agents/pi/integration.toml @@ -0,0 +1,20 @@ +cli_name = "pi" +aliases = [] + +[commands] +unix = ["pi"] +windows = ["pi"] + +[supported] +unix = true +windows = true + +[versions] +unix = 9 +windows = 9 + +[[assets]] +path = "assets/herdr-agent-state.ts" +platform = "all" +role = "reporter" +install_name = "herdr-agent-state.ts" diff --git a/vendor/agent-registry/agents/pi/process.toml b/vendor/agent-registry/agents/pi/process.toml new file mode 100644 index 0000000000..87fa9d9f25 --- /dev/null +++ b/vendor/agent-registry/agents/pi/process.toml @@ -0,0 +1,9 @@ +names = ["pi"] + +[[package_paths]] +kind = "exact_suffix" +components = ["node_modules", "@earendil-works", "pi-coding-agent", "dist", "cli.js"] + +[[package_paths]] +kind = "exact_suffix" +components = ["node_modules", "@earendil-works", "pi-coding-agent", "dist", "bundle", "cli.js"] diff --git a/vendor/agent-registry/agents/pi/resume.toml b/vendor/agent-registry/agents/pi/resume.toml new file mode 100644 index 0000000000..e2812b5349 --- /dev/null +++ b/vendor/agent-registry/agents/pi/resume.toml @@ -0,0 +1,8 @@ +accepted_references = ["path", "id"] +preferred_reference = "path" +strategy = "separate_flag" +token = "--session" + +[resume_options] +flags = ["--no-tools", "-nt", "--no-builtin-tools", "-nbt", "--no-extensions", "-ne", "--no-skills", "-ns", "--no-context-files", "-nc", "--approve", "-a", "--no-approve", "-na", "--offline"] +options = ["--provider", "--model", "--thinking", "--tui-mode"] diff --git a/vendor/agent-registry/agents/qodercli/agent.toml b/vendor/agent-registry/agents/qodercli/agent.toml new file mode 100644 index 0000000000..7fb24a538b --- /dev/null +++ b/vendor/agent-registry/agents/qodercli/agent.toml @@ -0,0 +1,13 @@ +schema = 1 +id = "qodercli" +name = "qodercli" +aliases = ["qoderclicn", "qoder", "qodercn"] +startable = true + +[launch] +unix = "qodercli" +windows = "qodercli" + +[sound] +key = "qodercli" +default = "default" diff --git a/src/integration/assets/qodercli/herdr-agent-state.ps1 b/vendor/agent-registry/agents/qodercli/assets/herdr-agent-state.ps1 similarity index 100% rename from src/integration/assets/qodercli/herdr-agent-state.ps1 rename to vendor/agent-registry/agents/qodercli/assets/herdr-agent-state.ps1 diff --git a/src/integration/assets/qodercli/herdr-agent-state.sh b/vendor/agent-registry/agents/qodercli/assets/herdr-agent-state.sh similarity index 100% rename from src/integration/assets/qodercli/herdr-agent-state.sh rename to vendor/agent-registry/agents/qodercli/assets/herdr-agent-state.sh diff --git a/src/detect/manifests/qodercli.toml b/vendor/agent-registry/agents/qodercli/detection.toml similarity index 100% rename from src/detect/manifests/qodercli.toml rename to vendor/agent-registry/agents/qodercli/detection.toml diff --git a/vendor/agent-registry/agents/qodercli/integration.toml b/vendor/agent-registry/agents/qodercli/integration.toml new file mode 100644 index 0000000000..1ea9cef4ba --- /dev/null +++ b/vendor/agent-registry/agents/qodercli/integration.toml @@ -0,0 +1,26 @@ +cli_name = "qodercli" +aliases = [] + +[commands] +unix = ["qodercli"] +windows = ["qodercli", "qoder", "qoderclicn", "qodercn"] + +[supported] +unix = true +windows = true + +[versions] +unix = 3 +windows = 3 + +[[assets]] +path = "assets/herdr-agent-state.ps1" +platform = "windows" +role = "reporter" +install_name = "herdr-agent-state.ps1" + +[[assets]] +path = "assets/herdr-agent-state.sh" +platform = "unix" +role = "reporter" +install_name = "herdr-agent-state.sh" diff --git a/vendor/agent-registry/agents/qodercli/process.toml b/vendor/agent-registry/agents/qodercli/process.toml new file mode 100644 index 0000000000..1f8644880d --- /dev/null +++ b/vendor/agent-registry/agents/qodercli/process.toml @@ -0,0 +1 @@ +names = ["qodercli", "qoderclicn", "qoder", "qodercn"] diff --git a/vendor/agent-registry/agents/qodercli/resume.toml b/vendor/agent-registry/agents/qodercli/resume.toml new file mode 100644 index 0000000000..e4b5668cc5 --- /dev/null +++ b/vendor/agent-registry/agents/qodercli/resume.toml @@ -0,0 +1,4 @@ +accepted_references = ["id"] +preferred_reference = "id" +strategy = "separate_flag" +token = "--resume" diff --git a/vendor/agent-registry/agents/qwen/agent.toml b/vendor/agent-registry/agents/qwen/agent.toml new file mode 100644 index 0000000000..0b0717706a --- /dev/null +++ b/vendor/agent-registry/agents/qwen/agent.toml @@ -0,0 +1,13 @@ +schema = 1 +id = "qwen" +name = "qwen" +aliases = ["qwen-code", "qwen code"] +startable = true + +[launch] +unix = "qwen" +windows = "qwen" + +[sound] +key = "qwen" +default = "default" diff --git a/src/integration/assets/qwen/herdr-agent-session.ps1 b/vendor/agent-registry/agents/qwen/assets/herdr-agent-session.ps1 similarity index 100% rename from src/integration/assets/qwen/herdr-agent-session.ps1 rename to vendor/agent-registry/agents/qwen/assets/herdr-agent-session.ps1 diff --git a/src/integration/assets/qwen/herdr-agent-session.sh b/vendor/agent-registry/agents/qwen/assets/herdr-agent-session.sh similarity index 100% rename from src/integration/assets/qwen/herdr-agent-session.sh rename to vendor/agent-registry/agents/qwen/assets/herdr-agent-session.sh diff --git a/src/detect/manifests/qwen.toml b/vendor/agent-registry/agents/qwen/detection.toml similarity index 100% rename from src/detect/manifests/qwen.toml rename to vendor/agent-registry/agents/qwen/detection.toml diff --git a/vendor/agent-registry/agents/qwen/integration.toml b/vendor/agent-registry/agents/qwen/integration.toml new file mode 100644 index 0000000000..3f4e22d46c --- /dev/null +++ b/vendor/agent-registry/agents/qwen/integration.toml @@ -0,0 +1,26 @@ +cli_name = "qwen" +aliases = [] + +[commands] +unix = ["qwen"] +windows = ["qwen"] + +[supported] +unix = true +windows = true + +[versions] +unix = 1 +windows = 1 + +[[assets]] +path = "assets/herdr-agent-session.ps1" +platform = "windows" +role = "reporter" +install_name = "herdr-agent-session.ps1" + +[[assets]] +path = "assets/herdr-agent-session.sh" +platform = "unix" +role = "reporter" +install_name = "herdr-agent-session.sh" diff --git a/vendor/agent-registry/agents/qwen/process.toml b/vendor/agent-registry/agents/qwen/process.toml new file mode 100644 index 0000000000..e70db15c20 --- /dev/null +++ b/vendor/agent-registry/agents/qwen/process.toml @@ -0,0 +1,6 @@ +names = ["qwen", "qwen-code", "qwen code"] +secondary_runtime_argv_fallback = true + +[[package_paths]] +kind = "normalized_components" +components = ["node_modules", "@qwen-code", "qwen-code", "dist", "index"] diff --git a/vendor/agent-registry/agents/qwen/resume.toml b/vendor/agent-registry/agents/qwen/resume.toml new file mode 100644 index 0000000000..e4b5668cc5 --- /dev/null +++ b/vendor/agent-registry/agents/qwen/resume.toml @@ -0,0 +1,4 @@ +accepted_references = ["id"] +preferred_reference = "id" +strategy = "separate_flag" +token = "--resume" diff --git a/vendor/agent-registry/lock.json b/vendor/agent-registry/lock.json new file mode 100644 index 0000000000..7cbf7e32fa --- /dev/null +++ b/vendor/agent-registry/lock.json @@ -0,0 +1,535 @@ +{ + "schema": 1, + "repository": "https://github.com/herdrdev/agent-registry", + "sha256": "c85b430d22cb70f7dc3fe6f374a74c0a4f0c7dd085af9221b8a04fb1730316db", + "files": [ + { + "path": "agents/agy/agent.toml", + "sha256": "4c419527f83d96f55a83610eabd453beb43a46973c55822d0667100c03f92824" + }, + { + "path": "agents/agy/assets/herdr-agent-state.ps1", + "sha256": "25eefb17464a01ee000bfab24b95b7f040b8beaa9020e993416e315d6db1ef84" + }, + { + "path": "agents/agy/assets/herdr-agent-state.sh", + "sha256": "bdf8073a23de2aa919a23d004c1788f2af3082484acb144e2f6735699995ec09" + }, + { + "path": "agents/agy/detection.toml", + "sha256": "11300b853130d037eb2c57d9c4b897893cc1f0a876e12eb54ff1b216177db9d7" + }, + { + "path": "agents/agy/integration.toml", + "sha256": "44c93028a5ef6c5a038a9f6dd886462bf958481635f1a9a74f21604213b1a388" + }, + { + "path": "agents/agy/process.toml", + "sha256": "19a391715be2fbd10264e1aef16391f2d965d8d7556781442873787f41a29b1b" + }, + { + "path": "agents/agy/resume.toml", + "sha256": "4b8ecfc1e818f94db490878ba9ded5628e2090a4b6d98897d3348374c9154e14" + }, + { + "path": "agents/amp/agent.toml", + "sha256": "dda476701d54cf28c131ba29be0c9f8e1aaac709734067bc6ab59843a49a2f63" + }, + { + "path": "agents/amp/detection.toml", + "sha256": "b5806b0dd21e2f5e752d0eac7f084d5ce2e3f275638cfd1234c42cc88152dc8c" + }, + { + "path": "agents/amp/process.toml", + "sha256": "b1c83cfa6e6ec374b37fdb4df23fe16eccb89ec52e8f6c808fbf3c6afbcbc4ec" + }, + { + "path": "agents/claude/agent.toml", + "sha256": "859505114acee05642b051dc4956b3ed2003ead95cb938f2d7d5c0b699fa2b0b" + }, + { + "path": "agents/claude/assets/herdr-agent-state.ps1", + "sha256": "6db8bf92db45a3c5aa67994c1fb665231339e1dc3b08c28239c246c0874ebaaa" + }, + { + "path": "agents/claude/assets/herdr-agent-state.sh", + "sha256": "a61bbe2619a316669406316de260c13b6c1ae525dafd6f4f4956e26646268105" + }, + { + "path": "agents/claude/detection.toml", + "sha256": "038d0aa23fee3f9b39cb3c9ca117d0f95b0b3a5873cf0f38284ccbac279c9664" + }, + { + "path": "agents/claude/integration.toml", + "sha256": "7ac77a16539d085eaf44950e38747baf991b55fab93bae666e79f7f45757beb0" + }, + { + "path": "agents/claude/process.toml", + "sha256": "9169a6a734a141179136ae7c399be3e1eaaf9d5f2a33e71a526bf2b63d9ccce4" + }, + { + "path": "agents/claude/resume.toml", + "sha256": "bb551e683e1cbee81db9f8b6941a289f323563c0851036c95a3db13c9a973880" + }, + { + "path": "agents/cline/agent.toml", + "sha256": "f30d3d2a2e3ad37840a15574abc52ddb31a8ee1d5319ca80834e470b5f8eedbe" + }, + { + "path": "agents/cline/detection.toml", + "sha256": "75fe33ec735c59638da8d62e16bddd257d9959e692edb83b1f11b7f28057866a" + }, + { + "path": "agents/cline/process.toml", + "sha256": "2d751fc450305601c49bcbdd572a4dfc52d75bd4a4697d9e08941b34580f7264" + }, + { + "path": "agents/codex/agent.toml", + "sha256": "0cd67f25b8f435a2609286d8df116d78ba609169b4a629ffe284281d0c0320a4" + }, + { + "path": "agents/codex/assets/herdr-agent-state.ps1", + "sha256": "8fee2867ba1346e472157966dde74d7c5c6976a2a6e394583f03a5b9df690f4a" + }, + { + "path": "agents/codex/assets/herdr-agent-state.sh", + "sha256": "38a90a2a99872d06dcbc978e7613406cdd852dbb07503abd7a113f518d5951f3" + }, + { + "path": "agents/codex/detection.toml", + "sha256": "c9780984fec679a5a1a91f2018ba486a74d971e5837324a2a1d95047178d5a22" + }, + { + "path": "agents/codex/integration.toml", + "sha256": "1ff52570fe6930b240566525933b74621d3790ae1a4d5e37561037052a619344" + }, + { + "path": "agents/codex/process.toml", + "sha256": "9f93175e4aa36ca829fd2e1dcf18e477e8c58d98ff0d0938c7d2e8637295faa7" + }, + { + "path": "agents/codex/resume.toml", + "sha256": "89328ae69b5382232af9dea1a135e94ba89c358759ba64bbef63f9dc0fe0afc2" + }, + { + "path": "agents/copilot/agent.toml", + "sha256": "35658382de83bedadf311e4c638b59f552737bbde91ffc231720a0a7f15f3dac" + }, + { + "path": "agents/copilot/assets/herdr-agent-state.ps1", + "sha256": "5df815609525dad3a2e93cfd46a16650a6baba2ec20e89ee8fbca32ce3a6a4dd" + }, + { + "path": "agents/copilot/assets/herdr-agent-state.sh", + "sha256": "197460e7423d243862690eeb2dadaa0eeaec3d42f3106c5972483b029b797825" + }, + { + "path": "agents/copilot/detection.toml", + "sha256": "b70c652584326a1a98475a5fcef16207dee9f23b65f8e78300f4fc2bb578cb11" + }, + { + "path": "agents/copilot/integration.toml", + "sha256": "d0f8d645268fb3e89841d5a4ad146565f8af2f24a49f6ad1dd3d2bfa8f715e6d" + }, + { + "path": "agents/copilot/process.toml", + "sha256": "637f9d0c848b9a1e554f265530f33340a52dc510b78e8e538459ed7f28ec0a19" + }, + { + "path": "agents/copilot/resume.toml", + "sha256": "d8c7279f949c3afbdc090c10e66545e874374f71ffcfa6a753c746ba44f8afff" + }, + { + "path": "agents/cursor/agent.toml", + "sha256": "da4a10d5512630c1cd4817a256d6a0b2796fda2f65bfedac5ecdc729a4107a82" + }, + { + "path": "agents/cursor/assets/herdr-agent-state.ps1", + "sha256": "3405df5870b8151f8179fb96b0c79304566567339db7938b07fc2a79f0fd7b30" + }, + { + "path": "agents/cursor/assets/herdr-agent-state.sh", + "sha256": "186c6b4b9e0f8ca59288cb74c3c2c9131c0e3ca086004b1a26d0617c843a38a2" + }, + { + "path": "agents/cursor/detection.toml", + "sha256": "753b1f7f632d42fa21139c2767ecbb5e1078748aba2e59407ac4932d3ce36ad7" + }, + { + "path": "agents/cursor/integration.toml", + "sha256": "3dea48e869f79f9287fa31b19f85b13e85c06c6649bc4fa6eecde13bb619d566" + }, + { + "path": "agents/cursor/process.toml", + "sha256": "331626e76f1bff0421a2e62886f41c8342cda62673363071ee682e0b8d6948f4" + }, + { + "path": "agents/cursor/resume.toml", + "sha256": "93d0c7bf7f3581eaf5b707a0bbf9af296461909185a39364d13fbcfe6aaf68e5" + }, + { + "path": "agents/devin/agent.toml", + "sha256": "159dc15c8a7b013c403a30630753869eafa1d085e13dc77fbbd42a355d6a44ec" + }, + { + "path": "agents/devin/assets/herdr-agent-state.ps1", + "sha256": "a62c8c65b789843b9fdc99e702bc75f18279ce85a7228cc604e86750d626212c" + }, + { + "path": "agents/devin/assets/herdr-agent-state.sh", + "sha256": "29ee8f4c216dd07b08d45a2549ae1d3cbe0d593e6f0af987eb6983cca64cc6af" + }, + { + "path": "agents/devin/detection.toml", + "sha256": "250c9cea1d60bdb965dc6056f3066b785e941d60242756fbaca73b57ca6b0f85" + }, + { + "path": "agents/devin/integration.toml", + "sha256": "dd8f9ea6953036b05f89a21b1d45fc91072fafa6e6059f2775540714d249298a" + }, + { + "path": "agents/devin/process.toml", + "sha256": "653e851c209e3113801122cab1f1809d1c5161c08610d8f171458cf3e8b7cd2e" + }, + { + "path": "agents/devin/resume.toml", + "sha256": "823de1f80cbd9f050754a421ed8b6d231cd3fc6204e39beb9f6d75723debaeba" + }, + { + "path": "agents/droid/agent.toml", + "sha256": "38a0cced0fe9737055c5692ee46c7208cefbb0759983b5bff37f6c6b7238cb47" + }, + { + "path": "agents/droid/assets/herdr-agent-state.ps1", + "sha256": "26d0f52c594a0322396ce746d6f9ee4723511005ce7ceff631c6895da74fa384" + }, + { + "path": "agents/droid/assets/herdr-agent-state.sh", + "sha256": "635a1e6eac814460c61da9421f6cd7a7fea81eaeca2b7469ac333e3c4e780b77" + }, + { + "path": "agents/droid/detection.toml", + "sha256": "d37e7c464177c0e8f3edce8d4fabc4bcc7a1874edf2c4c87a2f927888cf69ce9" + }, + { + "path": "agents/droid/integration.toml", + "sha256": "3f3ec094a03364a4a0bb9800dd557c18af67fb2e96f3577d8437656abcd27641" + }, + { + "path": "agents/droid/process.toml", + "sha256": "0f499de355d0f691da6cbb8b5a94427c975d3331ee0c883ac06ca5948d5f38de" + }, + { + "path": "agents/droid/resume.toml", + "sha256": "770d7d31065498ade93c0d9e58b709f9665192f984141e018c8c6f83bb3f7909" + }, + { + "path": "agents/gemini/agent.toml", + "sha256": "d382c3d6cf5cf50d53bb0778b52cd335fa85a7a156badaf0acf331fb34559f99" + }, + { + "path": "agents/gemini/detection.toml", + "sha256": "d7013b5e772852ecc595febf964f00b4f9edcbc6047a2a5421613a154d92520d" + }, + { + "path": "agents/gemini/process.toml", + "sha256": "aebe5eac8120de3c38e41aee72f2d228e64e6589b51988d732ca1458a3c7e5b6" + }, + { + "path": "agents/grok/agent.toml", + "sha256": "12d21ab94fed20df92936e33649a1f3425aab0c144c4c38b5bf9b1cd7c142cbd" + }, + { + "path": "agents/grok/assets/herdr-agent-state.ps1", + "sha256": "f5868cb925780899a912f06e38c5000cca913627556816d4d9b2f8f73e8bda3b" + }, + { + "path": "agents/grok/assets/herdr-agent-state.sh", + "sha256": "a5c66f3117037341df12e59fbefc5ff9869e092f8766dedf0500629e24006def" + }, + { + "path": "agents/grok/detection.toml", + "sha256": "618afc3d09d784343fc6c4bcee5801906df2c0fd62a65b447cf5f4b1bd215bb2" + }, + { + "path": "agents/grok/integration.toml", + "sha256": "eacf1c894261b3b61358a69e0c0e93ee7c2bb068c5fa3f378f17ddf9dea390bd" + }, + { + "path": "agents/grok/process.toml", + "sha256": "8c5eef68d135332fe5661d777694964965239b92f8ebe0c93a8d13c97493c451" + }, + { + "path": "agents/grok/resume.toml", + "sha256": "823de1f80cbd9f050754a421ed8b6d231cd3fc6204e39beb9f6d75723debaeba" + }, + { + "path": "agents/hermes/agent.toml", + "sha256": "9f81f2c382958d3bad1f09f15c1c98555ec6208146eefda5465bc20a8bcd9ace" + }, + { + "path": "agents/hermes/assets/__init__.py", + "sha256": "62884f3e0f714f78bfc42722cddef3dd32a0a184502864d79d113329563deed2" + }, + { + "path": "agents/hermes/assets/plugin.yaml", + "sha256": "e1c775a6d6ccad2f8e6e0566a0aa61aeef9b8141f59d8a742a77c6951a261170" + }, + { + "path": "agents/hermes/detection.toml", + "sha256": "533d21b65dea3a0c60c25d0475c9c900d28a5712b6392669a7788f75de2b6e85" + }, + { + "path": "agents/hermes/integration.toml", + "sha256": "938eae966f96a9cba98f23beb477a2a8601b5a04c91627d24170ca858bd07225" + }, + { + "path": "agents/hermes/process.toml", + "sha256": "a6c9f941374841b02d90045dca7883eea16256c0ef8d8700570c92e15650a84c" + }, + { + "path": "agents/hermes/resume.toml", + "sha256": "d62267638c9dc33555990c7598e8790f453365aff99bde9460bd12bbbede93fc" + }, + { + "path": "agents/kilo/agent.toml", + "sha256": "c2e7a7d973c10d31346136a77e06787e04bc7711a6e970e7d5f98747224b49d8" + }, + { + "path": "agents/kilo/assets/herdr-agent-state.js", + "sha256": "386e7605e0bfb39b71bc9c48509c54e9964e71097d3b35a44f4a509af421469d" + }, + { + "path": "agents/kilo/detection.toml", + "sha256": "70f0ba4e58bc141fe69d7024013f973cd16e8616393079318914d70afeefef3b" + }, + { + "path": "agents/kilo/integration.toml", + "sha256": "1ec8cb69e3ba110209232a2e0e01dbb9c3f8f96df3a710e2f5d134d9ace5fca8" + }, + { + "path": "agents/kilo/process.toml", + "sha256": "ef00463dda268462a7424d16a0c55c7375e608a23b8d2a6e5e68575fd9984c39" + }, + { + "path": "agents/kilo/resume.toml", + "sha256": "b8b5077fd1fa78f68d5ae0b0b6b6e68ff8d385581a77ee79000329992383d383" + }, + { + "path": "agents/kimi/agent.toml", + "sha256": "bf2893d438714960644507a1831afdfe23f073fdad916caef263fb34edf9846c" + }, + { + "path": "agents/kimi/assets/herdr-agent-state.ps1", + "sha256": "2b76be7c2b7be2e3a5a746ab7949ebec77d451914d50f6f7ca5348c03d824f32" + }, + { + "path": "agents/kimi/assets/herdr-agent-state.sh", + "sha256": "f28cf38a2b1e46b654b33e4dff72bdafbd24b6d58b37f7c0f538c2a096410f00" + }, + { + "path": "agents/kimi/detection.toml", + "sha256": "ede08c0d2d5024f7606dc0a1b2f7a9c6a0ebb99f3b6c58ca6757049856d06e05" + }, + { + "path": "agents/kimi/integration.toml", + "sha256": "2ec751582697cf1a575cca0f53c0a947b3637f1b97eddf75388c3b78761cc53a" + }, + { + "path": "agents/kimi/process.toml", + "sha256": "6667e09624d84f367232b4edd3c397f2b853285ef05ac1bb0b9c8336fbaca56e" + }, + { + "path": "agents/kimi/resume.toml", + "sha256": "668520271a4194d9a008ff7dd3538acde36497f57f3262ef72e8a9f77d3ea2ef" + }, + { + "path": "agents/kiro/agent.toml", + "sha256": "96fa9bf08ac360c51eac513960f8b6fc959a60286200a972f0e5199ab005e67e" + }, + { + "path": "agents/kiro/detection.toml", + "sha256": "2b56fc18c478e2730acd9f8cbfecb69a2496258f8f20ef157813b09fa74c6e37" + }, + { + "path": "agents/kiro/process.toml", + "sha256": "a4db775feeb1389b6d4903d2a34027617ca144101f61640f19cf24338873dd81" + }, + { + "path": "agents/maki/agent.toml", + "sha256": "f07628da0adebe75d6eabc4d1ba25c63e288dd68124e7dd31d72ecd82a09d690" + }, + { + "path": "agents/maki/detection.toml", + "sha256": "3b392170ee3082051266509f575a4640bde8d693b2f37ecf52157a641bc75b28" + }, + { + "path": "agents/maki/process.toml", + "sha256": "b33066eeab5b457f79150a4c4400fd4acb79ee7966490ecc459ff8ff0e009ad8" + }, + { + "path": "agents/mastracode/agent.toml", + "sha256": "920a7af86cff356c6798f7f17edfb8a219a0041755477377844e0665f41a0ea7" + }, + { + "path": "agents/mastracode/assets/herdr-agent-state.ps1", + "sha256": "36672725e132737d0659c8f906380f9fb033c68b70f646d0dc0197cbefc6bf73" + }, + { + "path": "agents/mastracode/assets/herdr-agent-state.sh", + "sha256": "f5d9dc5c99458a5872ccb437bbd92d7b2d5c6d5469510a6b39b95ecc671ba94a" + }, + { + "path": "agents/mastracode/integration.toml", + "sha256": "3e1a69b412829340024a6bb40ee175fa40bd02b57f02885ca2203b1495c1040e" + }, + { + "path": "agents/mastracode/process.toml", + "sha256": "80f6d36fba74cbccf5fb5064e75b9c52c3f6faffd0276f4b13283e59814d1cd0" + }, + { + "path": "agents/mastracode/resume.toml", + "sha256": "7fc3fc89e814db6643ab85d1f36a7962e0e176fe84129fc7e9393d3041f84a02" + }, + { + "path": "agents/muse/agent.toml", + "sha256": "9e568aabebcf366f3ec8a3a0610aa8b9921ba883bee5f2ff8be626949c7b2dfe" + }, + { + "path": "agents/muse/detection.toml", + "sha256": "b69c4d87fa9c19e3e6453b706fbe39c98a8b33ffbaa48e8cd5ae6751e9615074" + }, + { + "path": "agents/muse/process.toml", + "sha256": "e833f42575212623a0eb9229aeadcafddaa0a58fc7f9cfb7d956fd562a5863d8" + }, + { + "path": "agents/omp/agent.toml", + "sha256": "95073a584455fc07d17cffa55dae09c9f5e13797868c80b3426a58a33a4ae81f" + }, + { + "path": "agents/omp/assets/herdr-agent-state.ts", + "sha256": "4cfd1e32da1f15efba0521c52fee3ba4b23c36dc236a77aea922220fdefa024e" + }, + { + "path": "agents/omp/integration.toml", + "sha256": "8381007563ac40afa262a2e05e62538685a0dffcfc7e48e99332ab0da05986d8" + }, + { + "path": "agents/omp/process.toml", + "sha256": "22566d1dbee7bd5c7afdb770774e9fb693b2569063a4816cb425908bea24b76d" + }, + { + "path": "agents/omp/resume.toml", + "sha256": "82b0ee49611741d651dbbc49c6dae2808577c1ccb6c6e5f7722c37248d5c13c6" + }, + { + "path": "agents/opencode/agent.toml", + "sha256": "a8ac0b09b2d6ff41e81345a2dfcbfc8b2074a1622af2d23ebea9923f6edb1678" + }, + { + "path": "agents/opencode/assets/herdr-agent-state.js", + "sha256": "1a6aebf6632324fec63d1de226737b9fa6b35f3ddb51ce687439e66b6c7cb475" + }, + { + "path": "agents/opencode/assets/herdr-tui-session.js", + "sha256": "f9b5c2db169b0f0c38a78bc2c8341178673bc0d00e5f7057765aea73ddc57f95" + }, + { + "path": "agents/opencode/detection.toml", + "sha256": "cb7ec791eb082a4da0f334804f2b361aa1a0a8bfc80e4a280dcfae20942402e3" + }, + { + "path": "agents/opencode/integration.toml", + "sha256": "bd388024a4201994bac951cf5d693210c38a543bf1d2a64ce657af963267a376" + }, + { + "path": "agents/opencode/process.toml", + "sha256": "bb2da1f7482e89b57c878cfe4f80f55612fb9bb3456a65fa7f036bfbb9ce8b90" + }, + { + "path": "agents/opencode/resume.toml", + "sha256": "c861467807b157270620d994b1b3cba6c2d665c637c1fbc97893d27b1e9d3f31" + }, + { + "path": "agents/pi/agent.toml", + "sha256": "a1f263905a6753d6346a8f57f3b7fe75038197e1b726b4d59c6446645cab8fd1" + }, + { + "path": "agents/pi/assets/herdr-agent-state.ts", + "sha256": "2c5272d732b475bbf91a027203b1f98d25fe43d2c1402530a442b288aeaca1e4" + }, + { + "path": "agents/pi/detection.toml", + "sha256": "d852f52d637040fad207a395928dc190d99d24b6c72d6e1b7fbbf4487a416e40" + }, + { + "path": "agents/pi/integration.toml", + "sha256": "6d431e1950ca31951e53b82dcfaa0d512c1a1399d58dee1ee2173ba88f101420" + }, + { + "path": "agents/pi/process.toml", + "sha256": "2f4524afc2870aa80ae2f03df5985b8980f73b4376ab5ab361c3a8aecc9623da" + }, + { + "path": "agents/pi/resume.toml", + "sha256": "9282ea526f54d4131e1557dc2e733d13f251de7a91b67c00da48343ae732af3b" + }, + { + "path": "agents/qodercli/agent.toml", + "sha256": "ccae165817356ab60ce5da26480cb98a66a78de0cdd7ef38e147e5040bc237bc" + }, + { + "path": "agents/qodercli/assets/herdr-agent-state.ps1", + "sha256": "6558902b7778d32702a8a2a375b2bd004ae3389de37f296e52cb0d20e6129577" + }, + { + "path": "agents/qodercli/assets/herdr-agent-state.sh", + "sha256": "fbcca789b1488b6ac6092e1143655184fb5c321207182b31ac17324b23e13132" + }, + { + "path": "agents/qodercli/detection.toml", + "sha256": "2089f70fc78c6576fd7128a00f4e7eedb85be81bde9ed73301b3b88000048961" + }, + { + "path": "agents/qodercli/integration.toml", + "sha256": "2b465f4d1a393eda6c801aa3fa466d2746d3a08496e68402b057a62535481ef8" + }, + { + "path": "agents/qodercli/process.toml", + "sha256": "3512aa956b4174b91e7e8f1b3213c6405cc1d33221970ce07d721bf6a80bbd3e" + }, + { + "path": "agents/qodercli/resume.toml", + "sha256": "823de1f80cbd9f050754a421ed8b6d231cd3fc6204e39beb9f6d75723debaeba" + }, + { + "path": "agents/qwen/agent.toml", + "sha256": "ee7d732194265d70ff8836b6d7069ce88de6d910704f82890e8541b87c7cd6a7" + }, + { + "path": "agents/qwen/assets/herdr-agent-session.ps1", + "sha256": "f3207f32d99a3517e0560de1d3352e6118e67dfea18fc31a44ff0394a0dfeafd" + }, + { + "path": "agents/qwen/assets/herdr-agent-session.sh", + "sha256": "db424e03eca6bef24f3e04cd41292128acf3b8bfd0e97d838a577b39da660075" + }, + { + "path": "agents/qwen/detection.toml", + "sha256": "b27aa456af228e8a4ceac74f0dd431c33b21473b69314fc672a7fba474e2a7fe" + }, + { + "path": "agents/qwen/integration.toml", + "sha256": "7cda658fbf6c1f767bf0a59e36158f31cbc337ab4a2b7c0c00560cb9b2e78842" + }, + { + "path": "agents/qwen/process.toml", + "sha256": "34edea01c7b645aab8812ded54bfb00d595de95b2196ace13ec9d9af70aed683" + }, + { + "path": "agents/qwen/resume.toml", + "sha256": "823de1f80cbd9f050754a421ed8b6d231cd3fc6204e39beb9f6d75723debaeba" + } + ] +}