artifact presenter & global Agents updates & plan for nono profiles

This commit is contained in:
jb
2026-08-26 21:57:56 -04:00
parent 645c38d43c
commit 51eace1fa8
7 changed files with 1723 additions and 91 deletions
+501 -39
View File
@@ -1,65 +1,527 @@
# harness-tools
Personal extensions and skills for [Pi](https://github.com/earendil-works/pi-mono).
Personal [Pi](https://github.com/earendil-works/pi-mono) extensions, skills, global agent instructions, sandbox profiles, and supporting services.
## Included extensions
## What this repository installs
- **dictate** — Linux-only local voice dictation through a socket-activated NVIDIA Parakeet service.
- **effort-controls** — `/effort` reasoning-level selection and `/fast` priority mode for eligible OpenAI models.
- **update** — `/update` and `--update` support for updating Pi and configured Pi packages.
[`config/pi-links.json`](config/pi-links.json) is the source of truth for the normal installation. The link manager exposes:
## Repository layout
- global agent instructions for Pi, Codex, and Claude;
- 34 shared skills under `~/.agents/skills/`;
- the `manage-pi-links` and `nono-profiles` project-only skills under `.pi/skills/`;
- five global Pi extensions under `~/.pi/agent/extensions/`:
- **dictate**: local Linux voice dictation;
- **effort-controls**: `/effort` reasoning selection and `/fast` priority mode;
- **pi-workflow**: persisted, gated feature workflows;
- **prompt-snippets**: per-message prompt rule toggles;
- **update**: `/update` and `--update` support.
```text
docs/ Usage guides
extensions/ Pi extensions
profiles/nono/ Repo-managed nono profiles, symlinked into ~/.config/nono/profiles
services/ Host-side services used by extensions
skills/ Pi skills (one directory with a SKILL.md per skill)
systemd/user/ Repository-managed systemd user units
```
The repository also contains `copy-all`, `file-search`, and `subagents` extensions, but the normal manifest does not install them globally. The `pptx`, `stop-slop`, `subagents`, and `xlsx` skills are also intentionally excluded. Archived resources under `__archived/` are never installed. Update `config/pi-links.json` deliberately if that policy changes.
The root `package.json` exposes the extensions and skills as a single Pi package.
## Fresh-machine setup
## Nono sandbox
The commands below reproduce the normal setup. They assume Linux, a systemd user session, and the current canonical checkout path. Pi and the shared skills work on other platforms, but the nono launcher and Dictate sections are Linux-specific.
Use `pi-sbx` to launch Pi with a nono profile selected from the current directory. Manage routes with `pi-sbx-profile set <profile> [directory]`; omitting the directory assigns the current working directory. See [`docs/nono.md`](docs/nono.md) for setup, profile settings, Internet tiers, multi-repository access, and the `nono-profiles` editing skill.
### 1. Install the base tools
## Install locally
Required:
- Git with access to `git.jb.codes`;
- Node.js 22.19 or newer with npm;
- Pi Coding Agent;
- a configured model provider.
Install Pi using its official npm package:
```bash
pi install /absolute/path/to/harness-tools
npm install -g --ignore-scripts @earendil-works/pi-coding-agent
pi --version
```
For a one-off development run:
A Bun global install also works, but record its executable path later when configuring `pi-sbx`.
Start Pi once and authenticate with a subscription or API-key provider:
```bash
pi
```
Run `/login` inside Pi. Pi stores its user state under `${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}`. When Pi runs through nono, prefer nono credential routes so real API keys do not enter the sandbox; see [Sandboxed Pi credentials](#sandboxed-pi-credentials).
### 2. Clone the repository
Several current profiles and instruction imports contain `/srv/data/code/harness-tools`, so using that path requires no repository edits:
```bash
if [ ! -e /srv/data/code ]; then
sudo install -d -o "$USER" -g "$(id -gn)" /srv/data/code
fi
test -d /srv/data/code && test -w /srv/data/code || {
echo '/srv/data/code already exists but is not a writable directory' >&2
exit 1
}
git clone ssh://git@git.jb.codes:7722/jb/harness-tools.git \
/srv/data/code/harness-tools
cd /srv/data/code/harness-tools
```
The setup creates `/srv/data/code` only when absent. If it already exists, keep its current ownership and obtain access through the machine's normal administration policy.
If the checkout lives elsewhere, replace the machine-specific paths before syncing:
- `docs/global.CLAUDE.md`;
- `profiles/nono/pi-local.json`;
- `profiles/nono/pi-orca-local.json`;
- `profiles/nono/pi-work.json`;
- the copied `~/.config/pi-sbx/config.json` described below.
Dictate currently targets `/run/user/1000/pi-dictate.sock` in both the extension and profile. It therefore expects UID 1000. Change and review the socket path before installation if `id -u` reports another UID.
### 3. Install repository dependencies
The linked TypeScript extensions resolve runtime dependencies from this checkout, so `node_modules` is required even though the extensions have no build step:
```bash
cd /srv/data/code/harness-tools
npm ci
npm run typecheck
```
Use `npm ci` after pulling a lockfile change. Node.js must satisfy the version in [`package.json`](package.json).
### 4. Install instructions, skills, and extensions
Preview the exact changes first, then apply them:
```bash
cd /srv/data/code/harness-tools
./scripts/pi-links.mjs sync --dry-run
./scripts/pi-links.mjs sync
./scripts/pi-links.mjs check
```
A successful check currently reports 44 links and zero local package entries. The manager:
- preserves unrelated global resources;
- refuses to overwrite regular files, directories, or links owned outside this repository;
- uses absolute links for global resources and relative links for project-only skills.
Resolve any reported collision manually instead of deleting it blindly. Run `/reload` in an active Pi session, or restart Pi, after the sync.
Do not also run `pi install /path/to/harness-tools` for the normal setup. That installs the root as a Pi package and applies the broader `package.json` resource globs, which is different from the curated `config/pi-links.json` policy and can duplicate resources. It remains useful for a temporary package-development test in an isolated Pi agent directory.
### 5. Verify Pi
```bash
pi
```
Inside Pi:
1. Run `/reload`.
2. Confirm `/effort`, `/snippets`, `/update`, and `/workflow` are available.
3. Ask Pi to list its available skills and confirm a shared skill such as `tdd` appears.
4. From this repository, confirm the project-only `manage-pi-links` and `nono-profiles` skills appear.
## Additional Pi packages from Gitea
Two extensions are maintained in separate Gitea repositories rather than `harness-tools`. Install them as Pi-managed Git packages after completing the canonical link sync above. Pi clones each package into its agent directory and installs its package dependencies.
Run these commands from a normal host terminal. A restrictive nono session may intentionally lack the SSH key or agent socket needed to clone private repositories.
### Verify Gitea SSH access
Both repositories use SSH on port 7722. Confirm access without changing either checkout:
```bash
GIT_TERMINAL_PROMPT=0 git ls-remote \
ssh://git@git.jb.codes:7722/jb/pi-interactive-subagents.git HEAD
GIT_TERMINAL_PROMPT=0 git ls-remote \
ssh://git@git.jb.codes:7722/jb/pi-todo-herdr.git HEAD
```
Each command should print a commit hash and `HEAD`. Configure the machine's SSH key and `git.jb.codes` host key before continuing if either command fails.
### Install both packages
```bash
pi install ssh://git@git.jb.codes:7722/jb/pi-interactive-subagents.git
pi install ssh://git@git.jb.codes:7722/jb/pi-todo-herdr.git
pi list
```
Confirm both Gitea URLs appear under `User packages`, then restart Pi or run `/reload`. These package entries are independent of `config/pi-links.json`, so `./scripts/pi-links.mjs check` still reports zero local package entries.
Do not globally enable `harness-tools/extensions/subagents` at the same time as `pi-interactive-subagents`. They provide overlapping subagent tools. The curated harness-tools manifest leaves its in-repository implementation disabled for this reason.
### Configure interactive subagents
[`pi-interactive-subagents`](https://git.jb.codes/jb/pi-interactive-subagents) creates asynchronous child Pi sessions in Herdr tabs or tmux panes. It requires one terminal backend:
- **Herdr:** install its Pi integration, then launch Pi inside a Herdr-managed pane;
- **tmux:** launch Pi inside tmux when Herdr is unavailable.
For Herdr:
```bash
herdr integration install pi
herdr integration status
herdr
```
Start Pi in a Herdr pane. The extension detects `HERDR_ENV=1` and opens each subagent in an unfocused tab. The `pi-local` nono profile already declares the reviewed Herdr socket used on the original machine; update that machine-specific path if necessary.
For tmux:
```bash
tmux new -A -s pi 'pi'
```
The package bundles `scout`, `researcher`, and `worker` agent definitions. Their configured provider, model, thinking level, and tool allowlist live in the package's `agents/` directory. Authenticate the configured provider and confirm the named model is available. To override a bundled definition without editing Pi's managed Git clone, create a same-named file in `${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}/agents/` or a trusted project's `.pi/agents/`; project definitions take precedence over global definitions, which take precedence over package definitions.
Inside Pi, verify and try the extension:
1. Run `/reload`.
2. Ask Pi to call `subagents_list`.
3. Confirm `scout`, `researcher`, and `worker` are listed.
4. Run `/subagent scout Summarize this repository` from a disposable test checkout.
5. Confirm a Herdr tab or tmux pane opens and the completed result returns to the parent session.
The status widget is enabled by the package's example configuration when no local `config.json` exists. Copy and edit `config.json.example` beside the installed package only when a status override is needed. Treat Pi's managed Git checkout as disposable because package updates may reset it; prefer global or project agent overrides for durable customization.
### Configure Pi Todo Herdr
[`pi-todo-herdr`](https://git.jb.codes/jb/pi-todo-herdr) provides the `set_tasks`, `update_task`, `rm_task`, `list_task`, and `get_task` tools plus a session-local Pi task widget. Herdr is optional for the tools and widget.
Inside Pi after `/reload`:
1. Confirm the five task tools are available.
2. Run `/todo` to toggle the widget.
3. Ask Pi to create a small three-step test plan, update one task, and list the tree.
For Herdr sidebar task metadata, back up and edit the existing configuration:
```bash
cp -a ~/.config/herdr/config.toml \
~/.config/herdr/config.toml.before-pi-todo-herdr
"${EDITOR:-vi}" ~/.config/herdr/config.toml
```
If `[ui.sidebar.agents]` already exists, edit its existing `rows` value and add `"$task_progress"`, `"$task"`, and `"$task_count"` where desired. Do not add a duplicate TOML table header. If the table does not exist, append the complete block:
```toml
[ui.sidebar.agents]
rows = [["state_icon", "workspace", "tab"], ["$ask", "$ask_count"], ["$task_progress", "$task", "$task_count"]]
```
Apply and verify the change:
```bash
herdr server reload-config
herdr status client
```
### Update or remove the Gitea packages
Use the same source strings stored by Pi:
```bash
pi update ssh://git@git.jb.codes:7722/jb/pi-interactive-subagents.git
pi update ssh://git@git.jb.codes:7722/jb/pi-todo-herdr.git
```
Remove them with:
```bash
pi remove ssh://git@git.jb.codes:7722/jb/pi-interactive-subagents.git
pi remove ssh://git@git.jb.codes:7722/jb/pi-todo-herdr.git
```
Run `/reload` after an update or removal.
### Optional development checkouts
A Pi-managed package clone is enough for normal use. For source development, use separate durable checkouts so Pi package updates cannot discard work:
```bash
mkdir -p /srv/data/repo-store
git clone ssh://git@git.jb.codes:7722/jb/pi-interactive-subagents.git \
/srv/data/repo-store/pi-interactive-subagents
git clone ssh://git@git.jb.codes:7722/jb/pi-todo-herdr.git \
/srv/data/repo-store/pi-todo-herdr
npm ci --prefix /srv/data/repo-store/pi-interactive-subagents
npm test --prefix /srv/data/repo-store/pi-interactive-subagents
npm ci --prefix /srv/data/repo-store/pi-todo-herdr
npm run typecheck --prefix /srv/data/repo-store/pi-todo-herdr
npm test --prefix /srv/data/repo-store/pi-todo-herdr
```
Both repositories are separate projects. Keep their changes and releases out of the `harness-tools` checkout.
## Companion tools used by installed skills
The link sync installs skill instructions, not every external program those skills may call. Install the companion tools you intend to use.
### OpenSrc for dependency research
`code-research` and `package-source` require the `opensrc` CLI and intentionally use `/srv/data/opensrc` as the single source cache:
```bash
npm install -g --prefix "$HOME/.local" opensrc
if [ ! -e /srv/data/opensrc ]; then
sudo install -d -o "$USER" -g "$(id -gn)" /srv/data/opensrc
fi
test -d /srv/data/opensrc && test -w /srv/data/opensrc || {
echo '/srv/data/opensrc already exists but is not a writable directory' >&2
exit 1
}
if [ ! -e "$HOME/.opensrc" ] && [ ! -L "$HOME/.opensrc" ]; then
ln -s /srv/data/opensrc "$HOME/.opensrc"
fi
command -v opensrc
readlink -f "$HOME/.opensrc"
```
The setup preserves existing directory ownership and any existing `~/.opensrc`. If that path already exists, inspect it instead of replacing it. Completion requires it to resolve to `/srv/data/opensrc`.
If `~/.opensrc` already exists, inspect it instead of replacing it. Completion requires it to resolve to `/srv/data/opensrc`.
### Browser Harness
The `browser-harness` skill requires `uv`, Chrome or Chromium, and the Browser Harness CLI:
```bash
uv tool install --python 3.12 --upgrade --force browser-harness
browser-harness --doctor
browser-harness <<'PY'
print(page_info())
PY
```
For a Browser Use cloud session, authenticate separately with `browser-harness auth login`. Local browser control does not require a cloud key.
### Artifact presentation
The `artifact-presenter` skill uses [Herdr](https://herdr.dev) and [mcat](https://github.com/Skardyy/mcat). Install both and verify:
```bash
command -v herdr
command -v mcat
herdr --version
mcat --version
```
On Arch-based systems, mcat is available as the `mcat-bin` AUR package. Follow Herdr's installation guide for its CLI. The skill falls back to reporting the artifact path when either program is unavailable.
### PDF work
The `pdf` skill calls tools as needed rather than maintaining a repository environment. For its common workflows, install the relevant subset of:
- Python packages: `pypdf`, `pdfplumber`, `reportlab`, `pymupdf`, `pytesseract`, and `pdf2image`;
- system tools: Poppler (`pdftotext`, `pdfimages`, and `pdftoppm`), `qpdf`, and Tesseract OCR.
The intentionally excluded `pptx` and `xlsx` skills have additional office and rendering dependencies and are not part of the normal setup.
## Optional Linux sandbox setup
The repository-managed [nono](https://github.com/always-further/nono) profiles and `pi-sbx` launcher are optional, but they reproduce the normal restricted Pi runtime.
### Install nono and its Pi package
Install the `nono` CLI using its upstream instructions, then pull the parent Pi profile:
```bash
nono pull nolabs-ai/pi
nono profile show nolabs-ai/pi
```
### Link the repository profiles
```bash
cd /srv/data/code/harness-tools
mkdir -p ~/.config/nono/profiles
for profile in pi-local pi-orca-local pi-harness-tools pi-work; do
source="$PWD/profiles/nono/$profile.json"
destination="$HOME/.config/nono/profiles/$profile.json"
if [ -L "$destination" ] && \
[ "$(readlink -f "$destination")" = "$(readlink -f "$source")" ]; then
continue
fi
if [ -e "$destination" ] || [ -L "$destination" ]; then
echo "Refusing to replace existing profile: $destination" >&2
exit 1
fi
ln -s "$source" "$destination"
done
for profile in pi-local pi-orca-local pi-harness-tools pi-work; do
nono profile validate "$profile"
done
```
The loop is rerunnable for links already targeting this checkout and refuses every other collision.
These profiles inherit from one another, so install and validate all four even if only `pi-local` is used initially. Review their filesystem paths, Orca integration paths, socket grants, network allowlist, and work-specific Git path on every new machine.
### Install the routed launcher
`pi-sbx` is a Fish script. Install Fish, then link the launch commands and systemd slice:
```bash
cd /srv/data/code/harness-tools
mkdir -p ~/.local/bin ~/.config/pi-sbx ~/.config/systemd/user
safe_link() {
source="$1"
destination="$2"
if [ -L "$destination" ] && \
[ "$(readlink -f "$destination")" = "$(readlink -f "$source")" ]; then
return 0
fi
if [ -e "$destination" ] || [ -L "$destination" ]; then
echo "Refusing to replace existing path: $destination" >&2
return 1
fi
ln -s "$source" "$destination"
}
safe_link "$PWD/scripts/pi-sbx.fish" "$HOME/.local/bin/pi-sbx"
safe_link "$PWD/scripts/pi-sbx-profile.py" "$HOME/.local/bin/pi-sbx-profile"
safe_link "$PWD/scripts/pi-orca.fish" "$HOME/.local/bin/pi-orca"
safe_link "$PWD/scripts/orca-nono-profile.py" "$HOME/.local/bin/orca-nono-profile"
safe_link "$PWD/systemd/user/pi-agents.slice" \
"$HOME/.config/systemd/user/pi-agents.slice"
if [ ! -e "$HOME/.config/pi-sbx/config.json" ]; then
cp "$PWD/config/pi-sbx.example.json" "$HOME/.config/pi-sbx/config.json"
chmod 600 "$HOME/.config/pi-sbx/config.json"
else
echo 'Keeping existing ~/.config/pi-sbx/config.json'
fi
systemctl --user daemon-reload
```
The setup preserves an existing launcher, systemd unit, or routing configuration unless it already links to this checkout. Resolve reported collisions manually after inspecting both paths.
Edit `~/.config/pi-sbx/config.json` before launching. Set:
- `nodeExecutable` to `readlink -f "$(command -v node)"`;
- `piExecutable` to the real Pi executable path;
- `piAgentDirectory` to the matching Pi state directory;
- directory routes to paths that exist on this machine;
- profile-specific runtimes only when a different Node ABI or Pi state directory is intentional.
The checked-in example contains paths from the original machine and is a template, not a portable final configuration. Separate Pi agent directories are required when profiles use incompatible Node ABIs with native dependencies.
Verify and launch:
```bash
pi-sbx-profile list
pi-sbx-profile show
pi-sbx-profile set pi-local /path/to/a/project
pi-sbx-profile resolve /path/to/a/project
pi-sbx
```
See [`docs/nono.md`](docs/nono.md) for routing behavior, multi-repository grants, Internet tiers, profile editing, and troubleshooting denials.
### Sandboxed Pi credentials
The base `nolabs-ai/pi` package supports mediated credential routes. Store secrets in the OS keychain or another nono-supported secret source, then enable only the required route names in a reviewed child profile. For example, supported built-in route names include `openai`, `anthropic`, `gemini`, `github`, and `gitlab`.
Do not place real provider keys in files readable by sandboxed Pi if the goal is credential mediation. Consult the installed package guide after `nono pull`:
```text
~/.config/nono/packages/nolabs-ai/pi/README.md
```
## Optional Dictate service
Dictate is Linux-only and requires:
- PipeWire with `pw-record`;
- systemd user services;
- an NVIDIA GPU with a working CUDA runtime;
- `uv`;
- network access for the initial locked dependency and model download;
- UID 1000 unless the hardcoded socket path is changed first.
Install the host service from the repository root:
```bash
cd /srv/data/code/harness-tools
./services/dictate/install.sh
systemctl --user status pi-dictate.socket pi-dictate.service
```
The installer provisions the locked Python environment, verifies the pinned model, installs host-owned systemd units, and enables socket activation. Initial storage is several gigabytes. To reuse an existing model archive or preview the operation:
```bash
./services/dictate/install.sh --model-source /path/to/parakeet-tdt-0.6b-v3.nemo
./services/dictate/install.sh --dry-run --model-source /path/to/model.nemo
```
Restart a sandboxed Pi after profile or socket changes. In Pi, press `Alt+M` to start or stop dictation and `Alt+N` to cancel. See [`extensions/dictate/README.md`](extensions/dictate/README.md) for privacy, dictionary, model, and troubleshooting details.
## Maintenance
After pulling repository changes:
```bash
cd /srv/data/code/harness-tools
git pull --rebase
npm ci
./scripts/pi-links.mjs sync --dry-run
./scripts/pi-links.mjs sync
./scripts/pi-links.mjs check
npm run typecheck
```
Run `/reload` after skill, extension, instruction, or separately installed Gitea package changes. Re-run `./services/dictate/install.sh` when Dictate's host service or protocol changes.
Before removing repository-managed exposure, preview exactly what will be deleted:
```bash
./scripts/pi-links.mjs prune --dry-run
```
Only run `./scripts/pi-links.mjs prune` after reviewing every removal. The manager prunes repository-owned exposure and leaves unrelated global resources alone.
## Development and checks
Extensions are TypeScript modules loaded directly by Pi; no build step is required.
```bash
npm ci
npm run typecheck
npm test
```
For a one-off extension development run:
```bash
pi -e ./extensions/effort-controls
pi -e ./extensions/update
```
## Development
## Repository layout
Node.js 22.19 or newer is required by the current Pi development dependencies.
```bash
npm install
npm run typecheck
```text
config/ Installation and launcher manifests
docs/ Usage guides, plans, and ADRs
extensions/ Pi extensions
profiles/nono/ Repository-managed nono profiles
scripts/ Link manager and sandbox launch helpers
services/ Host-side services used by extensions
skills/ Pi skills, one SKILL.md per skill
systemd/user/ Repository-managed systemd user units
```
Extensions are TypeScript modules loaded directly by Pi; no build step is required.
Repository-managed global instructions, skills, and extension exposure are declared in [`config/pi-links.json`](config/pi-links.json) and reconciled by one script:
```bash
./scripts/pi-links.mjs check
./scripts/pi-links.mjs sync --dry-run
./scripts/pi-links.mjs sync
./scripts/pi-links.mjs prune --dry-run
```
The repository-local `manage-pi-links` skill applies this workflow. Global instructions are linked to Pi and Codex as `AGENTS.md`; Claude receives a `CLAUDE.md` that imports the shared instructions. The manager preserves unrelated global resources and refuses unmanaged destination collisions. Run `/reload` after changing active Pi instruction, skill, or extension exposure.
## License
MIT
+513
View File
@@ -0,0 +1,513 @@
# Dynamic nono permissions implementation plan
## Purpose
Replace the current directory-selected static Pi profiles with a trusted launcher that composes least-privilege permissions from reviewed capability bundles. The rollout must preserve normal development workflows while separating personal, work, maintenance, and high-risk capabilities.
This plan turns the findings in `/tmp/nono-dynamic-permissions-audit.md` into implementation slices. It does not authorize profile activation, credential changes, or production access by itself. Each policy change still requires a resolved-policy review before activation.
## Current implementation anchors
The work starts from these repository-owned surfaces:
- `scripts/pi-sbx-profile.py` validates the launcher configuration and selects a static profile by longest directory match.
- `scripts/pi-sbx.fish` launches the selected profile and Pi runtime inside the `pi-agents.slice` systemd scope.
- `config/pi-sbx.example.json` documents the current directory-to-profile configuration.
- `scripts/test_pi_sbx_profile.py` and `scripts/test_pi_sbx_launcher.py` cover routing and launch validation.
- `profiles/nono/*.json` contains the repository-managed static profiles.
- `docs/nono.md` and the tracked source at `skills/tools/nono-profiles/` document profile operations; `.pi/skills/nono-profiles` is a generated link and is not edited directly.
Known drift must be treated as an input to the rollout: the active `pi-local` and `pi-work` profiles are repository symlinks, while active `pi-orca-local` and `pi-harness-tools` are regular files that may differ from Git.
## Outcomes and security invariants
The rollout is complete when normal Pi sessions have only the capabilities required by the active repository, provider, enabled integration set, and explicitly selected launch mode.
Every slice must preserve these invariants:
1. **Reviewed grants only.** Repository files may suggest capabilities, but only the trusted catalog in this repository may grant them.
2. **Trusted pre-sandbox code.** The production launcher and catalog run from a user-owned, maintenance-installed snapshot that normal Pi sessions cannot modify. Environment overrides, writable source checkouts, and arbitrary installed profile names cannot replace production inputs.
3. **Restrictive composition.** The new baseline derives from nono's `default` profile rather than the broad `nolabs-ai/pi` profile. Additive inheritance must not be used to claim that a child narrows its parent.
4. **Isolated state.** Normal personal and work sessions receive separate Pi state, credentials, SSH agents, caches where required, one exact per-launch temporary directory, and exact state and active-worktree access from the trusted repository map. Core fragments contain no worktree grant, so a read-only repository cannot inherit read/write access. A session receives no broad `/tmp`, `$TMPDIR`, runtime-session parent, sibling-worktree, or shared worktree-root grant. Extension source and global instructions remain read-only.
5. **Command-scoped privilege.** Git, SSH, GitHub CLI, package managers, child agents, deployment tools, and maintenance commands receive their own command policies. The outer Pi process does not inherit their credentials or broad network access.
6. **Controlled in-process extensions.** A normal session loads only its reviewed extension allowlist. Privileged extension operations are disabled or moved to controlled subprocesses because nono cannot isolate extensions inside Pi.
7. **Explicit high-risk modes.** Voice, browser, Docker, release, production, and maintenance access require an explicit launch flag and appear in the printed permission plan.
8. **Fail closed.** Unknown repositories, capabilities, providers, extensions, dynamic endpoints, malformed configuration, or invalid generated profiles stop the launch with an actionable error. There is no implicit default profile for an unknown repository.
9. **Verifiable policy.** Generated profiles are mode `0600`, live under a caller-owned `$XDG_RUNTIME_DIR`, pass `nono profile validate --strict`, and can be inspected with `nono profile show`.
10. **Negative evidence.** Every capability has at least one test proving the corresponding access is absent when the capability or mode is not selected.
## Boundaries
### In scope
- Trusted capability catalog and canonical repository mapping.
- Per-launch nono profile generation, validation, explanation, and caching.
- Explicit environment filtering.
- Command policies for development tools and child processes.
- Exact local socket and loopback endpoint grants.
- Separate normal and privileged launch modes.
- Migration of `/srv/data/code`, `/srv/work`, and selected `/srv/data/repo-store` repositories.
### Deferred until their dedicated slices
- Docker, browser control, deployment, release, production credentials, and Pi/package updates.
- Automatic grants inferred from `package.json`, scripts, extension metadata, or repository-controlled manifests.
- Broad local-network access.
- Direct SSH from the outer Pi session.
## Delivery model
Use one worktree per slice. Durable linked worktrees live outside primary checkouts under `/srv/data/worktrees/<repository-id>/<worktree-id>`; `/run` is reserved for disposable session state. Assign the file ownership below before implementation so parallel branches do not edit the same files. Shared integration files are updated only by the named integration slice or during a scheduled integration pass.
A slice is complete only when:
- its focused automated tests pass;
- every new or changed profile passes strict validation;
- positive and negative integration checks produce saved, secret-free evidence;
- required Linux host checks run with nono 0.74 rather than skip;
- credentialed live checks have separate, manually approved evidence when fixtures cannot prove the real route;
- affected documentation is updated;
- an agent other than the implementer reviews the changes.
## Work graph
```text
Slice 0: containment and drift reconciliation
|
v
Slice 1: policy contract and restrictive base ----+
Slice 2: test and evidence harness ---------------+---- run in parallel
| |
+--------------------+--------------------------+
v
Slice 3: catalog and profile generator
|
+--> Slice 4A: personal/work Git and SSH -----+
+--> Slice 4B: GitHub CLI credential broker --+---- run in parallel
| |
+---------------- pilot acceptance gate <-----+
|
+--------------------+--------------------------+---------------------+
v v v v
Slice 5A Slice 5B Slice 6A Slice 6B1
Node/Bun Python/Rust/Go/Java local integrations child isolation
| | | |
+--------------------+--------------------------+---------------------+
v
Slice 6B2: pi-workflow/toolchain broker
|
v
Slice 7A: Docker/browser Slice 7B: release/production Slice 7C: maintenance
run independently after explicit approval
|
v
Slice 8: repository migration batches and legacy-profile retirement
```
Slices in the same row may proceed in parallel after their listed dependencies are complete. A branch must not activate profiles or modify user credential stores as part of automated testing.
## Slice 0: Containment and profile drift reconciliation
**Goal:** establish a reviewable baseline before adding dynamic behavior.
**Owner surfaces:** existing `profiles/nono/*.json`, `config/pi-links.json`, a new `config/pi-package-lock.json`, and `docs/nono-profile-baseline.md`. Slice 3 alone owns edits to `docs/nono.md` during integration.
**Work:**
1. Compare every active `~/.config/nono/profiles/pi-*.json` entry with its repository counterpart, including resolved inheritance and saved grants.
2. Decide, with explicit user approval, whether each divergent active file is imported into Git or replaced by a repository symlink. Any temporary regular-file exception requires an owner, expiry date, resolved-policy evidence, and a tested restoration command.
3. Remove unapproved saved grants from the proposed repository policy, especially writes to global instructions, workflow state, extension source, and unrelated Pi state.
4. Inventory `~/.pi/agent/settings.json` and other active personal/work Pi settings into a secret-free lock record keyed by immutable package and extension source revisions. Treat `config/pi-links.json` as resource-link ownership, not as package-source authority. Define precedence and reconciliation between the lock record, active settings, and link configuration.
5. Inventory every file and marker under `~/.config/nono/profile-drafts/`. Validate its internal profile name against its filename, capture resolved grants, then either import an approved draft or move it to a timestamped quarantine outside nono's draft search path. The current `pi-local-dictate.json` mismatch and socket grants require explicit disposition.
6. Verify the installed `nolabs-ai/pi` package source, lock metadata, and active wiring. A `pinned: false` record or mismatch between installed content and lock state blocks normal activation.
7. Replace `latest`, unversioned registry entries, SSH Git package references, and floating Git URLs with reviewed immutable versions. Any unresolved floating source blocks normal-profile activation.
8. Define a maintenance-only path for `/update`, package installation, extension installation, managed links, and active-profile activation. Normal extension allowlists must exclude the `update` extension even though `config/pi-links.json` exposes its source.
9. Capture the current positive workflows that the new baseline must preserve without recording tokens, key material, or sensitive environment values.
**Acceptance criteria:**
- Every active Pi profile is either a repository symlink or a time-bounded exception with an owner, expiry, evidence, and restoration test.
- No normal repository profile includes an interactive saved grant that is absent from Git.
- Every active package, extension, and installed nono package source matches immutable lock metadata; unresolved floating or `pinned: false` sources block activation.
- Every pending nono draft is approved and imported or quarantined, and mismatched names or untracked socket grants remain inactive.
- The `update` extension and its command are unavailable in normal personal and work sessions.
- Maintenance tests prove tracked source files are promoted into generated/installed destinations rather than editing `.pi/skills` or other generated links directly.
- The current state can be restored from repository files and documented activation steps.
**Dependency:** none. This is the only slice that should modify existing active-profile definitions before the proof of concept.
## Slice 1: Policy contract and restrictive base
**Goal:** prove that Pi can start from a narrow baseline without inheriting broad `~/.pi`, network, socket, or service-launch grants.
**Owner surfaces:** `profiles/nono/pi-core-personal.json`, `profiles/nono/pi-core-work.json`, `profiles/nono/provider-*.json`, `extensions/lm-studio/`, `docs/adr/<dynamic-nono-composition>.md`, `skills/tools/nono-profiles/`, and `tests/nono/capabilities/core/`. This slice does not own the catalog schema, launcher routing, or shared test helpers.
**Work:**
1. Confirm nono 0.74's installed schema and behavior for multiple `extends`, `command_policies`, environment policy, caller relationships, credential routes, loopback ports, and strict validation.
2. Record the chosen composition contract in a short ADR before locking the catalog schema. Update the existing nono skill documentation where it incorrectly assumes one parent.
3. Create `pi-core-personal` and `pi-core-work` baselines derived from `default` with:
- exact runtime, reviewed extension, instruction, configuration, and session-state reads;
- exact mutable session/config/state paths;
- no worktree grant in the reusable core fragment; handwritten composition fixtures add the active worktree as read-only or read/write exactly as the future trusted repository map declares;
- one exact per-launch temporary directory supplied by the trusted launcher, with no broad `/tmp` or `$TMPDIR` grant;
- an explicit environment allowlist that removes raw credential names from the outer process, including `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GITHUB_TOKEN`, `GH_TOKEN`, `MORPH_API_KEY`, and unselected `SSH_AUTH_SOCK`; the trusted provider catalog owns the complete tested list;
- one selected provider route;
- no package registries, SSH, Docker, browser, microphone, deployment credentials, or broad localhost access.
4. Separate extension source from extension state. Confirm that normal sessions cannot modify global instructions, installed extension code, authentication state, other Pi profiles, or sibling repositories.
5. Materialize the normal in-process extension allowlist as a generated, isolated Pi `settings.json` under the selected personal/work state root. Its package/extension entries come only from `config/pi-package-lock.json` and the trusted repository map. Disable `pi-ask` arbitrary notification commands, `/update`, `pi-workflow`, background shell jobs, and any privileged direct spawn path until a controlled subprocess policy exists.
6. Import the unmanaged `~/.pi/agent/extensions/lm-studio.ts` implementation into a repository-managed extension at `extensions/lm-studio/index.ts`, lock its source revision, and change its fixed base URL contract to `http://127.0.0.1:1234/v1`. Maintenance moves the old extension to the quarantined state store and removes its active settings entry. A duplicate-provider test fails if the old source, another `lm-studio` registration, or `localhost` base URL remains loadable.
7. Define and own stable profile fragments for `provider-lm-studio`, `provider-openai`, `provider-anthropic`, and `provider-morph`, granting only one route at launch. Prove nono 0.74's `tcp_connect_ports` behavior with real loopback fixtures listening on ports 1234 and 1235: the selected provider must connect to 1234 while 1235 and every other loopback port fail. Do not use hostname-only `allow_domain` as evidence of port restriction. If the fixture cannot prove exact enforcement, `provider-lm-studio` remains unavailable until a separately reviewed mediated relay is designed.
**Acceptance criteria:**
- A minimal Pi session starts for both personal and work state roots.
- The selected provider works while non-selected provider hosts, raw key environment names, and credentials are absent from the environment and process table.
- Reads outside the exact Pi state, declared active-worktree access, and per-launch temporary paths fail; writes to a read-only active worktree fail; another session's temporary directory is inaccessible.
- LM Studio, when selected, reaches only `127.0.0.1:1234`.
- `/update`, `pi-workflow`, arbitrary notification commands, and unbrokered background shell jobs are unavailable.
- The repository-managed LM Studio provider uses only the exact `127.0.0.1:1234` base URL, the old unmanaged source is quarantined, duplicate registration fails, and a real two-port fixture proves all other loopback ports are denied.
- All base and provider profiles pass strict validation and resolved-policy review.
**Dependency:** Slice 0's baseline inventory. It may run in parallel with Slice 2.
## Slice 2: Integration test and evidence harness
**Goal:** make least privilege measurable before command brokers are implemented.
**Owner surfaces:** `tests/nono/harness/`, `tests/nono/fixtures/core/`, `scripts/test_nono.py`, `scripts/nono-evidence.py`, and the evidence schema at `config/nono/evidence.schema.json`. Capability slices own only `tests/nono/capabilities/<capability>/`. Slice 3 owns any root package-script integration.
**Work:**
1. Build fixture repositories, isolated temporary HOME/XDG directories, fake sockets, fake provider endpoints, and stub executables. Tests must not depend on personal keys or live tokens.
2. Add helpers that accept handwritten fixture profiles, run `nono profile validate --strict`, inspect the resolved policy, execute a command, and record a redacted result. Slice 3 later supplies the production generator through a stable adapter rather than duplicating generation here.
3. Encode the audit's required negative matrix:
- broad `/tmp`, the runtime-session parent, and another live or abandoned session's temporary directory denied;
- sibling linked worktrees and unrelated durable worktree roots denied;
- direct SSH denied;
- personal key route absent from work and work key route absent from personal;
- sibling repositories and unrelated Pi state inaccessible;
- global instructions and extension source read-only;
- Docker, browser control, dictation, `/update`, and production credentials absent in normal mode;
- non-selected provider routes and known raw provider-key environment names absent.
4. Encode synthetic positive fixtures for future Git, GitHub CLI, package-manager cache, exact provider-port, and exact socket policies. Mark deferred capability positives as fixture contract tests, not proof that the production capability exists.
5. Separate hermetic tests, mandatory Linux/nono 0.74 host tests, and manually approved live credential tests. Missing nono or a wrong version fails the security gate; optional local services may skip only their own capability check with a recorded reason.
6. Implement source at `scripts/nono-evidence.py`, but execute it only as the digest-verified `pi-sbx-evidence` command from the installed trusted snapshot. It supports `run <test-id>` and `summarize <run-id>`. Raw mode-`0600` evidence lives under `$XDG_STATE_HOME/pi-sbx/evidence/<run-id>/` for 30 days. A redacted, schema-validated summary is written to `docs/nono-migration/evidence/<repository-id>/<policy-digest>/<run-id>.json` and records schema version, repository/commit, policy and installed-snapshot digests, nono version, test IDs/results, approver, timestamps, and hashes rather than raw command output.
7. Add canary-secret tests covering environment values, arguments, stdout/stderr, and exception text. Summary generation fails if a canary or configured credential-name pattern survives redaction.
**Acceptance criteria:**
- One command runs the hermetic negative suite.
- The pilot gate requires the Linux/nono 0.74 suite to execute with zero skips in core, Git/SSH, and GitHub CLI policy checks.
- Live credentialed Git and `gh` evidence is collected separately through explicit manual approval and is never replaced by a skipped test.
- A deliberately broad fixture causes at least one negative test to fail, proving the harness detects over-granting.
- Test output identifies the capability, expected policy, and actual denial or allowance.
- Evidence summaries validate against the schema, contain no canary secrets, and can be traced to retained raw evidence during the 30-day review window.
**Dependency:** Slice 0 only. Slice 1 supplies real profiles later, but harness development can start against fixtures.
## Slice 3: Trusted catalog and per-launch profile generator
**Goal:** replace static directory routing with reviewed capability composition and a validated runtime profile.
**Owner surfaces:** `scripts/{pi-sbx,pi-orca}.fish`, `scripts/{pi-sbx-profile,orca-nono-profile}.py`, `config/pi-sbx.example.json`, `config/nono/schema.json`, `config/nono/catalog.json`, `config/nono/repositories.json`, `tests/nono/generator/`, `scripts/test_pi_sbx_*.py`, root package-script integration, `systemd/user/pi-agents.slice`, new session-runtime and garbage-collection units under `systemd/user/`, `README.md`, and `docs/nono.md`.
**Work:**
1. Define a versioned catalog schema for capability fragments, repository assignments, provider selection, extension requirements, privileged modes, and runtime selection.
2. Define the only provider-selection grammar as `pi-sbx [launcher options] [--provider <id>] -- [Pi arguments]`. The flag may occur once and only before `--`; `--provider=<id>`, provider/model selectors after `--`, environment selectors, and child overrides are rejected. Without the flag, the trusted repository entry must name exactly one `defaultProvider`; the launcher fails if it is missing or ambiguous. A flag may choose only from that entry's `approvedProviders`. The launcher injects the resolved provider/model into Pi and exports immutable `PI_SBX_PROVIDER` metadata to child-policy generation so children inherit the route rather than reparsing Pi arguments.
3. Install the production launcher, catalog, schema, repository map, approved fragments, and evidence producer as an atomic, versioned snapshot under a user-owned libexec/config path that normal Pi profiles cannot write. The checkout is only a development source; maintenance promotes a reviewed digest.
4. Remove production trust in `PI_SBX_PROFILE_COMMAND`, arbitrary `PI_SBX_CONFIG`, inherited `PI_SBX_PROFILE`/`ORCA_NONO_PROFILE`, and legacy `set <installed-profile>`. Tests use a separate test entry point or explicit test-only API that production launch code cannot enable from inside a sandbox. Add tamper tests for environment overrides, argument-boundary confusion, symlinks, writable catalogs, and launches from writable `harness-tools`.
5. Resolve the physical Git worktree root, Git common directory, nested-repository boundary, and submodule relationship. Match only canonical primary checkouts and durable worktrees from the trusted map. Durable linked worktrees use `/srv/data/worktrees/<repository-id>/<worktree-id>` and remain outside both the primary checkout and `/run`; unknown repositories and unregistered worktrees fail closed with no `pi-orca-local` or other implicit fallback.
6. Treat repository manifests and enabled extensions as compatibility checks only. A requested or discovered capability that is not approved for the canonical repository stops launch.
7. Materialize dynamic values before entering the sandbox, including the Orca hook port, exact Unix sockets, selected SSH-agent socket, isolated Pi state root, and a cryptographically unique session ID.
8. Require `$XDG_RUNTIME_DIR` to resolve to `/run/user/<uid>`, be owned by the launching UID, and have no symlinked path component. Create `/run/user/<uid>/pi-sbx`, `/run/user/<uid>/pi-sbx/cache`, and the session runtime parent as mode `0700` directories opened with no-follow semantics. Never grant the sandbox a runtime parent directory.
9. Launch Pi through a transient user service that owns `/run/user/<uid>/pi-sbx/sessions/<session-id>/tmp` as a mode-`0700` runtime directory. Set `TMPDIR` to that exact path and grant only that path to the generated profile. A trusted supervisor holds an exclusive lock on a no-follow mode-`0600` lease file for the complete session lifetime and records the boot ID, supervisor PID/start time, and cgroup path/identity. Use service-managed runtime-directory cleanup, with a trusted `ExecStopPost` fallback if required by the installed systemd version, so normal exit, cancellation, and process crashes remove the directory; the sandbox cannot select, preserve, or clean its own runtime path.
10. Add defense-in-depth cleanup for user-manager or host failures. A trusted garbage-collection service runs at launcher startup and on a timer. It considers a mode- and owner-validated session directory abandoned only after at least 24 hours and only when it can acquire the lease lock, the recorded PID/start-time identity is absent, the recorded cgroup is absent or reports `populated 0`, and no matching live systemd unit exists. Any malformed identity, unavailable check, disagreement, or evidence of a live process preserves the directory and reports it for maintenance review. `/run/user/<uid>` termination and reboot provide the final cleanup layer; logout alone is not considered sufficient when lingering user services are enabled. Prove these liveness checks and an enforceable per-session runtime-space bound on the target host before activation; if either cannot be enforced, fail the pilot rather than risking live-session deletion or exposing unrestricted scratch space under `/run`.
11. Canonically serialize the profile and cache it at `/run/user/<uid>/pi-sbx/cache/<sha256>.json`. The digest includes normalized generated policy, installed launcher/catalog/wrapper/fragment digests, generator build digest, nono version/schema, package/extension lock state and source revisions, repository/worktree/common-directory identity, provider, runtime, mode flags, and dynamic endpoints. Document excluded runtime metadata.
12. Write a mode-`0600` temporary file in the same cache directory, flush it, atomically rename it, then reopen with no-follow semantics. On every cache hit, require a regular file owned by the launching UID with mode `0600`, rehash the file and every installed input, rerun every selected dynamic endpoint validator and authenticated health check, and rerun `nono profile validate --strict`. Recheck device, inode, size, owner, mode, content digest, and dynamic endpoint identity immediately before `nono run`; any change deletes the entry and fails the launch rather than regenerating silently. Add concurrent-writer, symlink, hard-link, replacement, partial-write, stale-version, endpoint-replacement, and tampered-content tests.
13. Print a concise permission plan containing repository, active worktree, temporary directory, provider, state root, capabilities, privileged flags, dynamic sockets/ports, installed snapshot digest, and generated-profile path. Add a JSON format for tests and diagnostics.
14. Add explicit flags for `--voice`, `--browser`, `--docker`, `--release`, `--production`, and `--maintenance`. Initially, unimplemented high-risk flags fail with an explanatory error rather than silently degrading.
15. Preserve compatibility exports only where required. Make `PI_SBX_PROFILE` and `ORCA_NONO_PROFILE` refer to the generated policy in a form consumers can resolve.
16. Retain `pi-sbx-profile show`, `list`, and `resolve` as read-only views of the installed trusted map. Remove `set`, `unset`, and `set-root`; `orca-nono-profile.py` returns a migration error for mutating commands and forwards only read-only commands, while `pi-orca.fish` forwards to `pi-sbx` without selecting a profile.
**Acceptance criteria:**
- The same reviewed inputs produce byte-identical canonical policy apart from documented excluded runtime metadata.
- Cache invalidation occurs when generator code, nono version/schema, base/fragment content, package lock, extension revision, or any other policy input changes.
- Unknown repositories, unapproved or ambiguously selected providers, provider/model flags after `--`, child provider overrides, extensions, capabilities, malformed dynamic endpoints, unsafe runtime directories, writable installed inputs, and tampered overrides fail before `nono run`.
- Linked worktrees, shared Git common directories, nested repositories, and submodules have explicit isolation tests before their mappings are approved; unregistered and sibling worktrees remain inaccessible.
- The launcher never accepts a repository-controlled profile path or capability definition.
- Every session receives one exact private `TMPDIR`; broad `/tmp`, runtime parents, and other sessions' paths are denied. Normal exit and cancellation remove the directory; crash leftovers expire only after lease, PID/start-time, cgroup, and unit checks all prove inactivity; unknown or conflicting liveness preserves data; and the tested aggregate space bound stops an active session from exhausting `/run`.
- Cache hits pass strict validation and content rehash; tampered entries and every tested filesystem race fail before `nono run`.
- Existing compatibility commands have the restricted read-only semantics above, and all launcher/profile tests pass.
**Dependencies:** Slices 1 and 2.
### Pre-pilot cutover gate
Slice 3 prepares and tests this transaction, but maintenance executes it only after the required Slice 4A and applicable Slice 4B implementations are ready for pilot use. The cutover tool writes a mode-`0600` journal at `$XDG_STATE_HOME/pi-sbx/cutover.json`, stages a complete version directory, and changes one `current` symlink with an atomic rename only after every preflight passes.
**Staged transaction:**
1. Resolve `nono` to one approved absolute path and digest. Stage the launcher/catalog/evidence snapshot, schema-v2 config, `pi-agents.slice`, personal/work Pi state roots and generated settings, global managed links, compatibility commands, and rollback manifest under a new version ID.
2. Run staged postcondition checks: strict profile validation; owner/mode/digest checks; `scripts/pi-links.mjs check`; user-systemd unit validation; personal/work state separation; no checkout symlink in executable resolution; and no stale install instructions in `README.md`, `docs/nono.md`, or `config/pi-sbx.example.json`.
3. Replace each repository legacy profile (`pi-local`, `pi-orca-local`, `pi-work`, and `pi-harness-tools`) with a validated retired deny profile that has no workdir, network, socket, credential, URL-opening, or service-launch capability. Move broad active, draft, and rollback copies into a host-only mode-`0700` store outside every normal profile grant.
4. Stage `~/.local/bin/pi-sbx` and `pi-sbx-profile` links to the versioned snapshot. Stage `pi-orca` and `orca-nono-profile` as the restricted read-only compatibility entry points. Stage schema-v2 config with no `defaultProfile` or arbitrary profile-name mapping.
5. Record every old/new path, device/inode, digest, owner/mode, systemd unit, Pi state root, global link, and intended postcondition in the journal. Flush staged files and the journal, then atomically flip the `current` version pointer and reconcile the four command links.
6. Run the mandatory pre-pilot test against names and paths. Prove old config, checkout symlinks, inherited `PI_SBX_PROFILE`/`ORCA_NONO_PROFILE`, repository profile paths, absolute and symlinked draft/quarantine/rollback paths, legacy mutating commands, compatibility wrappers, direct `nono run --profile <legacy-or-path>`, and frozen socket-bearing profiles cannot launch a usable Pi policy.
7. Verify final postconditions for the approved `nono` binary, `pi-agents.slice`, state roots, global links, commands, documentation, active nono profile directory, retired repository profiles, and installed snapshot. Record the test/evidence IDs in the journal and mark it committed.
**Interrupted-cutover recovery:** before the pointer flip, delete the staged version and restore paths recorded in the journal. After the pointer flip, either complete reconciliation to the new version or atomically return `current` to the recorded prior version, restore command links and systemd state, and rerun the prior version's postconditions. An unresolved journal blocks every launch and prints the maintenance recovery command.
The pilot cannot begin until the committed journal and Slice 2 evidence prove every postcondition. The rollback store is inaccessible to normal profiles and can be used only through the host-authorized, expiring rollback procedure.
## Slice 4A: Personal/work Git and SSH command chains
**Goal:** support normal Git-over-SSH without giving Pi direct SSH or cross-context keys.
**Owner surfaces:** `profiles/nono/git-personal.json`, `profiles/nono/git-work.json`, `profiles/nono/ssh-personal.json`, `profiles/nono/ssh-work.json`, `config/nono/ssh-agents.json`, the trusted worktree registry under `config/nono/`, `systemd/user/pi-sbx-ssh-{personal,work}.service`, new sources under `scripts/nono/{git-ssh,worktrees}/`, and `tests/nono/capabilities/{git-ssh,worktrees}/`. This slice does not edit the generator schema or catalog index.
**Work:**
1. Implement separate caller chains:
- `session -> git-personal -> ssh-personal -> personal agent`
- `session -> git-work -> ssh-work -> work agent`
2. Implement `pi-sbx-ssh-agent` and two user-systemd services owned by UID 1000. They start empty `ssh-agent` processes at `/run/user/1000/pi-sbx/ssh-personal.sock` and `/run/user/1000/pi-sbx/ssh-work.sock`, remove stale sockets on start/stop, and terminate at user-manager shutdown. `config/nono/ssh-agents.json` records for each context the exact private-key path, approved public-key SHA-256 fingerprint, host aliases, and socket path; these values require explicit user approval before installation.
3. Provide executable host procedures: `pi-sbx-ssh-agent start <context>`, `load <context>`, `status <context>`, and `stop <context>`. `load` derives and checks the public fingerprint before `ssh-add`, then verifies `ssh-add -l` contains exactly the approved fingerprint. `status` checks process ancestry, UID, socket type/owner/mode/inode, configured fingerprint set, and host mapping. The launcher runs `status` before selecting the socket and never inherits a caller-supplied `SSH_AUTH_SOCK`.
4. Define live acceptance as a host-invoked `pi-sbx-evidence run git-ssh-live-<context>` after interactive key loading. It performs approved fetch and dry-run/non-destructive push checks, verifies the remote host key and wrapper log digest, and records only fingerprints and result hashes. Missing approved key paths/fingerprints, extra loaded keys, stale agents, or unavailable live evidence block pilot acceptance.
5. Restrict Git filesystem access to nono Git macros for the active worktree, common directory, top level, and config files. Add parent access only to a trusted worktree broker for explicitly tested clone/worktree operations; the outer session never receives `/srv/data/worktrees` or a repository's worktree parent. Model linked worktrees, shared object stores, nested repositories, and submodules so common-directory access does not silently expose sibling checkout paths.
6. Implement the trusted worktree broker around `/srv/data/worktrees/<repository-id>/<worktree-id>`. It creates and registers canonical linked worktrees, records repository/branch/path/creator/time and active-unit identity in trusted state, and removes a worktree only after proving it is inactive, clean, free of untracked files, and not ahead of its reviewed upstream. Dirty, untracked, unpushed, ambiguous, or active worktrees require explicit maintenance review and are never deleted solely because of age. Run `git worktree prune` only after the checkout removal is verified.
7. Record that linked worktrees share refs and an object store: pathname isolation protects sibling checkout files but does not provide branch-level confidentiality. Repositories requiring confidentiality between tasks use separately registered clones with independent object stores instead of linked worktrees.
8. Deny direct SSH from the session. Use a maintenance-installed immutable SSH wrapper to enforce reviewed host aliases, ports, trusted `known_hosts`, and the appropriate dedicated agent socket. Treat nono's port restriction as defense in depth, not hostname enforcement.
9. Prevent alternate caller paths through `GIT_SSH`, `GIT_SSH_COMMAND`, repository/global/system Git config, aliases, hooks, credential helpers, alternate Git/SSH binaries, shell expansion, and same-port unapproved hosts.
10. Add HTTPS Git destinations separately from SSH destinations.
11. Test the observed personal `git.jb.codes:7722` and work `bitbucket.org:22` routes with safe fixtures before the live evidence procedure.
**Acceptance criteria:**
- Direct `ssh` and every tested Git/SSH bypass path fail from both sessions.
- Personal and work fixture keys cannot cross contexts; missing, stale, foreign-owned, or substituted agent sockets fail launch.
- Fixture fetch/push succeeds automatically, and live fetch/push succeeds through the correct agent after explicit manual approval in each pilot repository.
- Registered external worktrees work without granting their shared parent or sibling checkout paths; unsafe removal attempts preserve the checkout and report the blocking state; confidential-task fixtures use independent clones.
- Unapproved SSH hosts, same-port hosts, ports, command arguments, config overrides, hooks, alternate binaries, and agent sockets fail.
**Dependencies:** Slice 3 and the test harness from Slice 2. May run in parallel with Slice 4B.
## Slice 4B: GitHub CLI credential broker
**Goal:** support repository-scoped `gh` operations without exposing the real keyring token to Pi.
**Owner surfaces:** `profiles/nono/github-cli.json`, `config/nono/github-cli.json`, new broker sources under `scripts/nono/github-cli/`, and `tests/nono/capabilities/github-cli/`. This slice does not edit Git/SSH policy files or the catalog index.
**Work:**
1. Create `config/nono/github-cli.json` with required, user-approved fields for keyring service/account selector, GitHub host, repository owner/name, allowed REST operation templates, and asset hosts. The selector is resolved and tested by maintenance without recording the token; an unset, ambiguous, or changed selector blocks installation.
2. Implement the fixed boundary `session -> gh-wrapper command policy -> nono credential route -> api.github.com`. The nono route is the only component that resolves the real keyring token. It supplies an invocation-scoped phantom as `GITHUB_TOKEN` to the wrapper; the wrapper copies that phantom to `GH_TOKEN`, unsets `GITHUB_TOKEN`, and execs the pinned `gh` binary. The outer Pi process, wrapper arguments, and child commands never receive the real token.
3. Normal mode permits only REST operations beneath `https://api.github.com/repos/<approved-owner>/<approved-repo>/...` plus approved downloads from `objects.githubusercontent.com` and `release-assets.githubusercontent.com`. GraphQL, `/user`, organization, account, gist, package, and other repository endpoints are denied until separately modeled. A deferred `gh-admin` capability owns cross-repository operations.
4. Define a REST-only pilot fixture command set before activation: relative `gh api repos/<owner>/<repo>...` reads, approved issue/PR mutations through exact relative REST paths, and release asset download where needed. Record the exact CLI arguments, HTTP method/path templates, and response fixtures in `tests/nono/capabilities/github-cli/`; any unmodeled request fails closed.
5. The wrapper rejects `gh auth token`, `gh auth login`, `gh auth refresh`, `gh api graphql`, `gh api` with an absolute URL, extension execution, aliases, and config overrides. Tests assert `gh auth token` fails and never returns even the phantom.
6. Require `pi-sbx-evidence run github-cli-live` after explicit host approval. It checks the approved fixture commands against the pilot repository, verifies the broker and endpoint-policy digests, and records no response bodies or credentials. A pilot that does not need GitHub CLI omits this capability rather than weakening the gate.
**Acceptance criteria:**
- Normal `gh` operations against the pilot repository work through the proxy.
- The real token is absent from Pi's environment, process arguments, generated policy, logs, and command output.
- Cross-repository and unapproved endpoint requests fail in normal mode.
- `gh auth token`, auth mutation, GraphQL, aliases/extensions, absolute API URLs, cross-repository requests, and every unmodeled endpoint fail without revealing the real or phantom credential.
- The keyring selector, repository identity, method/path fixtures, pinned `gh` digest, and live evidence are approved before an applicable pilot passes.
**Dependencies:** Slice 3 and the test harness from Slice 2. May run in parallel with Slice 4A.
## Pilot acceptance gate
Do not add package managers or privileged integrations until one personal Git repository and one work Git repository pass the complete proof of concept. The pilot runs only Git/SSH and applicable `gh` operations; it does not run Node, Maven, or other package-manager workflows:
- restrictive personal/work bases;
- explicit environment filtering;
- generated runtime profiles;
- separate Git/SSH chains with direct SSH denial;
- command-scoped GitHub CLI credentials where applicable;
- approved SSH key paths/fingerprints and mandatory live Git evidence for both contexts;
- approved GitHub selector/endpoints/fixtures and live evidence when `github-cli` is selected;
- repository-managed exact LM Studio source/base-URL evidence when `--provider lm-studio` is selected;
- pre-pilot cutover evidence proving legacy static profiles and compatibility paths are inactive;
- positive and negative evidence reviewed by someone other than the implementers.
A failed pilot returns to the owning slice. Do not compensate by broadening the outer Pi profile.
## Slice 5A: Node, npm, pnpm, and Bun bundles
**Goal:** add JavaScript toolchains without granting registries and caches to Pi itself.
**Owner surfaces:** `profiles/nono/{node-npm,pnpm,bun}.json` and disjoint tests under `tests/nono/capabilities/{node-npm,pnpm,bun}/`.
**Work:** grant each command the active repository, exact cache/store paths, required registry and artifact hosts, and a token-free normal configuration. Separate install/build/test from publish. Cover lockfile-respecting commands used by the mapped repositories. Treat npm/pnpm/Bun lifecycle scripts and `npm test`, `npm run`, `pnpm run`, and equivalents as shell-spawning paths: prove their children inherit the package-manager command sandbox and cannot invoke SSH, Docker, browsers, deployment tools, sibling repositories, raw credentials, or an alternate package binary. Keep `pi-workflow` disabled until Slice 6B2 integrates its executor with these completed policies.
**Acceptance criteria:** installs, builds, and tests work in representative npm, pnpm, and Bun fixtures; adversarial lifecycle/run scripts remain inside the command policy; sibling repositories, unrelated caches, privileged commands, and raw credentials remain unavailable; publish credentials and commands are absent outside release mode.
**Dependencies:** pilot acceptance. May run in parallel with Slices 5B, 6A, and 6B1.
## Slice 5B: Python, Rust, Go, and work Java bundles
**Goal:** add remaining normal language toolchains as independently reviewable fragments.
**Owner surfaces:** `profiles/nono/{python-uv,rust,go,maven-work,gradle-work}.json` and matching disjoint directories under `tests/nono/capabilities/`. Assign one owner per language fragment so this slice can be split into four parallel branches.
**Work:** grant only each active repository, virtual environment or build output, exact caches, required registry/toolchain hosts, and mediated work artifact credentials where necessary. Keep publishing, Docker, deployment, and production routes out of these fragments.
**Acceptance criteria:** representative restore/build/test workflows succeed; each tool can access only its own caches and hosts; work artifact credentials are command-scoped; publishing credentials remain absent.
**Dependencies:** pilot acceptance. Each language fragment may proceed independently.
## Slice 6A: Exact local integrations
**Goal:** make local application and voice access opt-in and endpoint-specific. Provider policy, including LM Studio, remains owned by Slice 1.
**Owner surfaces:** `profiles/nono/orca-hooks.json`, `profiles/nono/herdr.json`, `profiles/nono/dictation.json`, their endpoint discovery helpers, and `tests/nono/capabilities/{orca,herdr,dictation}/`. Slice 1 exclusively owns `provider-lm-studio`.
**Work:**
1. Read `/home/jb/.config/orca/agent-hooks/endpoint.env` with no-follow semantics and require UID 1000 ownership, mode `0600`, trusted parent directories, and a regular file. Require its mtime to postdate the trusted running Orca process start, validate the port, environment, and version, perform an authenticated health handshake with the endpoint token, snapshot device/inode/content digest, and recheck them before launch. A stale, replaced, unauthenticated, wrong-environment, or wrong-version endpoint fails closed.
2. Pass only the validated Orca hook values required by the integration; Pi never receives read access to the endpoint file itself. Repeat file freshness, identity, digest, and authenticated health checks on every generated-profile cache hit.
3. Validate `/home/jb/.config/herdr/herdr.sock` and `/run/user/1000/pi-dictate.sock` on every launch and cache hit: trusted parent paths, no symlink, Unix-socket type, UID 1000 owner, reviewed mode, stable device/inode, expected user-systemd service identity, and peer PID/UID from `SO_PEERCRED` where supported. Recheck immediately before `nono run`; stale, replaced, or wrong-service sockets fail closed. Put dictation behind `--voice`.
4. Remove general `127.0.0.1` and inherited localhost URL-opening access from normal profiles.
5. Prove whether Herdr/tmux receiving panes run under the same nono policy. Leave command execution disabled when containment cannot be demonstrated.
6. Keep model files, audio devices, PipeWire, GPU devices, and service data outside the Pi policy.
**Acceptance criteria:** each integration works only when selected; stale or malformed endpoints fail closed; unrelated loopback ports and sockets are unreachable; `--voice` is visible in the permission plan and is absent by default.
**Dependencies:** pilot acceptance. May run in parallel with Slices 5A, 5B, and 6B1.
## Slice 6B1: Child-agent and background-task isolation
**Goal:** isolate spawned Pi, Claude, Codex, bounded background jobs, Herdr panes, and tmux panes without enabling repository toolchain execution or `pi-workflow`.
**Owner surfaces:** `profiles/nono/{child-pi,child-claude,child-codex,background-jobs}.json`, new spawn-wrapper sources under `scripts/nono/child-agents/`, isolated child-state configuration, and `tests/nono/capabilities/child-agents/`. Coordinate with extension owners but avoid editing Slice 6A's socket fragments or shared extension files concurrently.
**Work:**
1. Inventory every spawn path in active subagent, `pi-ask`, update, and background-task extensions. Classify each in-process operation as safe UI/state access, disabled behavior, or a brokered subprocess. Keep `pi-workflow` absent from generated settings.
2. Route child Pi, Claude, and Codex through command policies with isolated child state and the immutable provider selected by Slice 3.
3. Broker only named background jobs and notification commands that do not invoke repository toolchains; keep arbitrary commands, package updates, and direct privileged spawn paths disabled.
4. Define caller relationships and prohibit privileged-command escalation through child commands.
5. Verify Herdr and tmux targets launch under equivalent or narrower generated policies before enabling command delivery.
6. Add containment tests for nested children, background completion, cancellation, inherited environment, provider override denial, notification commands, package-update denial, and artifact paths.
**Acceptance criteria:** child commands receive no broader filesystem, network, credential, socket, environment, or provider access than declared; nested child tests prove containment; unsandboxed Herdr/tmux targets are rejected; bounded extension behavior remains functional without `pi-workflow` or toolchain execution.
**Dependencies:** pilot acceptance. May run in parallel with Slices 5A, 5B, and 6A.
## Slice 6B2: `pi-workflow` and toolchain integration
**Goal:** enable `pi-workflow` only after every toolchain command it can invoke has a completed command policy.
**Owner surfaces:** `profiles/nono/pi-workflow.json`, workflow spawn-wrapper sources under `scripts/nono/child-agents/pi-workflow/`, the generated extension allowlist entry, and `tests/nono/capabilities/pi-workflow/`.
**Work:** route `pi-workflow` executable resolution and every npm/pnpm/Bun/Python/Rust/Go/Maven/Gradle task through the completed Slice 5 policies. Include lifecycle/run scripts, nested package executables, verification commands, and cancellation. Any unmodeled executable or repository without the required toolchain fragment fails before workflow execution.
**Acceptance criteria:** adversarial workflow tasks cannot bypass toolchain policies, select another provider, invoke high-risk commands, read sibling repositories, or access raw credentials; repositories lacking a required toolchain capability do not load `pi-workflow`.
**Dependencies:** Slices 5A, all applicable 5B language fragments, and Slice 6B1. It does not run in parallel with the toolchain policy it consumes.
## Slice 7: High-risk modes
Start each mode as its own branch and approval decision. The trusted pre-sandbox launcher accepts high-risk flags only from an interactive host terminal when no parent nono sandbox is detected. High-risk modes cannot be combined unless the trusted catalog explicitly defines and tests that exact combination; `--yes` and environment-based authorization are unsupported.
### Slice 7A: Docker and browser
Create separate `docker-high-risk` and `browser-high-risk` modes. Treat the Docker socket and browser control as trusted escape hatches that can bypass ordinary filesystem or network constraints. Require the user to type the selected mode and canonical repository before launch. Test denial without the flag, noninteractive invocation, invocation from a sandbox, conflicting flags, and access to the other high-risk mode.
### Slice 7B: Release and production
Create separate release and production modes with environment-specific credential routes, exact APIs, and command allowlists. Keep package publishing separate from deployment and production data operations. The trusted launcher requires a second interactive confirmation naming the environment and repository immediately before credential activation; production mutations also require the deployment command's own approval mechanism where available. Record who approved, policy digest, repository revision, mode, and time without recording credentials.
### Slice 7C: Maintenance
Create a maintenance-only profile for Pi updates, package/extension installation, `scripts/pi-links.mjs`, immutable package-lock reconciliation, trusted launcher/catalog snapshot installation, nono draft/profile work, and active symlink activation. For `/srv/data/code/harness-tools`, normal mode grants repository read-only access; only this host-authorized maintenance profile grants exact repository source paths read/write for policy development and promotion. Add an `opensrc` command policy with read access to the source repository, write access only to `/srv/data/opensrc`, and required package/source hosts. Normal profiles retain read-only runtime and extension source and cannot invoke `/update`. Activation remains an explicit host-side step. Review whether `pi-sbx.fish`'s hardcoded systemd limits belong in the installed snapshot and test that maintenance updates do not silently change them.
**Shared acceptance criteria:** each mode is absent by default; requires the documented host-side interactive authorization; prints mode, repository, blast radius, and policy digest before confirmation; rejects all unapproved flag combinations and sandbox-origin launches; has positive and negative tests; and produces a redacted approval record before activation.
**Dependencies:** the pilot and Slice 3's mode mechanism. Individual modes may proceed in parallel after explicit user approval.
## Slice 8: Repository migration and legacy retirement
**Goal:** move repositories in small, observable batches and remove the broad static path only after evidence shows it is unnecessary.
**Owner surfaces:** disjoint batch files under `config/nono/repositories.d/`, matching redacted evidence under `docs/nono-migration/`, and eventual legacy-profile removal in an integration-only branch. Migration branches do not edit `config/nono/repositories.json` directly.
**Batches:**
1. Preserve the already accepted pilot mappings: one personal Git repository and one work Git repository, exercising core, Git/SSH, and applicable `gh` policies without invoking Node, Maven, or other package managers.
2. Map `/srv/data/code/harness-tools` to a self-protection policy: repository and policy sources are read-only in normal mode, the installed launcher/catalog remains inaccessible, and writes are available only through the explicitly authorized maintenance mode. Test both mappings from the repository root and linked worktrees.
3. Content/config and offline Git/editor repositories.
4. Personal Node/Bun/Python repositories.
5. Rust/Go/native/ML repositories.
6. Work Java and UI repositories.
7. Large mixed-service repositories.
8. Infrastructure and production applications, only after their high-risk modes exist.
9. Extension-development repositories under `/srv/data/repo-store`, read-only during normal use and writable only when selected as the active repository.
For each repository:
1. Inventory actual commands and enabled extensions.
2. Propose the smallest reviewed capability list.
3. Run the full negative suite and representative positive workflow through the installed `pi-sbx-evidence run <repository-test-id>`.
4. Review the schema-validated summary and retained raw nono evidence for denied required access and unexpected allowed access.
5. Add only the exact missing capability. Never grant a shared parent directory to clear an unexplained denial.
6. Record the approved mapping, owner, policy digest, reviewer, and evidence-summary path.
**Acceptance criteria:** every mapped repository has an owner, reviewed capability list, positive workflow evidence, and negative isolation evidence; unknown repositories always fail closed; static `pi-local`, `pi-orca-local`, `pi-work`, and `pi-harness-tools` routing is retired only after no active mapping depends on it.
## Integration ownership and conflict controls
To keep parallel work mergeable:
- Slice 0 owns existing static profiles, `config/pi-package-lock.json`, `config/pi-links.json`, and `docs/nono-profile-baseline.md` only.
- Slice 1 owns core/provider fragments, its ADR, nono skill corrections, and `tests/nono/capabilities/core/`.
- Slice 2 owns `tests/nono/harness/`, `tests/nono/fixtures/core/`, and `scripts/test_nono.py`; capability branches consume its API without editing it.
- Slice 3 owns catalog/schema/index files, repository-map format and pilot entries, launcher code and flags, existing launcher tests, root package-script integration, and `docs/nono.md`.
- Each later capability branch owns named fragment files and one disjoint `tests/nono/capabilities/<name>/` directory. It supplies an integration handoff for Slice 3 to add catalog indexes or shared docs.
- Slice 8 owns disjoint repository-map batch files under `config/nono/repositories.d/` after the pilot; Slice 3's integration pass builds any canonical index.
- Rebase each slice before integration. Run strict validation and the full security matrix after every catalog or generator merge.
## Verification matrix
| Capability | Positive proof | Negative proof |
| --- | --- | --- |
| Trusted launcher/cutover | Installed snapshot digest and committed cutover journal match; approved launch succeeds | Interrupted journal, environment overrides, writable inputs, checkout/profile paths, retired profiles, stale links, symlink substitution, sandbox-origin high-risk launch, and arbitrary profile names fail |
| Base filesystem | Active registered worktree has exactly its mapped read-only or read/write access; exact mutable Pi state and the per-launch temporary directory are writable | A read-only worktree cannot be written; broad `/tmp`, runtime parents, other sessions, sibling or unregistered worktrees, global instructions, extension source, other Pi state, `~/.ssh`, and `~/.config/gh` are inaccessible or read-only as designed |
| Temporary lifecycle | Normal exit and cancellation remove the private runtime directory; approved active use remains within its aggregate bound | Cleanup preserves a held lease, matching PID/start time, populated cgroup, live unit, or inconclusive identity; proven crash leftovers expire, unsafe paths survive for review, and an active session cannot exhaust `/run` |
| Worktree lifecycle | Broker creates, registers, resolves, and safely removes an external clean worktree | Broad worktree-root access, age-only deletion, dirty/untracked/unpushed removal, active-worktree removal, and linked-worktree confidentiality claims fail |
| Environment/extensions | Reviewed extensions and selected mediated provider credential work | Raw credential variables, old LM Studio source, duplicate providers, `/update`, `pi-workflow` before 6B2, arbitrary notification commands, and unbrokered shell jobs are absent |
| Provider | Trusted default or one pre-boundary approved flag selects the route; LM Studio reaches port 1234 | Ambiguous/post-boundary/child overrides, non-selected providers, LM Studio port 1235, and broad localhost fail |
| Git/SSH | Fetch/push uses the correct provisioned agent and immutable wrapper | Direct SSH, stale/substituted agent, bypass config, alternate binary, same-port host, wrong agent, wrong host, and wrong port fail |
| GitHub CLI | Active-repository operation succeeds through proxy | Real token exposure, cross-repository API, and unapproved endpoints fail |
| Package manager/workflow | Restore/build/test uses exact cache and hosts; 6B2 routes workflow tasks through completed policies | Lifecycle/run-script escape, unrelated cache, publish token, privileged command, and unapproved registry fail |
| Orca/Herdr | Authenticated current endpoint and verified socket peer succeed on launch and cache hit | Stale/replaced/wrong-service endpoint and unrelated socket/port fail |
| Dictation | Voice request succeeds with `--voice` | Socket, audio stack, model data, and GPU remain absent by default |
| Child agents | Declared bounded child workflow completes | Child cannot broaden filesystem, network, credentials, sockets, environment, or provider |
| High-risk modes | Explicit host-authorized workflow succeeds and records policy digest | Missing confirmation, conflicting flags, sandbox invocation, or another mode's capability fails |
| Generator/evidence | Strict validation, dynamic revalidation, deterministic cache hit, and trusted evidence summary succeed | Unknown or malformed inputs, stale digests/endpoints, tampered cache/evidence producer, and unsafe runtime/cache paths stop before launch |
## Rollback
Keep broad legacy profile content only in the host-only rollback store; repository paths contain retired deny profiles. A static fallback requires a host-side authorization record containing an owner, repository, reviewed profile digest, reason, and expiry of at most seven days. Maintenance copies that exact digest to a mode-`0600` ephemeral runtime path that the trusted launcher binds to the named repository for the authorized period. The launcher refuses direct rollback-store/repository paths, expired or mismatched records, inherited profile variables, and arbitrary profile names.
If a generated-profile launch blocks required work:
1. save redacted denial evidence;
2. explicitly authorize the last reviewed static-profile digest for that repository and limited period;
3. run the restoration test and launch with the time-bounded compatibility option;
4. fix the owning capability or generator slice;
5. rerun both positive and negative tests before restoring dynamic routing.
Remove the compatibility option after all mapped repositories complete Slice 8. Rollback is never automatic because silent fallback would hide fail-open behavior.
## Immediate next actions
1. Complete Slice 0 and obtain approval for active-profile reconciliation.
2. Assign parallel worktrees for Slice 1 and Slice 2. Run a read-only Slice 3 schema spike in parallel, but do not edit Slice 1 or Slice 2 owner surfaces and do not finalize interfaces until Slice 1 confirms nono composition behavior.
3. Select one personal and one work Git pilot repository that exercise Git-over-SSH and, where applicable, GitHub CLI, without requiring package-manager commands.
4. Lock the pilot acceptance checklist, SSH-agent provisioning procedure, and live credential evidence procedure before implementing Slices 4A and 4B.
5. Defer package-manager, non-SSH local sockets, child-agent, Docker, browser, release, production, and maintenance grants until the pilot gate passes. The two dedicated SSH-agent sockets are the only socket capabilities permitted by the pilot, and frozen legacy socket-bearing profiles remain deactivated by the cutover gate.
+8
View File
@@ -35,3 +35,11 @@ Unix socket
Vite
Wayland
WebSocket
YAML
pi-sbx
shd.build
pi-interactive-subagents
pi-todo-herdr
herdr
harness-tools
Gitea
+1 -1
View File
@@ -43,7 +43,7 @@ Run the helper using its absolute path resolved from this skill directory:
python3 <skill-directory>/scripts/present.py <artifact-path>
```
The helper reuses the pane labeled `artifact presenter` in the current Herdr workspace. When none exists, it creates a right split without taking focus. It invokes `mcat <artifact-path>` directly so mcat can infer and display documents, source, archives, PDFs, images, and videos. It falls back to artifact-only delivery when Herdr or mcat is unavailable.
The helper reuses the pane labeled `artifact presenter` in the current Herdr workspace. It serializes pane discovery, creation, and replacement per workspace, with an additional per-pane lock during viewer handoff. When that pane is already displaying an artifact, it closes the active `mcat` viewer and waits for the pane shell before launching the replacement through a shell-neutral helper command with encoded path arguments. It confirms the helper started the viewer rather than trusting terminal input delivery alone. It leaves unrelated foreground programs untouched and creates a new viewer pane instead. When no reusable pane exists, it creates a right split without taking focus. It launches `mcat <artifact-path>` so mcat can infer and display documents, source, archives, PDFs, images, and videos. It falls back to artifact-only delivery when Herdr or mcat is unavailable.
Completion criterion: capture whether the helper opened, reused, or skipped the viewer pane.
@@ -4,15 +4,44 @@
from __future__ import annotations
import argparse
import base64
import fcntl
import hashlib
import json
import os
import re
import secrets
import shutil
import subprocess
import tempfile
import time
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Mapping, Sequence
PANE_LABEL = "artifact presenter"
VIEWER_PROCESS_NAME = "mcat"
PANE_READY_ATTEMPTS = 40
PANE_READY_DELAY_SECONDS = 0.05
PANE_SHELL_STABLE_CHECKS = 2
VIEWER_START_ATTEMPTS = 40
SAFE_COMMAND_TOKEN = re.compile(r"^[A-Za-z0-9_./=-]+$")
VIEWER_LAUNCH_SCRIPT = """#!/usr/bin/env python3
import base64
import os
import sys
from pathlib import Path
def decode(value: str) -> str:
return base64.urlsafe_b64decode(value.encode("ascii")).decode("utf-8")
acknowledgment, token, viewer, artifact = map(decode, sys.argv[1:])
Path(acknowledgment).write_text(token, encoding="utf-8")
os.execv(viewer, [viewer, artifact])
"""
RunCommand = Callable[..., subprocess.CompletedProcess[str]]
FindExecutable = Callable[[str], str | None]
@@ -74,43 +103,174 @@ def created_pane_id(response: Mapping[str, object]) -> str:
return pane_id
@dataclass(frozen=True)
class PaneProcessState:
shell_pid: int
foreground_pids: frozenset[int]
foreground_names: frozenset[str]
@property
def shell_is_ready(self) -> bool:
return self.shell_pid in self.foreground_pids
@property
def viewer_is_active(self) -> bool:
return VIEWER_PROCESS_NAME in self.foreground_names
def pane_process_state(response: Mapping[str, object]) -> PaneProcessState:
result = response.get("result")
process_info = result.get("process_info") if isinstance(result, dict) else None
if not isinstance(process_info, dict):
raise ValueError("Herdr process response is missing process info")
shell_pid = process_info.get("shell_pid")
processes = process_info.get("foreground_processes")
if not isinstance(shell_pid, int) or not isinstance(processes, list):
raise ValueError("Herdr process response is missing shell state")
pids: set[int] = set()
names: set[str] = set()
for process in processes:
if not isinstance(process, dict):
continue
pid = process.get("pid")
name = process.get("name")
if isinstance(pid, int):
pids.add(pid)
if isinstance(name, str) and name:
names.add(name)
return PaneProcessState(shell_pid, frozenset(pids), frozenset(names))
def prepare_pane_for_command(
herdr: str,
pane_id: str,
run: RunCommand,
*,
allow_startup_transition: bool,
) -> None:
viewer_closed = False
stable_shell_checks = 0
for attempt in range(PANE_READY_ATTEMPTS):
response = run_json(herdr, ["pane", "process-info", "--pane", pane_id], run)
state = pane_process_state(response)
if state.shell_is_ready:
stable_shell_checks += 1
if stable_shell_checks >= PANE_SHELL_STABLE_CHECKS:
return
else:
stable_shell_checks = 0
if state.viewer_is_active:
if not viewer_closed:
run(
[herdr, "pane", "send-keys", pane_id, "q"],
check=True,
capture_output=True,
text=True,
)
viewer_closed = True
elif (
state.foreground_pids
and not state.shell_is_ready
and not viewer_closed
and not allow_startup_transition
):
processes = ", ".join(sorted(state.foreground_names)) or "unknown"
raise ValueError(f"pane is running an unrelated foreground process: {processes}")
if attempt + 1 < PANE_READY_ATTEMPTS:
time.sleep(PANE_READY_DELAY_SECONDS)
raise ValueError("pane shell did not become ready for the artifact command")
def encode_argument(value: str) -> str:
return base64.urlsafe_b64encode(value.encode("utf-8")).decode("ascii")
@contextmanager
def lock_pane(pane_id: str):
runtime_directory = Path(os.environ.get("XDG_RUNTIME_DIR", tempfile.gettempdir()))
lock_directory = runtime_directory / "pi-artifact-presenter-locks"
lock_directory.mkdir(mode=0o700, exist_ok=True)
lock_name = hashlib.sha256(pane_id.encode("utf-8")).hexdigest() + ".lock"
descriptor = os.open(
lock_directory / lock_name,
os.O_CREAT | os.O_RDWR | os.O_CLOEXEC,
0o600,
)
with os.fdopen(descriptor, "w") as handle:
fcntl.flock(handle, fcntl.LOCK_EX)
try:
yield
finally:
fcntl.flock(handle, fcntl.LOCK_UN)
def run_mcat(
herdr: str,
mcat: str,
pane_id: str,
artifact: Path,
run: RunCommand,
) -> None:
run(
[herdr, "pane", "run", pane_id, mcat, str(artifact)],
check=True,
capture_output=True,
text=True,
)
def present_document(
document: Path,
*,
env: Mapping[str, str] = os.environ,
which: FindExecutable = shutil.which,
run: RunCommand = subprocess.run,
reused: bool = False,
) -> None:
with lock_pane(pane_id):
prepare_pane_for_command(
herdr,
pane_id,
run,
allow_startup_transition=not reused,
)
token = secrets.token_hex(12)
with tempfile.TemporaryDirectory(
prefix="pi-artifact-presenter-",
dir="/tmp",
) as acknowledgment_directory:
temporary = Path(acknowledgment_directory)
acknowledgment = temporary / "viewer-started"
launcher = temporary / "launch-viewer.py"
launcher.write_text(VIEWER_LAUNCH_SCRIPT, encoding="utf-8")
command_parts = [
"python3",
str(launcher),
encode_argument(str(acknowledgment)),
encode_argument(token),
encode_argument(mcat),
encode_argument(str(artifact)),
]
if not all(SAFE_COMMAND_TOKEN.fullmatch(part) for part in command_parts):
raise ValueError("viewer launcher produced an unsafe terminal token")
command = " ".join(command_parts)
run(
[herdr, "pane", "send-text", pane_id, command + "\r"],
check=True,
capture_output=True,
text=True,
)
for attempt in range(VIEWER_START_ATTEMPTS):
try:
if acknowledgment.read_text(encoding="utf-8") == token:
return
except FileNotFoundError:
pass
if attempt + 1 < VIEWER_START_ATTEMPTS:
time.sleep(PANE_READY_DELAY_SECONDS)
raise ValueError("artifact command was delivered but did not start")
def present_in_workspace(
artifact: Path,
herdr: str,
mcat: str,
workspace_id: str,
source_pane_id: str,
run: RunCommand,
) -> PresentationResult:
artifact = document.expanduser().resolve()
if not artifact.is_file():
raise ValueError(f"artifact is not a file: {artifact}")
herdr = which("herdr")
mcat = which("mcat")
workspace_id = env.get("HERDR_WORKSPACE_ID")
source_pane_id = env.get("HERDR_PANE_ID")
if not herdr:
return PresentationResult("artifact-only", artifact, reason="herdr is unavailable")
if not mcat:
return PresentationResult("artifact-only", artifact, reason="mcat is unavailable")
if not workspace_id or not source_pane_id:
return PresentationResult("artifact-only", artifact, reason="not running in a Herdr pane")
try:
pane_list = run_json(herdr, ["pane", "list", "--workspace", workspace_id], run)
existing_panes = panes_with_label(pane_list)
@@ -119,9 +279,9 @@ def present_document(
for existing_pane in existing_panes:
try:
run_mcat(herdr, mcat, existing_pane, artifact, run)
run_mcat(herdr, mcat, existing_pane, artifact, run, reused=True)
return PresentationResult("reused", artifact, pane_id=existing_pane)
except (OSError, subprocess.SubprocessError):
except (OSError, subprocess.SubprocessError, ValueError):
try:
run(
[herdr, "pane", "rename", existing_pane, f"{PANE_LABEL} stale"],
@@ -161,7 +321,7 @@ def present_document(
)
run_mcat(herdr, mcat, pane_id, artifact, run)
return PresentationResult("opened", artifact, pane_id=pane_id)
except (OSError, subprocess.SubprocessError) as error:
except (OSError, subprocess.SubprocessError, ValueError) as error:
try:
run(
[herdr, "pane", "close", pane_id],
@@ -174,6 +334,39 @@ def present_document(
return PresentationResult("artifact-only", artifact, reason=f"could not initialize Herdr viewer: {error}")
def present_document(
document: Path,
*,
env: Mapping[str, str] = os.environ,
which: FindExecutable = shutil.which,
run: RunCommand = subprocess.run,
) -> PresentationResult:
artifact = document.expanduser().resolve()
if not artifact.is_file():
raise ValueError(f"artifact is not a file: {artifact}")
herdr = which("herdr")
mcat = which("mcat")
workspace_id = env.get("HERDR_WORKSPACE_ID")
source_pane_id = env.get("HERDR_PANE_ID")
if not herdr:
return PresentationResult("artifact-only", artifact, reason="herdr is unavailable")
if not mcat:
return PresentationResult("artifact-only", artifact, reason="mcat is unavailable")
if not workspace_id or not source_pane_id:
return PresentationResult("artifact-only", artifact, reason="not running in a Herdr pane")
with lock_pane(f"workspace:{workspace_id}"):
return present_in_workspace(
artifact,
herdr,
mcat,
workspace_id,
source_pane_id,
run,
)
def create_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("artifact", type=Path, help="Local file to present with mcat")
@@ -1,10 +1,14 @@
from __future__ import annotations
import base64
import importlib.util
import json
import os
import subprocess
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
@@ -23,21 +27,92 @@ class FakeRunner:
*,
failed_run_panes: set[str] | None = None,
fail_rename: bool = False,
foreground_processes: dict[str, list[str]] | None = None,
shell_processes: dict[str, str] | None = None,
viewer_teardown_processes: dict[str, list[list[str]]] | None = None,
write_start_acknowledgment: bool = True,
list_delay_seconds: float = 0,
) -> None:
self.panes = panes or []
self.failed_run_panes = failed_run_panes or set()
self.fail_rename = fail_rename
self.foreground_processes = {
pane_id: list(processes)
for pane_id, processes in (foreground_processes or {}).items()
}
self.shell_processes = shell_processes or {}
self.viewer_teardown_processes = {
pane_id: [list(processes) for processes in snapshots]
for pane_id, snapshots in (viewer_teardown_processes or {}).items()
}
self.viewer_closing_panes: set[str] = set()
self.write_start_acknowledgment = write_start_acknowledgment
self.pending_commands: dict[str, str] = {}
self.list_delay_seconds = list_delay_seconds
self.calls: list[list[str]] = []
def __call__(self, command, **_kwargs):
argv = [str(value) for value in command]
self.calls.append(argv)
if argv[1:3] == ["pane", "run"] and argv[3] in self.failed_run_panes:
raise subprocess.CalledProcessError(1, argv)
if argv[1:3] == ["pane", "send-text"]:
pane_id = argv[3]
if pane_id in self.failed_run_panes:
raise subprocess.CalledProcessError(1, argv)
self.pending_commands[pane_id] = argv[4]
command_parts = argv[4].strip().split()
decoded = [
base64.urlsafe_b64decode(value.encode("ascii")).decode("utf-8")
for value in command_parts[-4:]
]
if self.write_start_acknowledgment and decoded[-2] == "/usr/bin/mcat":
Path(decoded[-4]).write_text(decoded[-3], encoding="utf-8")
if argv[1:3] == ["pane", "send-keys"] and argv[4] == "q":
pane_id = argv[3]
self.viewer_closing_panes.add(pane_id)
if argv[1:3] == ["pane", "rename"] and self.fail_rename:
raise subprocess.CalledProcessError(1, argv)
if argv[1:3] == ["pane", "rename"]:
pane_id, label = argv[3], argv[4]
pane = next(
(candidate for candidate in self.panes if candidate["pane_id"] == pane_id),
None,
)
if pane is None:
self.panes.append({"pane_id": pane_id, "label": label})
else:
pane["label"] = label
if argv[1:3] == ["pane", "list"]:
output = {"result": {"panes": self.panes}}
pane_snapshot = [dict(pane) for pane in self.panes]
if self.list_delay_seconds:
time.sleep(self.list_delay_seconds)
output = {"result": {"panes": pane_snapshot}}
elif argv[1:3] == ["pane", "process-info"]:
pane_id = argv[4]
shell_name = self.shell_processes.get(pane_id, "fish")
teardown = self.viewer_teardown_processes.get(pane_id, [])
if pane_id in self.viewer_closing_panes and teardown:
process_names = teardown.pop(0)
elif pane_id in self.viewer_closing_panes:
process_names = [shell_name]
self.viewer_closing_panes.remove(pane_id)
self.foreground_processes[pane_id] = process_names
else:
process_names = self.foreground_processes.get(pane_id, [shell_name])
output = {
"result": {
"process_info": {
"pane_id": pane_id,
"shell_pid": 100,
"foreground_processes": [
{
"name": name,
"pid": 100 if name == shell_name else index + 101,
}
for index, name in enumerate(process_names)
],
}
}
}
elif argv[1:3] == ["pane", "split"]:
output = {"result": {"pane": {"pane_id": "workspace:p-new"}}}
else:
@@ -54,7 +129,10 @@ class ArtifactPresenterTests(unittest.TestCase):
"HERDR_WORKSPACE_ID": "workspace",
"HERDR_PANE_ID": "workspace:p-source",
}
self.executables = {"herdr": "/usr/bin/herdr", "mcat": "/usr/bin/mcat"}
self.executables = {
"herdr": "/usr/bin/herdr",
"mcat": "/usr/bin/mcat",
}
def tearDown(self) -> None:
self.tempdir.cleanup()
@@ -62,6 +140,25 @@ class ArtifactPresenterTests(unittest.TestCase):
def which(self, name: str) -> str | None:
return self.executables.get(name)
def mcat_run_call(
self,
runner: FakeRunner,
pane_id: str,
artifact: Path | None = None,
) -> list[str]:
expected_artifact = str((artifact or self.artifact).resolve())
return next(
call
for call in runner.calls
if call[1:3] == ["pane", "send-text"]
and call[3] == pane_id
and [
base64.urlsafe_b64decode(value.encode("ascii")).decode("utf-8")
for value in call[4].strip().split()[-2:]
]
== ["/usr/bin/mcat", expected_artifact]
)
def test_reuses_named_pane(self) -> None:
runner = FakeRunner(
[{"pane_id": "workspace:p-viewer", "label": MODULE.PANE_LABEL}],
@@ -76,18 +173,183 @@ class ArtifactPresenterTests(unittest.TestCase):
self.assertEqual(result.status, "reused")
self.assertEqual(result.pane_id, "workspace:p-viewer")
self.mcat_run_call(runner, "workspace:p-viewer")
self.assertFalse(any(call[1:3] == ["pane", "split"] for call in runner.calls))
def test_closes_active_mcat_before_reusing_named_pane(self) -> None:
runner = FakeRunner(
[{"pane_id": "workspace:p-viewer", "label": MODULE.PANE_LABEL}],
foreground_processes={"workspace:p-viewer": ["mcat", "less"]},
)
result = MODULE.present_document(
self.artifact,
env=self.env,
which=self.which,
run=runner,
)
self.assertEqual(result.status, "reused")
close_viewer = [
"/usr/bin/herdr",
"pane",
"send-keys",
"workspace:p-viewer",
"q",
]
launch_viewer = self.mcat_run_call(runner, "workspace:p-viewer")
self.assertIn(close_viewer, runner.calls)
self.assertLess(runner.calls.index(close_viewer), runner.calls.index(launch_viewer))
def test_waits_through_transient_viewer_teardown_process(self) -> None:
runner = FakeRunner(
[{"pane_id": "workspace:p-viewer", "label": MODULE.PANE_LABEL}],
foreground_processes={"workspace:p-viewer": ["mcat", "less"]},
viewer_teardown_processes={"workspace:p-viewer": [["less"]]},
)
result = MODULE.present_document(
self.artifact,
env=self.env,
which=self.which,
run=runner,
)
self.assertEqual(result.status, "reused")
process_checks = [
call for call in runner.calls if call[1:3] == ["pane", "process-info"]
]
self.assertEqual(len(process_checks), 4)
self.mcat_run_call(runner, "workspace:p-viewer")
def test_requires_start_acknowledgment_after_launch(self) -> None:
runner = FakeRunner(
[{"pane_id": "workspace:p-viewer", "label": MODULE.PANE_LABEL}],
)
result = MODULE.present_document(
self.artifact,
env=self.env,
which=self.which,
run=runner,
)
self.assertEqual(result.status, "reused")
launch = self.mcat_run_call(runner, "workspace:p-viewer")
command = launch[4].strip().split()
decoded = [
base64.urlsafe_b64decode(value.encode("ascii")).decode("utf-8")
for value in command[-4:]
]
acknowledgment = Path(decoded[-4])
self.assertEqual(Path(command[1]).name, "launch-viewer.py")
self.assertEqual(acknowledgment.name, "viewer-started")
self.assertEqual(len(decoded[-3]), 24)
def test_does_not_report_success_without_start_acknowledgment(self) -> None:
runner = FakeRunner(
[{"pane_id": "workspace:p-viewer", "label": MODULE.PANE_LABEL}],
write_start_acknowledgment=False,
)
original_attempts = MODULE.VIEWER_START_ATTEMPTS
original_delay = MODULE.PANE_READY_DELAY_SECONDS
MODULE.VIEWER_START_ATTEMPTS = 2
MODULE.PANE_READY_DELAY_SECONDS = 0
try:
result = MODULE.present_document(
self.artifact,
env=self.env,
which=self.which,
run=runner,
)
finally:
MODULE.VIEWER_START_ATTEMPTS = original_attempts
MODULE.PANE_READY_DELAY_SECONDS = original_delay
self.assertEqual(result.status, "artifact-only")
self.assertIn("could not initialize Herdr viewer", result.reason)
def test_accepts_idle_shell_by_pid_without_shell_name_allowlist(self) -> None:
runner = FakeRunner(
[{"pane_id": "workspace:p-viewer", "label": MODULE.PANE_LABEL}],
foreground_processes={"workspace:p-viewer": ["xonsh"]},
shell_processes={"workspace:p-viewer": "xonsh"},
)
result = MODULE.present_document(
self.artifact,
env=self.env,
which=self.which,
run=runner,
)
self.assertEqual(result.status, "reused")
self.assertFalse(
any(
call[1:3] == ["pane", "send-keys"] and call[4] == "q"
for call in runner.calls
)
)
def test_empty_foreground_state_is_not_treated_as_ready(self) -> None:
runner = FakeRunner(
[{"pane_id": "workspace:p-transitioning", "label": MODULE.PANE_LABEL}],
foreground_processes={"workspace:p-transitioning": []},
)
original_attempts = MODULE.PANE_READY_ATTEMPTS
original_delay = MODULE.PANE_READY_DELAY_SECONDS
MODULE.PANE_READY_ATTEMPTS = 2
MODULE.PANE_READY_DELAY_SECONDS = 0
try:
result = MODULE.present_document(
self.artifact,
env=self.env,
which=self.which,
run=runner,
)
finally:
MODULE.PANE_READY_ATTEMPTS = original_attempts
MODULE.PANE_READY_DELAY_SECONDS = original_delay
self.assertEqual(result.status, "opened")
process_checks = [
call
for call in runner.calls
if call[1:3] == ["pane", "process-info"]
and call[4] == "workspace:p-transitioning"
]
self.assertEqual(len(process_checks), 2)
def test_does_not_interrupt_unrelated_foreground_process(self) -> None:
runner = FakeRunner(
[{"pane_id": "workspace:p-busy", "label": MODULE.PANE_LABEL}],
foreground_processes={"workspace:p-busy": ["vim"]},
)
result = MODULE.present_document(
self.artifact,
env=self.env,
which=self.which,
run=runner,
)
self.assertEqual(result.status, "opened")
self.assertFalse(
any(
call[1:3] == ["pane", "send-keys"] and call[4] == "q"
for call in runner.calls
)
)
self.assertIn(
[
"/usr/bin/herdr",
"pane",
"run",
"workspace:p-viewer",
"/usr/bin/mcat",
str(self.artifact.resolve()),
"rename",
"workspace:p-busy",
f"{MODULE.PANE_LABEL} stale",
],
runner.calls,
)
self.assertFalse(any(call[1:3] == ["pane", "split"] for call in runner.calls))
def test_tries_each_named_pane_before_creating_another(self) -> None:
runner = FakeRunner(
@@ -134,18 +396,28 @@ class ArtifactPresenterTests(unittest.TestCase):
)
self.assertEqual(result.status, "reused")
self.assertIn(
[
"/usr/bin/herdr",
"pane",
"run",
"workspace:p-viewer",
"/usr/bin/mcat",
str(video.resolve()),
],
runner.calls,
self.mcat_run_call(runner, "workspace:p-viewer", video)
def test_encodes_artifact_paths_before_terminal_delivery(self) -> None:
artifact = self.artifact.with_name("report '$(touch unsafe)'.md")
artifact.write_text("# Safe\n", encoding="utf-8")
runner = FakeRunner(
[{"pane_id": "workspace:p-viewer", "label": MODULE.PANE_LABEL}],
)
result = MODULE.present_document(
artifact,
env=self.env,
which=self.which,
run=runner,
)
self.assertEqual(result.status, "reused")
launch = self.mcat_run_call(runner, "workspace:p-viewer", artifact)
command_parts = launch[4].strip().split()
self.assertTrue(all(MODULE.SAFE_COMMAND_TOKEN.fullmatch(part) for part in command_parts))
self.assertNotIn("$(touch unsafe)", launch[4])
def test_creates_right_pane_when_viewer_is_absent(self) -> None:
runner = FakeRunner()
@@ -214,6 +486,74 @@ class ArtifactPresenterTests(unittest.TestCase):
self.assertEqual(result.status, "artifact-only")
self.assertEqual(result.reason, "mcat is unavailable")
def test_pane_lock_serializes_concurrent_presenters(self) -> None:
first_acquired = threading.Event()
release_first = threading.Event()
second_acquired = threading.Event()
original_runtime = os.environ.get("XDG_RUNTIME_DIR")
os.environ["XDG_RUNTIME_DIR"] = self.tempdir.name
def first_presenter() -> None:
with MODULE.lock_pane("workspace:p-viewer"):
first_acquired.set()
release_first.wait(timeout=1)
def second_presenter() -> None:
first_acquired.wait(timeout=1)
with MODULE.lock_pane("workspace:p-viewer"):
second_acquired.set()
first = threading.Thread(target=first_presenter)
second = threading.Thread(target=second_presenter)
try:
first.start()
second.start()
self.assertTrue(first_acquired.wait(timeout=1))
self.assertFalse(second_acquired.wait(timeout=0.05))
release_first.set()
self.assertTrue(second_acquired.wait(timeout=1))
finally:
release_first.set()
first.join(timeout=1)
second.join(timeout=1)
if original_runtime is None:
os.environ.pop("XDG_RUNTIME_DIR", None)
else:
os.environ["XDG_RUNTIME_DIR"] = original_runtime
def test_workspace_lock_prevents_duplicate_viewer_creation(self) -> None:
runner = FakeRunner(list_delay_seconds=0.05)
results: list[MODULE.PresentationResult] = []
results_lock = threading.Lock()
original_runtime = os.environ.get("XDG_RUNTIME_DIR")
os.environ["XDG_RUNTIME_DIR"] = self.tempdir.name
def present() -> None:
result = MODULE.present_document(
self.artifact,
env=self.env,
which=self.which,
run=runner,
)
with results_lock:
results.append(result)
threads = [threading.Thread(target=present) for _ in range(2)]
try:
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=2)
finally:
if original_runtime is None:
os.environ.pop("XDG_RUNTIME_DIR", None)
else:
os.environ["XDG_RUNTIME_DIR"] = original_runtime
self.assertEqual(sorted(result.status for result in results), ["opened", "reused"])
splits = [call for call in runner.calls if call[1:3] == ["pane", "split"]]
self.assertEqual(len(splits), 1)
def test_rejects_missing_artifact(self) -> None:
with self.assertRaisesRegex(ValueError, "artifact is not a file"):
MODULE.present_document(
@@ -0,0 +1,116 @@
from __future__ import annotations
import importlib.util
import os
import shutil
import subprocess
import sys
import tempfile
import time
import unittest
from pathlib import Path
MODULE_PATH = Path(__file__).parents[1] / "scripts" / "present.py"
SPEC = importlib.util.spec_from_file_location("artifact_presenter_integration", MODULE_PATH)
assert SPEC and SPEC.loader
MODULE = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = MODULE
SPEC.loader.exec_module(MODULE)
@unittest.skipUnless(
os.environ.get("HERDR_INTEGRATION_TESTS") == "1",
"set HERDR_INTEGRATION_TESTS=1 inside a Herdr pane",
)
class ArtifactPresenterIntegrationTests(unittest.TestCase):
def wait_for_mcat(self, herdr: str, pane_id: str, artifact: Path) -> None:
for _attempt in range(100):
state = MODULE.run_json(
herdr,
["pane", "process-info", "--pane", pane_id],
subprocess.run,
)
processes = state["result"]["process_info"]["foreground_processes"]
if any(
process.get("name") == "mcat"
and str(artifact) in process.get("argv", [])
for process in processes
):
return
time.sleep(0.05)
self.fail(f"mcat did not open {artifact}")
def test_replaces_active_mcat_in_real_herdr_pane(self) -> None:
herdr = shutil.which("herdr")
mcat = shutil.which("mcat")
source_pane = os.environ.get("HERDR_PANE_ID")
if not all((herdr, mcat, source_pane)):
self.skipTest("Herdr, mcat, and HERDR_PANE_ID are required")
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
first = root / "first.md"
second = root / "second.md"
first.write_text(
"# First artifact\n" + ("first viewer line " * 20 + "\n") * 1_000,
encoding="utf-8",
)
second.write_text(
"# Second artifact\n" + ("second viewer line " * 20 + "\n") * 1_000,
encoding="utf-8",
)
split = MODULE.run_json(
herdr,
[
"pane",
"split",
"--pane",
source_pane,
"--direction",
"right",
"--cwd",
directory,
"--env",
"PAGER=less",
"--no-focus",
],
subprocess.run,
)
pane_id = MODULE.created_pane_id(split)
try:
MODULE.run_mcat(
herdr,
mcat,
pane_id,
first,
subprocess.run,
)
self.wait_for_mcat(herdr, pane_id, first)
MODULE.run_mcat(
herdr,
mcat,
pane_id,
second,
subprocess.run,
reused=True,
)
self.wait_for_mcat(herdr, pane_id, second)
finally:
subprocess.run(
[herdr, "pane", "send-keys", pane_id, "q"],
check=False,
capture_output=True,
text=True,
)
subprocess.run(
[herdr, "pane", "close", pane_id],
check=False,
capture_output=True,
text=True,
)
if __name__ == "__main__":
unittest.main()