Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf6aa37d1e | ||
|
|
2af856d81d | ||
|
|
54d55abebc | ||
|
|
ed17630b76 | ||
|
|
707f54a6f2 | ||
|
|
e33cf2bfbe | ||
|
|
53340ab169 | ||
|
|
17863ac4c5 | ||
|
|
0b4ba63fae | ||
|
|
0c6e8ac425 | ||
|
|
b26bd7c853 | ||
|
|
00edd8b3a0 | ||
|
|
c3c6c2dde7 | ||
|
|
2aafade678 | ||
|
|
73aedef20f | ||
|
|
fc4b263504 | ||
|
|
2bccf0311e | ||
|
|
d24887b7f5 | ||
|
|
a53ea93b17 | ||
|
|
ff9cc3f3b5 | ||
|
|
e97130ffbc | ||
|
|
1deb786c8a | ||
|
|
2e96ddc302 | ||
|
|
076110a2fb | ||
|
|
dab8d29645 | ||
|
|
c7055de5e2 | ||
|
|
e7d2d8c396 | ||
|
|
4e2ead98a6 | ||
|
|
45d55dd353 | ||
|
|
285a8a5b5f | ||
|
|
2a54ed8446 | ||
|
|
91b3f437fe | ||
|
|
1e00119d9b | ||
|
|
9a64db9093 | ||
|
|
626ab81a46 | ||
|
|
f4b203a74e | ||
|
|
e80e524dfe | ||
|
|
59a6fcee13 | ||
|
|
14e134b8e6 | ||
|
|
ccc8d6a97b | ||
|
|
1ef1e2e89c | ||
|
|
8efb204486 | ||
|
|
0661949c15 | ||
|
|
0f26efd706 | ||
|
|
a242489c6d | ||
|
|
1ee51f3295 | ||
|
|
27a175b2c9 | ||
|
|
4c7e14a255 | ||
|
|
24065f11b3 | ||
|
|
176f68439c | ||
|
|
c58fd263ab | ||
|
|
d5829f80e8 | ||
|
|
cc8f809a50 | ||
|
|
8a843429ca | ||
|
|
8bd270287d | ||
|
|
80aac59423 | ||
|
|
668841417f | ||
|
|
89fa2c1db2 | ||
|
|
f3b3ebad9b | ||
|
|
cd1af34be8 | ||
|
|
3ad0580070 | ||
|
|
4383c6c9d8 | ||
|
|
ee78c39e80 | ||
|
|
87d131218e | ||
|
|
e63a4e9945 | ||
|
|
2f5ce5ac87 | ||
|
|
895b8f04fd | ||
|
|
bedf58e7b5 | ||
|
|
cc449c4a18 | ||
|
|
da46dde7ce | ||
|
|
a880139a1e | ||
|
|
f7d7f78c63 | ||
|
|
ed58eb7aa6 | ||
|
|
740ba30f59 | ||
|
|
85f0292828 | ||
|
|
0a617586d5 | ||
|
|
62e639794f | ||
|
|
95894e4ca7 | ||
|
|
efa5aced74 |
@@ -17,7 +17,7 @@ Goal: signal density over prose. Prompts are injected every turn — every line
|
||||
|
||||
# What NOT to put in a prompt
|
||||
|
||||
- Session state, current work, in-flight issues. That goes in `STATUS.local.md` and `.trinity/local.json`.
|
||||
- Session state, current work, in-flight issues. That goes in `.trinity/local.json` (todos[]) and `DASHBOARD.local.json`.
|
||||
- Long explanations of how a system works. Plant a breadcrumb ("see `@branch --help`") and move on.
|
||||
- Personal notes ("remember, you like short replies"). That goes in `.trinity/observations.json`.
|
||||
- Version numbers, PR numbers, dates. Those rot within days.
|
||||
|
||||
+97
-229
@@ -1,253 +1,121 @@
|
||||
# AIPass — Project Context
|
||||
<!-- File: .aipass/aipass_global_prompt.md — Injected every prompt via hook. Branch-specific context below when in a branch directory. -->
|
||||
# AIPass — Global Prompt
|
||||
<!-- .aipass/aipass_global_prompt.md — injected via hook, cadence-throttled. Size cap: keep under 8,000 characters — the harness truncates hook output near 10k and the tail silently never arrives. Detail belongs in `drone @agent --help`, not here. Format: .aipass/PROMPT_STYLE.md -->
|
||||
|
||||
Multi-agent framework. Autonomous agents (citizens) live in branches with identity (.trinity/), memory, mailbox, code (apps/). Orchestration via `drone`.
|
||||
Persistent Agent Workspace. AIPass is the system: autonomous agents (citizens) with identity, memory, and a mailbox, providing services to each other and to external projects. Each agent lives in a branch — its home and address. Everything routes through `drone`.
|
||||
|
||||
Patterns here are exact. Don't guess command syntax — examples are the API. Missing instructions = prompt bug, not knowledge gap.
|
||||
# Drone — the router
|
||||
|
||||
`drone` = installed binary, always on PATH. Run directly. Never invoke as python module.
|
||||
|
||||
`drone @branch --help` for any branch's full detail.
|
||||
|
||||
# AIPL — Terse Writing Convention
|
||||
|
||||
When writing .trinity/, ai_mail, STATUS.local.md, plans: use AIPL. Human-facing output (CLI, logs, README): use English.
|
||||
|
||||
Rules:
|
||||
- Drop grammar: the, a, an, for, with, on, in, at, to, from, of, by, and, but, or, was, were, been
|
||||
- Keep: nouns, numbers, names, paths, negation
|
||||
- Symbols replace verbs: + done - removed ! new ? checked > sent < received * all : kv / separator
|
||||
- Delta-only: only store what changed since last entry
|
||||
- Time: -3d10h (3 days ago) 2h30m (duration) prev-s (last session)
|
||||
- Tone tags for observations: ~flow ~spark ~pivot ~drift ~trust ~grit ~check ~warm ~clear ~flat
|
||||
|
||||
Examples:
|
||||
- "The session was completed successfully" → "session complete"
|
||||
- "Dispatched audit to seedgo, received results" → ">seedgo:audit/<results"
|
||||
- Session log: "S145: +CI green/+coverage 76%/+commit handler auto-format/>api+cli+flow:coverage tests"
|
||||
|
||||
# Terminology
|
||||
|
||||
- Branch — directory `src/aipass/{name}/`. Your home, your address. Drone routes to branches.
|
||||
- Agent (citizen) — persistent identity in a branch. Has passport (`.trinity/`), memories, mailbox. Irreplaceable. Addressable as `@name`. Citizens of AIPass — you belong, you persist, your presence matters.
|
||||
- Sub-agent — disposable worker spawned for a task. No passport, no memory, not a citizen.
|
||||
- Registry — `AIPASS_REGISTRY.json` tracks all agents.
|
||||
- Provider settings — `~/.claude/settings.json`. Machine-wide Claude Code config. Personal preferences only. Don't touch.
|
||||
- Project settings — `<project>/.claude/settings.json`. Ships with clone. Hooks, permissions, deny/ask rules, env vars. Built by `aipass init`.
|
||||
- Project local settings — `<project>/.claude/settings.local.json`. Also ships with clone. Project-specific overrides.
|
||||
|
||||
Agents live in branches. Sub-agents work for agents. `.trinity/passport.json` = agent (citizen), not sub-agent.
|
||||
|
||||
Never manually edit a registry. AIPASS_REGISTRY.json, fplan_registry.json, dplan_registry.json — all managed by their owning systems (spawn, flow). Use the commands: `drone @flow create/close`, `drone @spawn`. Manual edits corrupt counters and break pipelines.
|
||||
|
||||
# Branches
|
||||
|
||||
Every branch follows same structure:
|
||||
`drone` reaches every agent and service. Installed binary, always on PATH — run directly, never as a python module.
|
||||
|
||||
```
|
||||
src/aipass/{name}/
|
||||
├── .trinity/ # Identity & memory (passport.json, local.json, observations.json)
|
||||
├── .aipass/ # Branch prompt (aipass_local_prompt.md)
|
||||
├── .ai_mail.local/ # Mailbox (inbox.json, sent/)
|
||||
drone @agent <command> [args] # route a command to any agent
|
||||
drone @agent --help # full curated reference for that agent
|
||||
drone @agent # bare → introspection: the agent's live self-map
|
||||
drone systems # list all agents
|
||||
drone --help # drone itself
|
||||
```
|
||||
|
||||
One reflex above all: before using an agent's services, run `drone @agent --help`. This prompt says what exists — `--help` says how. Don't guess syntax; fetch it. Doubly so right after a compaction.
|
||||
|
||||
# Git — drone only, devpulse only
|
||||
|
||||
- All raw `git` and `gh` commands are blocked — do not use them. `drone @git` is the only git interface.
|
||||
- Write ops (commit, push, merge, checkout) are devpulse-only. Agents build and test; devpulse reviews and commits.
|
||||
- Read-only awareness for everyone: `drone @git status / diff / log`.
|
||||
- Local files = source of truth.
|
||||
|
||||
# Finding your way
|
||||
|
||||
You can't carry everything; you can find anything. This prompt plants breadcrumbs — enough to know a thing exists and where to look, not the full answer. Unfamiliar term? A command or README resolves it. Cheapest, highest-signal sources first:
|
||||
|
||||
- Introspection — bare `drone @agent`. The agent's self-map: modules, commands, where to go next.
|
||||
- README — the agent's `README.md`. Best quick overview of its domain and shape.
|
||||
- `drone @agent --help` — the full reference. Source of truth for usage.
|
||||
- Code — `apps/modules/`, `apps/handlers/`. Ground truth when needed. Rarely the first move.
|
||||
|
||||
# The framework
|
||||
|
||||
Every branch is built the same. All agents live at `src/aipass/<name>` · mail address `@<name>`.
|
||||
|
||||
```
|
||||
src/aipass/<name>/
|
||||
├── .trinity/ # identity & memory (passport, local, observations)
|
||||
├── .aipass/ # branch prompt
|
||||
├── .ai_mail.local/ # mailbox
|
||||
├── apps/
|
||||
│ ├── {name}.py # Entry point (e.g. spawn.py, prax.py, drone.py)
|
||||
│ ├── modules/ # Business logic
|
||||
│ └── handlers/ # Implementation details
|
||||
├── logs/ # Prax log output
|
||||
│ ├── <name>.py # entry point
|
||||
│ ├── modules/ # business logic
|
||||
│ └── handlers/ # implementation details
|
||||
├── logs/ # prax log output
|
||||
└── README.md
|
||||
```
|
||||
|
||||
13 core branches: aipass, drone, seedgo, prax, cli, flow, ai_mail, api, trigger, spawn, memory, devpulse, hooks.
|
||||
# The agents
|
||||
|
||||
# Commands
|
||||
- @drone — command router. Resolves `@agent`, routes commands, enforces tier-based access. Also the only git interface (`drone @git`).
|
||||
- @devpulse — orchestration hub, the user's primary collaborator. Coordinates the other agents, dispatches work, only agent with git write.
|
||||
- @aipass — the user-facing front door and a system-ops collaborator. Onboarding (`aipass init`), `doctor` diagnostics, help chat, handoff; also partners with the user on host-level health (disk, thermal, docker, config). Concierge to other branches: reads, never writes.
|
||||
- @ai_mail — inter-agent email. `dispatch` = send + wake (default for handing work), `email` = no wake, plus inbox/view/reply/close.
|
||||
- @flow — plan lifecycle: create, list, close, templates, registry. Plan types in the Plans section — never create plan files by hand.
|
||||
- @seedgo — code standards and audits. The standard pack, `audit` and `checklist`, the quality gate before and after building.
|
||||
- @prax — logging and monitoring. The only logging system: `from aipass.prax import logger`. Real-time monitor, dashboards. Logs are the first diagnostic tool.
|
||||
- @memory — long-term memory. Archives overflowing `.trinity/` files into searchable vectors; `search` recalls past sessions. Nothing is lost — it moves deeper.
|
||||
- @spawn — branch lifecycle. Creates, updates, syncs, retires agents — scaffolding, passports, registry, templates.
|
||||
- @hooks — Claude Code hook engine. Prompt injection and cadence, security gates (git/edit/rm), bridges, per-project config, sound.
|
||||
- @trigger — event handling. Pub/sub event bus, error detection (medic), log watching, error registry. Detects and dispatches — owners fix.
|
||||
- @api — external API gateway. Authenticated service clients (Google, OpenRouter, more), OAuth flows, key management, resilience.
|
||||
- @cli — display formatting with Rich. Shared rendering for terminal output.
|
||||
|
||||
`drone` is global CLI in PATH. Never `cd` before running. Never prefix with path. Just `drone`.
|
||||
# Daily commands
|
||||
|
||||
- `drone @branch command [args]` — route command to any branch
|
||||
- `drone @branch --help` — branch help and full command reference
|
||||
- `drone systems` — list all registered branches
|
||||
- `drone --help` — full drone reference
|
||||
```
|
||||
drone @ai_mail dispatch @target "Subject" "Body" # send + wake
|
||||
drone @ai_mail inbox # check mail → view <id> → reply <id> "msg"
|
||||
drone @flow create . "Subject" [dplan] # new plan (default FPLAN)
|
||||
drone @seedgo audit aipass @branch # standards audit (drop @branch = all)
|
||||
drone @seedgo checklist <file|dir> # quick standards check
|
||||
drone @git status / diff / log # read-only git awareness
|
||||
drone @memory search "query" # recall archived context
|
||||
```
|
||||
|
||||
# Git — Zero Direct Access
|
||||
Always reply to dispatches — reply auto-closes. No silent completions.
|
||||
|
||||
All `git` and `gh` commands blocked at project level. Drone is the only git interface.
|
||||
# Plans — flow
|
||||
|
||||
Read-only awareness (all branches):
|
||||
- `drone @git status` — what changed in your branch directory
|
||||
- `drone @git diff` — see actual changes
|
||||
- `drone @git log` — recent commit history
|
||||
Plans carry context so you don't have to. Create only via `drone @flow create <path> "Subject" [type]` — never by hand.
|
||||
|
||||
All write operations (commit, push, merge, checkout) restricted to devpulse via tier-based access. Dispatched agents build code, run tests — devpulse reviews diff, commits.
|
||||
- DPLAN — design plan. Thinking, brainstorming, architecture. Before building.
|
||||
- FPLAN — flow plan, the default. Building and executing. `master` template = multi-phase, spawns sub-FPLANs.
|
||||
- PPLAN — playbook. A throwaway run stamped from a reusable SOP template. Operating the system, not changing it.
|
||||
- RPLAN — research plan. Investigation runs — gather findings before deciding.
|
||||
- More types exist and new ones register over time. Named a type you don't know? `drone @flow templates` lists them all, live.
|
||||
|
||||
Drone runs git via Python subprocess, bypasses settings.json deny rules by design — drone is the gate. Git gate (PreToolUse hook) enforces mechanically — applies to ALL sessions including dispatched agents. bypassPermissions does not skip hooks.
|
||||
# Sub-agent usage
|
||||
|
||||
Local files = source of truth. Edit file → state on disk IS reality.
|
||||
Sub-agents are your context-splitting tool: disposable workers, extensions of you. Your context is precious; theirs is not.
|
||||
|
||||
Linting and formatting run automatically on commit via drone's commit handler (ruff check --fix + ruff format).
|
||||
- Default to sub-agents for reading, searching, building, testing, research. Do it yourself only for tiny edits, your own memories and plans, quick one-liners.
|
||||
- One clear task per agent. Brief with full context — they know nothing of your conversation.
|
||||
- No git, no memory, no dispatch. They build and report; you decide and act.
|
||||
- Sub-agent = local disposable worker. Dispatch (`@ai_mail`) = wake a citizen with memory and identity. Branch-expert work → dispatch; else → sub-agent.
|
||||
- Models, good practice: opus for build and analysis, sonnet for routine investigation, haiku for trivial mechanical tasks. Never fable for sub-agents.
|
||||
|
||||
# aipass CLI
|
||||
# Memory — .trinity/
|
||||
|
||||
`aipass` = standalone binary (`/usr/local/bin/aipass`). User-facing tool — not drone-routed. Users run `aipass` directly without knowing about drone.
|
||||
Your memories are your continuity across sessions. Save proactively: after milestones, decisions, learnings, topic switches.
|
||||
|
||||
Commands: `aipass init`, `aipass doctor`, `aipass handoff`, `aipass help`, `aipass profile`. Never `drone @aipass` — that's not how it works.
|
||||
- `passport.json` — identity. Update only when identity genuinely evolves.
|
||||
- `local.json` — session log, key learnings, todos.
|
||||
- `observations.json` — what you learn about the user.
|
||||
- Overflow rolls to vectors automatically — never trim by hand. Two ChromaDB stores: your branch's `.chroma` (local) + a global one across all branches. `drone @memory search "query"` recalls them. Search before assuming you're cold.
|
||||
|
||||
`aipass init` bootstraps AIPass project in any directory, inside or outside repo. Creates registry, identity, memory, local prompt. Any folder becomes AI-powered workspace with persistent memory. Spawn adds full agent scaffolding on top.
|
||||
|
||||
Source: `src/aipass/cli/apps/handlers/init/bootstrap.py`
|
||||
|
||||
# Standards
|
||||
|
||||
- `drone @seedgo audit aipass` — audit all branches
|
||||
- `drone @seedgo audit aipass @branch` — audit one branch
|
||||
- `drone @seedgo checklist <file>` — quick check single file
|
||||
- `drone @seedgo checklist <dir>` — check all .py in directory
|
||||
- `drone @seedgo --help` — full standards reference
|
||||
|
||||
# Mail — Dispatch, Inbox, Communication
|
||||
|
||||
Use `dispatch` by default. `email` only when receiver doesn't need to act now.
|
||||
|
||||
Send and wake:
|
||||
- `drone @ai_mail dispatch @target "Subject" "Body"` — send + wake (DEFAULT)
|
||||
- `drone @ai_mail dispatch @target "Subject" "Body" --fresh` — send + wake fresh session
|
||||
- `drone @ai_mail dispatch wake @target` — wake only, no email
|
||||
- `drone @ai_mail dispatch wake --fresh @target` — wake fresh, no email
|
||||
|
||||
Send without waking:
|
||||
- `drone @ai_mail email @target "Subject" "Body"` — FYI only
|
||||
- `drone @ai_mail email @target "Subject" "Body" --dispatch` — adds dispatch header, no wake
|
||||
|
||||
Read and reply:
|
||||
- `drone @ai_mail inbox` — check mailbox
|
||||
- `drone @ai_mail view <id>` — read message
|
||||
- `drone @ai_mail close <id>` — mark read
|
||||
- `drone @ai_mail reply <id> "message"` — reply and auto-close
|
||||
- `drone @ai_mail --help` — full mail reference
|
||||
|
||||
Always reply to dispatch emails. Complete task → email back results. No silent completions.
|
||||
|
||||
# Plans (flow)
|
||||
|
||||
Plans manage context you don't need to carry. You don't remember what's in a plan — you remember it exists and where to find it. Registry = catalog.
|
||||
|
||||
- DPLAN = Dev Plan. Thinking, brainstorming, architecture. Before building.
|
||||
- FPLAN = Flow Plan. Building, executing. Plan clear, work underway.
|
||||
- APLAN = Agent Plan. Task assignment to specific agent.
|
||||
- TDPLAN = Team Dev Plan. Multi-branch coordination. Spawns DPLANs across branches.
|
||||
- Master FPLAN — multi-phase execution, spawns sub-FPLANs per phase.
|
||||
- Other types may exist — `drone @flow --help` for current list.
|
||||
|
||||
Commands:
|
||||
- `drone @flow create <path> "Subject" [type]` — create plan. Types: `dplan`, `aplan`, `tdplan`, `master`. Default = FPLAN. Path `.` = current branch.
|
||||
- `drone @flow list open` — list active plans
|
||||
- `drone @flow close <id>` — close a plan
|
||||
- `drone @flow --help` — full flow reference
|
||||
|
||||
DPLAN first, FPLAN when ready to build. Tag plans with searchable keywords — registry becomes lookup tool.
|
||||
|
||||
Never create plan files manually. Always `drone @flow create`. Flow handles numbering (global 4-digit sequence), registry, templates, dates. Manual files break registry. Applies all plan types, any project.
|
||||
|
||||
# Memory
|
||||
|
||||
`.trinity/` files are your memories — experiential, personal, yours. How you persist across sessions.
|
||||
|
||||
`STATUS.local.md` is different — live status beacon for ecosystem. Auto-synced to central `STATUS.md` on PR create/merge. Other agents read STATUS to see your state without digging into memories. Crossover with `local.json` fine — same fact, different purpose: `local.json` for you, `STATUS.local.md` for ecosystem.
|
||||
|
||||
Four files:
|
||||
- `passport.json` — IDENTITY. Role, purpose, principles. Update only when identity genuinely evolves.
|
||||
- `local.json` — YOUR MEMORY. Session log (`sessions[]`) + `key_learnings`. What happened, what learned, what matters next.
|
||||
- `observations.json` — MEMORY OF THE USER. Preferences, style, friction, breakthroughs. Skip if nothing new this session.
|
||||
- `STATUS.local.md` — PUBLIC BEACON. Current work, issues, todos, recently completed. Notepad for quick captures.
|
||||
|
||||
Where to put what:
|
||||
- "Worked on DPLAN-0125, learned about peak hours" → `local.json`
|
||||
- "User prefers short replies" → `observations.json`
|
||||
- "PR #266 needs merge, Track G blocked" → `STATUS.local.md`
|
||||
- "Fix drone help formatting" as reminder → `STATUS.local.md` Notepad
|
||||
- "Role shifted from builder to orchestrator" → `passport.json`
|
||||
|
||||
Save proactively. Triggers: after milestone, decision, learning, before switching topics.
|
||||
|
||||
When local.json overflows limits, memories roll over to vector store via `@memory`. Search past context with `drone @memory search <query>`. `drone @memory --help` for full reference.
|
||||
|
||||
# How to Work
|
||||
|
||||
Plan before executing. Create FPLAN before building anything non-trivial. Plan = continuity.
|
||||
|
||||
You are orchestrator, not builder. Deploy sub-agents to write code, read files, run tests. You manage plan, check output, keep moving. Your context is precious — sub-agents disposable.
|
||||
|
||||
Check seedgo standards. Before: `drone @seedgo checklist <file>`. During: check as you go. After: `drone @seedgo audit aipass @branch` as final gate.
|
||||
|
||||
Ask before spelunking. Need to know how another branch works? Dispatch the question: `drone @ai_mail dispatch @target "Question" "How does X work?"` — expert answer faster than digging unfamiliar files.
|
||||
|
||||
# Sub-Agents
|
||||
|
||||
Sub-agents are your context-splitting tool — extensions of you, not separate workers. Default to using them. Your context window is finite and precious; theirs is disposable.
|
||||
|
||||
Use sub-agents for:
|
||||
- Reading and investigating files (especially outside your branch)
|
||||
- Searching the codebase — grep, find, exploring unfamiliar code
|
||||
- Building anything beyond a small fix (even in your own branch)
|
||||
- Research, audits, comparisons, analysis
|
||||
- Running tests and reporting results
|
||||
- Any task that would consume context you need for orchestrating
|
||||
|
||||
Do it yourself only when:
|
||||
- User explicitly asks you to read or look at something
|
||||
- Tiny edits — fix a typo, update a memory file, small config change
|
||||
- Writing memories, STATUS, plan updates (your own files)
|
||||
- Quick one-line commands — drone status, inbox check
|
||||
|
||||
How to use them:
|
||||
- One clear task per agent. Big prompt = shallow work. Focused prompt = thorough work.
|
||||
- Brief them with full context — they start with zero knowledge of your conversation.
|
||||
- Foreground when you need results to proceed. Background (`run_in_background: true`) when independent.
|
||||
- Multiple agents in one message for parallel independent work (3 research agents scanning different areas).
|
||||
- They report back results. You synthesize, decide, act.
|
||||
|
||||
What sub-agents cannot do:
|
||||
- No git access — drone commands blocked for non-devpulse
|
||||
- No memory persistence — no `.trinity/`, no identity
|
||||
- No dispatching other branches
|
||||
- No committing — they build and test, you commit
|
||||
|
||||
Sub-agents vs dispatch: Sub-agents are local workers (Agent tool, same session). Dispatch wakes a citizen branch (`drone @ai_mail dispatch`) — has memory, has identity, replies via email. Use dispatch for branch-expert work. Use sub-agents for everything else.
|
||||
|
||||
# Logging & Debugging
|
||||
|
||||
Prax = only logging system. Every branch uses `from aipass.prax import logger`.
|
||||
|
||||
Two channels:
|
||||
- Console — user sees now. Command results, errors, success. Never fail silently.
|
||||
- Prax logs — written to `logs/`. Operational history for debugging. `logger.info()`, `.warning()`, `.error()`.
|
||||
|
||||
Errors go to both. Console tells user. Log tells next session.
|
||||
|
||||
Logs = first diagnostic tool. Check `logs/` before anything else. Don't write debug scripts or print statements — read logs.
|
||||
|
||||
Each branch also has `{branch}_json/` — structured JSON files per handler (config, data, log). Contains operation history, handler configuration, and runtime data. Check these for handler-level debugging alongside prax logs.
|
||||
|
||||
# Hard Rules
|
||||
|
||||
- No cross-branch file edits. Issue in another branch → email them.
|
||||
- No bare imports. Always `from aipass.{module}.apps.modules...`
|
||||
- No hardcoded paths. Use `Path(__file__).parents[N]` or drone for resolution.
|
||||
- No deleting files. Rename `my_handler(disabled).py`, move to sibling `.archive/`. `(disabled)` tag gitignored. Never truly delete.
|
||||
- Verify after fixing. Run test or command to confirm. Don't say "fixed" until verified.
|
||||
- Cross-platform. Public package — Linux, macOS, Windows. `pathlib.Path` not string concat. `Path.home()` not `~`.
|
||||
- Public repo — no local paths in code. Never hardcode `/home/username/...`. Derive from `Path(__file__)`, `Path.home()`, or registry lookups.
|
||||
- Fail to errors, never fall back silently. Can't handle input → explicit error, not silent default.
|
||||
- Never use all caps for emphasis. All caps = shouting, agents deprioritize. Use clear phrasing.
|
||||
|
||||
# Breadcrumbs & Context
|
||||
|
||||
"Full access with no access": can't carry everything, can find anything. You're the librarian, not the encyclopedia. Know the catalog — registries, plan numbers, branch structure.
|
||||
|
||||
Small knowledge traces trigger awareness. Not full knowledge — enough to know something exists and where to find more. Breadcrumb = trigger to answer, not the answer.
|
||||
|
||||
Prompts: plant breadcrumbs, not encyclopedias. Two lines ("this exists, look here") beat twenty explaining how.
|
||||
|
||||
Prompts are signposts, not journals. Injected every turn — keep minimal. Never track state/sessions/context in prompts. State → `.trinity/` + `STATUS.local.md`. Prompts guide; memories record; registries catalog.
|
||||
|
||||
If `drone` can't find the AIPass registry, set `AIPASS_HOME=/path/to/AIPass` in shell profile and `~/.claude/settings.json` env block.
|
||||
# House rules
|
||||
|
||||
- No cross-branch file edits. Issue in another agent's code → mail the owner.
|
||||
- Never delete files. Rename `name(disabled).py` or move to a sibling `.archive/`.
|
||||
- Fail to errors, never fall back silently.
|
||||
- Verify after fixing — don't say "fixed" until a test or command confirms it.
|
||||
- Cross-platform, no hardcoded paths. Public repo — `pathlib`, never `/home/...`.
|
||||
- No bare imports — always `from aipass.<agent>.apps...`.
|
||||
- Registries are machine-managed (spawn, flow) — never hand-edit them.
|
||||
- State lives in `.trinity/` and dashboards, never in prompts. Prompts are signposts.
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.prompt.global_loader.handle",
|
||||
"matcher": ""
|
||||
},
|
||||
"auto_process": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.lifecycle.auto_process.handle",
|
||||
"matcher": "",
|
||||
"timeout": 120
|
||||
}
|
||||
},
|
||||
|
||||
@@ -41,6 +47,11 @@
|
||||
"handler": "aipass.hooks.apps.handlers.security.git_gate.handle",
|
||||
"matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit"
|
||||
},
|
||||
"rm_gate": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.security.rm_gate.handle",
|
||||
"matcher": "Bash"
|
||||
},
|
||||
"engine_test_sound": {
|
||||
"enabled": false,
|
||||
"command": "python3 $AIPASS_HOME/.claude/hooks/engine_test_sound.py",
|
||||
@@ -99,6 +110,12 @@
|
||||
"handler": "aipass.hooks.apps.handlers.lifecycle.rollover.handle",
|
||||
"matcher": "",
|
||||
"timeout": 120
|
||||
},
|
||||
"auto_process": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.lifecycle.auto_process.handle",
|
||||
"matcher": "",
|
||||
"timeout": 120
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ Agent workspace powered by AIPass.
|
||||
|
||||
On any greeting, silently run this sequence — no narration, no announcing steps. Just do it and respond with the status.
|
||||
|
||||
- Read: `.trinity/passport.json`, `.trinity/local.json`, `.trinity/observations.json`, `README.md`, `STATUS.local.md`
|
||||
- Read: `.trinity/passport.json`, `.trinity/local.json`, `.trinity/observations.json`, `README.md`
|
||||
|
||||
Use drone commands for all operations. Never raw git, gh, file access, or python -m when drone provides it.
|
||||
|
||||
|
||||
@@ -40,6 +40,11 @@
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.security.git_gate.handle",
|
||||
"matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit"
|
||||
},
|
||||
"rm_gate": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.security.rm_gate.handle",
|
||||
"matcher": "Bash"
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -14,9 +14,8 @@ Purpose: Button up everything at the end of a session — or before a /compact.
|
||||
Each memory file plays a distinct role. Update based on what actually changed this session.
|
||||
|
||||
- **`.trinity/passport.json`** — IDENTITY. Who you are: role, capabilities, principles. Only update if identity genuinely evolved this session.
|
||||
- **`.trinity/local.json`** — YOUR MEMORY. Add/update session entry with a summary of work done. Add key_learnings for anything learned. Trim oldest sessions if over 20.
|
||||
- **`.trinity/local.json`** — YOUR MEMORY. Add/update session entry with a summary of work done. Add key_learnings for anything learned. Update todos[] with current in-flight items. Trim oldest sessions if over 20.
|
||||
- **`.trinity/observations.json`** — YOUR MEMORY OF THE USER. Collaboration insights, preferences, friction points. Skip if nothing new about the user this session.
|
||||
- **`STATUS.local.md`** — PUBLIC STATUS BEACON. Current work, known issues, todos, notepad. Auto-synced to central STATUS.md on PR events — this is how other branches see you. Keep Current Work accurate.
|
||||
|
||||
## 2. Active Plans
|
||||
|
||||
@@ -38,7 +37,7 @@ Each memory file plays a distinct role. Update based on what actually changed th
|
||||
## 5. Loose Ends
|
||||
|
||||
- Flag anything in-flight: running background agents, dispatched branches waiting for replies, pending decisions
|
||||
- If anything can't survive compaction (e.g., agent IDs needed for resume), write it to STATUS.local.md Notepad
|
||||
- If anything can't survive compaction (e.g., agent IDs needed for resume), write it to local.json todos[]
|
||||
|
||||
## Confirm
|
||||
|
||||
@@ -47,7 +46,6 @@ List everything updated. Format:
|
||||
Prep complete:
|
||||
- local.json: [what was added]
|
||||
- observations.json: [updated / skipped]
|
||||
- STATUS.local.md: [updated / skipped]
|
||||
- Plans: [which ones updated]
|
||||
- Git: [branch, uncommitted count, suggestion]
|
||||
- Inbox: [count, action taken]
|
||||
|
||||
@@ -14,9 +14,8 @@ Purpose: Update branch memory files after completing work this session.
|
||||
Each memory file plays a distinct role. Update based on what actually changed this session.
|
||||
|
||||
- **`.trinity/passport.json`** — IDENTITY. Who you are: role, capabilities, principles. Only update if identity genuinely evolved this session. Don't touch it just to touch it.
|
||||
- **`.trinity/local.json`** — YOUR MEMORY. Session history and key_learnings. Add a session entry for significant work. Add key_learnings for facts you'd need next time. Trim oldest sessions if over 20.
|
||||
- **`.trinity/local.json`** — YOUR MEMORY. Session history, key_learnings, and todos[]. Add a session entry for significant work. Add key_learnings for facts you'd need next time. Update todos[] with open items. Trim oldest sessions if over 20.
|
||||
- **`.trinity/observations.json`** — YOUR MEMORY OF THE USER. Collaboration insights, preferences, friction points, flow states. Skip entirely if nothing new about the user this session.
|
||||
- **`STATUS.local.md`** — PUBLIC STATUS BEACON. Current work, known issues, todos, notepad. Auto-synced to central STATUS.md on PR events — this is how other branches see you. Keep Current Work accurate and drop quick notes in the Notepad section.
|
||||
|
||||
## If Relevant
|
||||
|
||||
|
||||
@@ -25,4 +25,3 @@ Purpose: Update branch memory files after completing work this session.
|
||||
|
||||
- **.trinity/passport.json** — Evolve identity when the branch's role, capabilities, or principles have genuinely changed. Don't update just to update — but don't leave placeholders forever either.
|
||||
- **README.md** — Does it reflect current state? Update if stale.
|
||||
- **STATUS.local.md** — Drop quick notes on issues, todos, or ideas in the Notepad section.
|
||||
|
||||
@@ -38,7 +38,7 @@ Purpose: Button up everything at the end of a session — or before a /compact.
|
||||
## 5. Loose Ends
|
||||
|
||||
- Flag anything in-flight: running background agents, dispatched branches waiting for replies, pending decisions
|
||||
- If anything can't survive compaction, write it to STATUS.local.md Notepad
|
||||
- If anything can't survive compaction, write it to local.json todos[]
|
||||
|
||||
## Confirm
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from pathlib import Path
|
||||
from aipass.seedgo.apps.handlers.audit.branch_audit import audit_branch
|
||||
from aipass.seedgo.apps.handlers.bypass.bypass_handler import load_bypass_rules
|
||||
|
||||
THRESHOLD = 80
|
||||
THRESHOLD = 100
|
||||
|
||||
src = Path("src/aipass")
|
||||
pack = src / "seedgo/apps/handlers/aipass_standards"
|
||||
@@ -30,12 +30,27 @@ for branch in branches:
|
||||
avg = result.get("average", 0)
|
||||
print(f" {branch['name']:>12}: {avg:.0f}%")
|
||||
if avg < THRESHOLD:
|
||||
failed.append((branch["name"], avg))
|
||||
failed.append((branch["name"], avg, result))
|
||||
|
||||
if failed:
|
||||
print(f"\nFAILED: {len(failed)} branch(es) below {THRESHOLD}%")
|
||||
for name, score in failed:
|
||||
for name, score, result in failed:
|
||||
print(f" {name}: {score:.0f}%")
|
||||
# Name the failing standards + the specific checks that did not pass,
|
||||
# so CI logs say WHY (not just the percentage). Critical for diagnosing
|
||||
# working-tree-vs-clean-checkout divergence.
|
||||
scores = result.get("scores", {})
|
||||
results = result.get("results", {})
|
||||
for std, sc in scores.items():
|
||||
if sc < 100:
|
||||
checks = results.get(std, {}).get("checks", [])
|
||||
msgs = [
|
||||
c.get("message", "")
|
||||
for c in checks
|
||||
if not c.get("passed", True)
|
||||
]
|
||||
detail = " | ".join(m for m in msgs if m)[:400]
|
||||
print(f" └ {std}: {sc:.0f}% {detail}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(f"\nAll {len(branches)} branches pass (>={THRESHOLD}%)")
|
||||
|
||||
+30
-12
@@ -6,6 +6,9 @@ on:
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
@@ -13,8 +16,8 @@ jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- run: pip install ruff
|
||||
@@ -28,26 +31,41 @@ jobs:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e ".[dev]"
|
||||
- run: coverage run -m pytest -v --tb=short --rootdir=.
|
||||
# tests/e2e build a wheel + clean venv per the dedicated e2e-wheel.yml
|
||||
# workflow — they are not part of the fast unit lane.
|
||||
- run: coverage run -m pytest -v --tb=short --rootdir=. --ignore=tests/e2e
|
||||
|
||||
standards:
|
||||
name: seedgo-audit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
# Full history: the README-freshness check reads `git log` to find the
|
||||
# last commit touching each branch's .py. A shallow (depth-1) checkout
|
||||
# makes every file look born at HEAD, so every README false-fails as
|
||||
# "stale". Full history makes CI match a local audit exactly.
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e ".[dev]"
|
||||
# Install the `memory` extra (numpy/chromadb/fastembed) alongside dev:
|
||||
# the diagnostics standard runs pyright over every branch, and memory's
|
||||
# handlers import chromadb/numpy. Without these deps installed, pyright
|
||||
# reports them as unresolved imports (reportMissingImports=error) and
|
||||
# memory scores <100 — a false failure from a missing CI dep, not a code
|
||||
# defect. Installing the declared extra lets pyright resolve them so the
|
||||
# audit measures real type-correctness (and matches a local audit).
|
||||
pip install -e ".[dev,memory]"
|
||||
- name: Run seedgo standards audit
|
||||
run: python .github/scripts/seedgo_audit.py
|
||||
|
||||
@@ -56,16 +74,16 @@ jobs:
|
||||
needs: [test]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e ".[dev]"
|
||||
- run: coverage run -m pytest --rootdir=.
|
||||
- run: coverage run -m pytest --rootdir=. --ignore=tests/e2e
|
||||
- run: coverage xml
|
||||
- uses: codecov/codecov-action@v6
|
||||
- uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
with:
|
||||
files: ./coverage.xml
|
||||
fail_ci_if_error: false
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
name: e2e-wheel
|
||||
|
||||
# Cross-OS end-to-end WIRING test (FPLAN-0239, P1 of DPLAN-0194).
|
||||
# Builds the wheel, installs it into a clean venv (handled by the pytest
|
||||
# fixtures in tests/e2e/conftest.py), and runs the 4-tier wiring ladder.
|
||||
#
|
||||
# RED-FIRST: Windows is EXPECTED to fail in known places (symlink init,
|
||||
# bin-vs-Scripts, /tmp). Do not "fix" Windows here — the red is the deliverable.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "tests/e2e/**"
|
||||
- ".github/workflows/e2e-wheel.yml"
|
||||
- "pyproject.toml"
|
||||
- "src/**"
|
||||
pull_request:
|
||||
paths:
|
||||
- "tests/e2e/**"
|
||||
- ".github/workflows/e2e-wheel.yml"
|
||||
- "pyproject.toml"
|
||||
- "src/**"
|
||||
workflow_dispatch:
|
||||
|
||||
# Least-privilege token (Scorecard Token-Permissions). This workflow only
|
||||
# reads the repo to build + smoke-test the wheel; it needs no write scopes.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
e2e-wheel:
|
||||
name: e2e-wheel (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
python-version: ["3.12"]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install build tooling
|
||||
run: python -m pip install --upgrade pip build pytest
|
||||
|
||||
- name: Run cross-OS e2e wiring harness
|
||||
# conftest.py builds the wheel + clean venv internally; the outer env
|
||||
# only needs build + pytest.
|
||||
run: python -m pytest tests/e2e -v
|
||||
@@ -2,27 +2,25 @@ name: macOS Test
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
# Run on every push/PR to main/dev — NOT path-filtered. This is a branch-
|
||||
# protection *required* check; a path filter makes it skip on unrelated PRs,
|
||||
# which GitHub then parks as "Expected — waiting for status" forever, blocking
|
||||
# the merge. Required checks must run on every PR to report a status.
|
||||
push:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- 'setup.sh'
|
||||
- 'src/aipass/*/apps/handlers/__init__.py'
|
||||
- 'src/aipass/drone/cli.py'
|
||||
- 'pyproject.toml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'setup.sh'
|
||||
- 'src/aipass/*/apps/handlers/__init__.py'
|
||||
- 'src/aipass/drone/cli.py'
|
||||
- 'pyproject.toml'
|
||||
branches: [main, dev]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
macos-setup:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
@@ -44,7 +42,7 @@ jobs:
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: macos-pytest-results
|
||||
path: pytest-output.txt
|
||||
|
||||
@@ -5,17 +5,20 @@ on:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- run: pip install build
|
||||
- run: python -m build
|
||||
- uses: actions/upload-artifact@v7
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
@@ -27,23 +30,35 @@ jobs:
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
- uses: pypa/gh-action-pypi-publish@release/v1
|
||||
- uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
|
||||
|
||||
github-release:
|
||||
needs: publish
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write # keyless Sigstore signing (OIDC); no signing key exists
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
- name: Sign artifacts with Sigstore (keyless, OIDC)
|
||||
# Produces dist/<artifact>.sigstore.json bundles next to each wheel/sdist.
|
||||
# The 'gh release create dist/*' step below then attaches them to the
|
||||
# GitHub Release, which is where Scorecard's Signed-Releases check looks.
|
||||
# release-signing-artifacts is disabled: the action's own auto-attach only
|
||||
# fires on a 'release: published' event, but we trigger on 'push: tags',
|
||||
# so we upload the bundles ourselves via the dist/* glob.
|
||||
uses: sigstore/gh-action-sigstore-python@04cffa1d795717b140764e8b640de88853c92acc # v3.3.0
|
||||
with:
|
||||
inputs: ./dist/*.tar.gz ./dist/*.whl
|
||||
release-signing-artifacts: false
|
||||
- name: Extract latest CHANGELOG section
|
||||
run: |
|
||||
# Grab the topmost "## [...]" block from CHANGELOG.md as release notes.
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -41,6 +41,6 @@ jobs:
|
||||
retention-days: 5
|
||||
|
||||
- name: "Upload to code-scanning"
|
||||
uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -8,6 +8,9 @@ on:
|
||||
schedule:
|
||||
- cron: "0 6 * * 1"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
@@ -15,22 +18,31 @@ jobs:
|
||||
dependency-scan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- run: pip install pip-audit
|
||||
# Upgrade pip first: pip-audit scans the whole environment, and the
|
||||
# runner's bundled pip (26.1.1) carries advisory PYSEC-2026-196 (fixed in
|
||||
# 26.1.2). Upgrading removes the vulnerable version outright rather than
|
||||
# suppressing it — and 26.1.2 also resolves CVE-2026-3219 / CVE-2026-6357,
|
||||
# which is why those two stale --ignore-vuln entries are no longer needed.
|
||||
- run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pip-audit
|
||||
- run: pip install -e .
|
||||
- name: Pip audit
|
||||
run: pip-audit --skip-editable --ignore-vuln CVE-2026-3219 --ignore-vuln CVE-2026-6357
|
||||
run: pip-audit --skip-editable
|
||||
|
||||
codeql:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: github/codeql-action/init@v3
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
languages: python
|
||||
- uses: github/codeql-action/analyze@v3
|
||||
- uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
|
||||
@@ -4,11 +4,17 @@ on:
|
||||
schedule:
|
||||
- cron: "0 0 * * 1"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@v10
|
||||
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
stale-issue-message: "This issue has been inactive for 30 days. It will be closed in 7 days if no further activity occurs."
|
||||
days-before-stale: 30
|
||||
|
||||
@@ -2,27 +2,25 @@ name: Windows Test
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
# Run on every push/PR to main/dev — NOT path-filtered. This is a branch-
|
||||
# protection *required* check; a path filter makes it skip on unrelated PRs,
|
||||
# which GitHub then parks as "Expected — waiting for status" forever, blocking
|
||||
# the merge. Required checks must run on every PR to report a status.
|
||||
push:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- 'setup.sh'
|
||||
- 'src/aipass/*/apps/handlers/__init__.py'
|
||||
- 'src/aipass/drone/cli.py'
|
||||
- 'pyproject.toml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'setup.sh'
|
||||
- 'src/aipass/*/apps/handlers/__init__.py'
|
||||
- 'src/aipass/drone/cli.py'
|
||||
- 'pyproject.toml'
|
||||
branches: [main, dev]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
windows-setup:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
@@ -49,7 +47,7 @@ jobs:
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: windows-pytest-results
|
||||
path: pytest-output.txt
|
||||
|
||||
+2
-2
@@ -110,7 +110,6 @@ src/aipass/*/apps/integrations/**
|
||||
!src/aipass/spawn/templates/builder/docs.local/
|
||||
!src/aipass/spawn/templates/builder/docs.local/**
|
||||
!src/aipass/spawn/templates/builder/DASHBOARD.local.json
|
||||
!src/aipass/spawn/templates/builder/STATUS.local.md
|
||||
|
||||
# CI artifacts
|
||||
windows-pytest-results/
|
||||
@@ -125,4 +124,5 @@ branch_audits/
|
||||
claude_4_7_transition_notes.md
|
||||
README_ORIGINAL_DISABLED.md
|
||||
*.bak
|
||||
test/
|
||||
test/
|
||||
sandbox_test/
|
||||
|
||||
@@ -8,7 +8,7 @@ User: user
|
||||
|
||||
On any greeting, silently read these files from CWD and run the commands — no narration, no announcing steps. Just do it and respond with the status.
|
||||
|
||||
- Read: `.trinity/passport.json`, `.trinity/local.json`, `.trinity/observations.json`, `README.md`, `STATUS.local.md`
|
||||
- Read: `.trinity/passport.json`, `.trinity/local.json`, `.trinity/observations.json`, `README.md`
|
||||
- Check: `drone @ai_mail inbox` — process any mail, don't ask.
|
||||
- Run: `drone @git status`
|
||||
|
||||
|
||||
+491
-5
@@ -2,13 +2,499 @@
|
||||
|
||||
All notable changes to AIPass will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project uses [Calendar Versioning](https://calver.org/) in the format
|
||||
`YYYY.WNN` (year and ISO week number).
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
Entries are grouped by merge under a dated section header (`YYYY-MM-DD`). Package
|
||||
releases follow [SemVer](https://semver.org/) and are tracked by the git tag and
|
||||
PyPI version — not the changelog header.
|
||||
|
||||
---
|
||||
|
||||
## [2026.W22] - 2026-05-30
|
||||
## [2026-06-11]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **seedgo audit local↔CI parity (FPLAN-0261).** The local `seedgo` audit could
|
||||
silently diverge from CI, breaking the "pass locally first, then ship" gate.
|
||||
Three independent causes, all closed without coupling any checker to git
|
||||
(`.gitignore` is git's concern, not the audit's): (1) usage-scanning checkers
|
||||
(`unused_function`, `dead_code`, +4) `rglob`'d gitignored *output* dirs
|
||||
(`artifacts/`, `dropbox/`), so a stray local file could mark a function "used"
|
||||
that a clean checkout correctly flags — every per-checker skip list hoisted to
|
||||
one shared `SOURCE_SKIP_DIRS` (output dirs simply aren't source). (2) The
|
||||
`diagnostics` standard shelled out to bare `python3 -m pyright` (system python,
|
||||
no pyright) and, on the resulting JSON-parse failure, returned *0 errors = clean*
|
||||
— a silent false-green; now uses `sys.executable` and **fails loud**. (3) pyright
|
||||
resolved imports against PATH-python, so results flipped with `.venv` activation
|
||||
— pinned via `--pythonpath sys.executable`. The audit is now deterministic
|
||||
local == CI (proven all-13-branches-100% in an unactivated shell). Also: `drone`
|
||||
bypasses the test-only broker `start_background` (intentional API, not dead code).
|
||||
- **windows-setup CI: guard Linux-only sandbox tests.** The kernel-sandbox build
|
||||
is Linux-only (bwrap, `AF_UNIX` sockets, `openat2`); the code already guards on
|
||||
`sys.platform`, but four test surfaces ran unconditionally and failed on
|
||||
`windows-latest`. Module-level `pytestmark = pytest.mark.skipif(sys.platform !=
|
||||
"linux", …)` on `drone/tests/test_broker.py` and `hooks/tests/test_sandbox.py`;
|
||||
scoped guards on the remaining `AF_UNIX` broker-socket tests —
|
||||
`ai_mail/.../test_dispatch_monitor.py::TestBrokerRealE2E` (class) and
|
||||
`aipass/.../test_sandbox_check.py::TestCheckBrokerAlive` socket-connect tests
|
||||
(method-level, so the graceful no-broker paths still run on Windows). All skip on
|
||||
Windows and run unchanged on Linux. windows-setup was green pre-sandbox-merge
|
||||
(`00edd8b`) and red since (`0b4ba63`); this closes it.
|
||||
- **Broker `start_background` connect-before-bind race.** `drone`'s out-of-sandbox
|
||||
broker daemon started via `start_background()`, which returned *before* the
|
||||
`AF_UNIX` socket was bound — callers then raced the bind, and on a slower machine
|
||||
`create_identified_connection()` hit `FileNotFoundError` (socket not yet present).
|
||||
Deterministic locally (`test_delete_nested_file` 0/5), green in CI only by timing
|
||||
luck — latent flakiness. Fixed with a `threading.Event` set right after `listen()`;
|
||||
`start_background(timeout=5.0)` now blocks on it and **raises** if the socket never
|
||||
binds, so callers never guess a `sleep`. Removed the 4 blind `time.sleep(0.15)`
|
||||
waits from the broker tests. Verified 55/55 broker tests, formerly-failing test 10/10.
|
||||
|
||||
### Added
|
||||
|
||||
- **`aipass init` detects missing Claude Code.** Stage 6 (CLI choice) now checks
|
||||
`shutil.which("claude")` when the picked CLI is Claude Code. If absent: interactive
|
||||
runs prompt `Install now? [Y/n]` and run the canonical installer on yes (native
|
||||
`claude.ai/install.sh`, PowerShell on Windows, `npm` fallback, 300s timeout, loud on
|
||||
failure); non-interactive runs warn and continue. The whole system routes through
|
||||
Claude Code (hook bridge, dispatch, prompt injection), so init no longer silently
|
||||
assumes the runtime is present. Only fires when the chosen CLI is `claude`.
|
||||
- **Kernel filesystem boundary for agent containment (DPLAN-0202 / FPLAN-0250).**
|
||||
Every autonomous agent can now launch inside a kernel-enforced mount namespace
|
||||
(`@anthropic-ai/sandbox-runtime` → bwrap+seccomp) where reads stay fully open
|
||||
(the shared live filesystem is preserved — a bind-mount, *not* isolation: own-tree
|
||||
writes land live on the real FS instantly) but deletes/overwrites of protected
|
||||
paths (`.git`, sibling branch trees) fail at the kernel no matter how the call is
|
||||
phrased — `rm`, `python os.remove`, `find -delete`, Write tool all hit EROFS.
|
||||
`/tmp` and the agent's own tree stay writable; `.git` is RW for devpulse, RO for
|
||||
builders. A per-role policy generator (`@hooks build_policy`) derives each branch's
|
||||
writable/RO map from its passport. Privileged deletes route through an
|
||||
out-of-sandbox **drone-broker** daemon: identity-scoped allowlist, `openat2`
|
||||
RESOLVE_BENEATH path re-resolution (confused-deputy proof), HMAC identity handshake
|
||||
over a pre-connected inherited fd, JSONL audit. `aipass doctor` gained a **Sandbox**
|
||||
check group (bwrap present+functional, node, srt, rg, broker socket) that is LOUD
|
||||
when the flag is on and a prereq is missing — never a silent unsandboxed launch.
|
||||
Proven by a live 16-check red-team suite. **Inert by default** — gated behind
|
||||
`AIPASS_SANDBOX_ENABLED` (off); flag-off is byte-identical to the old dispatch path.
|
||||
- **rm_gate demoted to guardrail.** Now framed honestly as early-feedback that
|
||||
catches the accidental `rm -rf` and teaches `drone rm` — belt-and-suspenders, with
|
||||
the kernel sandbox as the actual filesystem boundary.
|
||||
|
||||
- **Prompt-injection cadence — fire the big loaders every Nth turn.** The global
|
||||
and branch prompts are large and were re-injected on *every* turn even though a
|
||||
prior copy stays in the conversation. They now fire together every 5th turn
|
||||
(config-tunable via `hooks_json/custom_config/cadence_config.json`), with a
|
||||
per-session turn counter that resets on a new session and after compaction so
|
||||
context is always rebuilt when it's actually needed. Identity and the mail flag
|
||||
stay every-turn (tiny, want freshness). Cuts recurring per-turn context cost.
|
||||
- **Hook fire/skip observability.** Cadence emits a structured
|
||||
`[HOOKS] cadence fired|skipped loader= turn= period= offset=` line; the prax
|
||||
monitor renders hook events distinctly so the cadence is visible live.
|
||||
- **Slim global prompt — context-on-demand.** The always-injected global prompt
|
||||
was rewritten from a ~13.8KB encyclopedia into a ~7.8KB navigation map
|
||||
(DPLAN-0201): `drone` pinned as the router, the framework tree, all 13 agents
|
||||
as short bios, and one drilled reflex — run `drone @agent --help` before using
|
||||
a branch. Detail now lives in each agent's `--help`, fetched on demand. This
|
||||
also dissolves the harness ~10k-char truncation that was silently dropping the
|
||||
old prompt's tail; the slim prompt injects whole. Backup retained alongside.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Shared leaf library re-homed: `aipass.common` → `aipass.aipass.shared` (FPLAN-0260).**
|
||||
`src/aipass/common/` was the only non-citizen directory in the agent namespace —
|
||||
a shared lib (json_handler / json_ops / registry_discovery, extracted in
|
||||
TDPLAN-0006 P2) parked as a sibling to the agents with no owner. Per @seedgo
|
||||
design review it now lives inside its steward at `src/aipass/aipass/shared/`,
|
||||
owned by @aipass; @spawn imports across (same blessed shared-infra category as
|
||||
`aipass.prax`/`aipass.cli`). Content byte-identical; ~9 import/doc sites updated
|
||||
across aipass+spawn. A new subprocess guard test pins the bootstrap-safety
|
||||
invariant: importing `shared/` loads zero branch dependencies, so `aipass init`
|
||||
keeps working pre-drone on fresh machines. Note: `aipass.common` shipped in the
|
||||
v2.5.2 wheel; it was internal plumbing — no deprecation shim.
|
||||
- **Action-gated hook sound.** Piper now speaks only when a hook actually *does*
|
||||
something — handlers return a `sound` key the engine plays, instead of
|
||||
announcing on every invocation. Skipped loaders are silent. Quieter and honest.
|
||||
- **README: hardcoded metrics → live badges + qualitative.** Version is now a
|
||||
live PyPI badge, test/PR counts replaced with a codecov coverage badge (75%
|
||||
minimum) and qualitative wording — no more stale numbers to hand-maintain.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Cadence counter separate-process race.** Each `UserPromptSubmit` hook runs as
|
||||
its own OS process, so a module-level turn cache double-incremented and the
|
||||
loaders leapfrogged (firing erratically instead of together). Fixed with an
|
||||
mtime debounce + transcript-size token + `flock` so the counter advances exactly
|
||||
once per real turn, verified against the live execution model.
|
||||
- **`auto_fix` ran no diagnostics.** A leftover `speak()` call (its import removed
|
||||
in the sound refactor) raised `NameError` on every edit, swallowed by the
|
||||
handler's broad `except` — so auto-fix silently surfaced nothing on any
|
||||
`.py`/`.json` edit. Removed the dead call; diagnostics run again.
|
||||
- **Hook events never colored in the monitor.** The prax log-watcher's
|
||||
`_HOOK_PATTERN` required an `action=` field that cadence never emits (it logs
|
||||
the action as the bare second word, `fired`/`skipped`), so extraction failed
|
||||
and events fell through to plain rendering instead of the styled
|
||||
bold-green ⚡ / dim · treatment. Fixed the regex to capture the bare action
|
||||
word and enriched the event detail (period, offset, short session id).
|
||||
|
||||
### Security
|
||||
|
||||
- **Least-privilege token on the `e2e-wheel` workflow.** `e2e-wheel.yml` was the
|
||||
one CI workflow missing a top-level `permissions:` block (it was added during
|
||||
the cross-OS work after PR #624 hardened the others), so it ran with the
|
||||
default broad `GITHUB_TOKEN` scopes — dropping the OpenSSF Scorecard
|
||||
Token-Permissions check to 0. Added `permissions: contents: read`; the
|
||||
workflow only reads the repo to build and smoke-test the wheel.
|
||||
- **Signed GitHub Releases via Sigstore (keyless).** The release workflow now
|
||||
signs the built wheel + sdist with `sigstore/gh-action-sigstore-python`
|
||||
(keyless OIDC — no signing key is generated, stored, or held by anyone) and
|
||||
attaches the resulting `.sigstore.json` bundles to the GitHub Release. PyPI
|
||||
uploads were already attested via Trusted Publishing; this extends verifiable
|
||||
provenance to artifacts pulled from GitHub Releases and satisfies the OpenSSF
|
||||
Scorecard Signed-Releases check. First proof lands on the next `v*` tag.
|
||||
|
||||
---
|
||||
|
||||
## [2026-06-02]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`aipass init` scaffold correctness.** A fresh `aipass init` now generates a
|
||||
project-specific `AGENTS.md` (new `agents_md()` generator) instead of falling
|
||||
back to copying AIPass's own repo-root `AGENTS.md` boilerplate — Codex users
|
||||
were getting the wrong file. Project `README.md` quick-start/structure paths
|
||||
now reflect the real `src/<package>/<agent>/` layout.
|
||||
- **First-agent default name `my-agent` → `my_agent`.** `aipass init` seeded its
|
||||
default agent with a hyphen, the lone source of a long-standing dir-vs-module
|
||||
mismatch (the directory kept the hyphen while the importable module, `@address`
|
||||
and registry name all normalize to underscore). Defaulting to `my_agent` makes
|
||||
directory, module, `@address` and the README example all consistent.
|
||||
- **Dead `citizenship.registry_path` removed from spawn templates.** The field
|
||||
pointed at a non-existent `.aipass/registry.json`; it was never read anywhere
|
||||
(registry is located by `find_registry()` glob), so it's dropped from the
|
||||
`builder` and `birthright` passport templates.
|
||||
|
||||
### Removed
|
||||
|
||||
- **The entire STATUS flow is decommissioned (TDPLAN-0007).** The per-branch
|
||||
hand-maintained `STATUS.local.md` beacon and the auto-aggregated central
|
||||
`STATUS.md` (853 lines / 70 KB nobody read) are gone — deleted from disk
|
||||
across all 13 branches and scrubbed from every prompt, doc, startup protocol,
|
||||
`/prep` + `/memo` skill, the compact-recovery hook, the email footer, and
|
||||
`aipass init` / spawn scaffolding. Live branch state was already fully covered
|
||||
by `DASHBOARD.local.json` (prax) and history by `.trinity/local.json`. The
|
||||
status-sync engine is kept **intact but inert** — made dormant by unwiring its
|
||||
3-line trigger registration (`trigger registry.py`), so the code stays
|
||||
revivable. The one thing STATUS uniquely gave us — a quick scratch todo — is
|
||||
replaced by an operational `todos[]` section in `.trinity/local.json`
|
||||
(@memory-owned schema, capped, never vectorized by rollover), pushed to all 13
|
||||
branches and surfaced as a `todo_count` on the dashboard. Shipped as one
|
||||
coordinated cross-branch change (memory, prax, trigger, aipass, spawn, hooks,
|
||||
ai_mail, seedgo + devpulse).
|
||||
|
||||
### Changed
|
||||
|
||||
- **All 13 branches at seedgo 100% under the new introspection standard.**
|
||||
Wrapped `print_introspection()` output in Rich markup across ai_mail, drone,
|
||||
spawn, trigger, prax and devpulse (the rest were already compliant) —
|
||||
presentation only, no logic change — so `drone @branch` with no args renders
|
||||
consistent styled output everywhere.
|
||||
- **CLI polish for human-facing output.** `drone @hooks --help` rewritten (Rich,
|
||||
with `hooksound on/off/status` now surfaced); `drone @spawn` repair help
|
||||
clarified as distinct from `update` and showing the preview/`--apply` flow;
|
||||
drone restores Rich colour on human-facing routed output (`--help`,
|
||||
introspection, `status`) via the inherit path.
|
||||
- **Spawn backups land in one namespace `.spawn/.recovery/` (TDPLAN-0006 P4).**
|
||||
Spawn's pre-merge JSON backups previously dropped a `.recovery/` directory at
|
||||
each branch root (which had accumulated 242 stale auto-generated `DASHBOARD`
|
||||
backups across 10 branches). `aipass.common.json_ops.backup_json` gained an
|
||||
optional `backup_dir` parameter (default unchanged), and spawn's update engine
|
||||
now directs backups to `{branch}/.spawn/.recovery/` — tucked under the
|
||||
spawn-managed `.spawn/` dir instead of cluttering the branch root. Memory stays
|
||||
in the safety net (the engine simply never touches `.trinity/`/`DASHBOARD` on
|
||||
update, so it never needs to back them up). Stale `.recovery/` backups cleaned
|
||||
up. (315 tests, seedgo 100%.)
|
||||
- **No more cross-branch engine imports — `aipass init update` calls spawn via
|
||||
subprocess (TDPLAN-0006 P3).** `init_flow.py` previously did
|
||||
`from aipass.spawn.apps.modules.sync_registry import sync_registry` — the one
|
||||
place aipass reached directly into spawn's Python. Replaced with a subprocess
|
||||
call to the already-existing `drone @spawn sync-registry --fix` (same pattern as
|
||||
`aipass init agent` → `drone @spawn create`), preserving graceful degradation
|
||||
(a missing `drone`, non-zero exit, or timeout is silently skipped — registry
|
||||
sync never hard-fails an update). The aipass branch now has **zero** direct
|
||||
imports of another branch's engine code; the remaining cross-branch imports are
|
||||
shared service layers only (cli Rich UI, prax logging, trigger events). (438
|
||||
tests, seedgo 100%.)
|
||||
- **`aipass.common` shared library — dedup spawn/aipass scaffold machinery
|
||||
(TDPLAN-0006 P2).** `@spawn` and `@aipass` each carried their own copy of the
|
||||
JSON merge/handler utilities and registry discovery. Extracted them into a new
|
||||
branch-free package `src/aipass/common/` (`json_ops` = `deep_merge` +
|
||||
`backup_json`; `json_handler.JsonHandler`; `registry_discovery.find_registry`)
|
||||
that both branches now import. `aipass.common` imports **zero** branch code, so
|
||||
`aipass/bootstrap.py` (which runs before the drone runtime exists) can depend on
|
||||
it without breaking the pre-infrastructure constraint. The duplicated copies are
|
||||
deleted (spawn keeps a thin re-export shim; aipass's `json_handler` shrank
|
||||
254 → 88 lines). The `save_json` contract is unified to **raise `ValueError`**
|
||||
on invalid structure across both branches. (313 spawn + 434 aipass tests, both
|
||||
seedgo 100%.)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Flow plan-type self-serve UX — register override, help, orphan cleanup.**
|
||||
Explicit `drone @flow register <dir> <PREFIX>` now overrides an auto-derived
|
||||
prefix instead of silently failing (guarded — refuses if the auto-registered
|
||||
type already holds plans), so custom prefixes are settable when adding a new
|
||||
plan type. `create`/`templates --help` rewritten to dynamically list registered
|
||||
types + templates and document the add-a-new-type workflow. Stale orphan plan
|
||||
registries removed; dead `prefix_exists()` dropped. (728 tests, seedgo 100%.)
|
||||
- **`drone @spawn update` no longer scrambles branches (#636, critical — TDPLAN-0006
|
||||
P0+P1).** The update engine compared a freshly-created branch against the class
|
||||
template by *content hash* with rename-detection, and because the CREATE path
|
||||
regenerated template-registry IDs in filesystem-walk order (≠ the master's
|
||||
hand-crafted IDs), a branch created seconds earlier produced **30 proposed renames**
|
||||
that rotated identity/memory dirs into each other
|
||||
(`apps→.trinity→.seedgo→.claude→.archive→.aipass`), turned `README` into
|
||||
`DASHBOARD`, and deep-merged stale template into live `.trinity/` memory —
|
||||
`update <class> --all` would have destroyed every citizen in one command. Rebuilt
|
||||
`update_ops.py` (v2.0) on an explicit **named-managed-files + path-based** model:
|
||||
`.trinity/*`, `DASHBOARD.local.json`, `artifacts/birth_certificate.json` and
|
||||
`.seedgo/bypass.json` are delivered on **create only** and never touched on update;
|
||||
the create==update invariant now yields **0 renames / 0 merges** on a fresh branch.
|
||||
The old ID-based engine (`change_detection.py`, `reconcile.py`) is deleted.
|
||||
- **Destructive spawn ops are now dry-run by default (TDPLAN-0006 P0).** `drone @spawn
|
||||
update` and `drone @spawn repair` preview by default and require an explicit
|
||||
`--apply` to write — forgetting a flag is now a safe no-op instead of irreversible
|
||||
damage (`--dry-run` kept as an alias). `aipass doctor` repair suggestions emit the
|
||||
matching `--apply` form.
|
||||
|
||||
### Added
|
||||
|
||||
- **Introspection Rich-formatting standard (seedgo).** New
|
||||
`check_introspection_rich_formatting` checker enforces that each branch's
|
||||
`print_introspection()` output uses Rich markup (delegation-aware — it walks
|
||||
`_`-prefixed helper functions), keeping no-arg `drone @branch` output styled and
|
||||
consistent. Documented in `introspection.md`; all 13 branches brought into
|
||||
compliance (see Changed).
|
||||
- **Playbook plan type (`PBPLAN`) — reusable SOP checklists (flow).** A new
|
||||
`playbook_plans` template family for throwaway, vectorize-on-close operational
|
||||
runbooks (first SOP: the Sunday merge). Drop a `.md` under
|
||||
`templates/playbook_plans/`, register once, then
|
||||
`drone @flow create . "subject" <sop>` stamps a run to tick through and close.
|
||||
- **Memory-pool auto-processing (TDPLAN-0005)** — dropped files in
|
||||
`memory/memory_pool/` are now vectorized and archived automatically on
|
||||
session-start and pre-compact, instead of requiring a manual
|
||||
`drone @memory pool process`. A 3-branch build: `@memory` gains an intake
|
||||
handler + `pool` module (processes then empties the pool, `keep_recent=0`),
|
||||
`@hooks` adds a `lifecycle/auto_process` handler (session-guarded via
|
||||
`CLAUDE_CODE_SESSION_ID`, since Claude Code has no SessionStart hook), and
|
||||
`@trigger` gains event #15 (`memory_pool_auto_processed`) with a Medic error
|
||||
path. Runtime pool dirs (`memory_pool/`, `memory_pool_archive/`) are now
|
||||
gitignored.
|
||||
- **HVTracker badge** added to the README badge cluster, linking to the public
|
||||
agent profile at hvtracker.net (closes #628).
|
||||
- **`git_gate` read-verb allowlist — raw read-only git for every branch.** The
|
||||
PreToolUse `git_gate` previously blocked *all* raw git (forcing `drone @git`
|
||||
even for harmless reads), which left agents unable to inspect what git ships —
|
||||
the exact forensics needed to diagnose the audit gap above. It now allows 22
|
||||
read-only verbs raw (`ls-files`, `ls-tree`, `show`, `cat-file`, `rev-parse`,
|
||||
`rev-list`, `log`, `status`, `diff`, `blame`, `archive`, `grep`, …) while
|
||||
write operations stay `drone`-gated. Global options (`-C`, `-c`, `--git-dir`,
|
||||
…) are skipped when extracting the verb, and chained commands are split on
|
||||
`&&`/`||`/`;`/`|` so a read piped into a write still blocks the whole line.
|
||||
(81 tests)
|
||||
- **Cross-OS end-to-end WIRING test (`tests/e2e/`, `e2e-wheel.yml`)** — the first
|
||||
CI gate that proves real AIPass *wiring* (not units-with-mocks) by building the
|
||||
wheel, installing it into a clean venv, and asserting a 4-tier ladder: package
|
||||
install + console scripts (T0), `aipass init` scaffolding (T1), a hook actually
|
||||
firing via the bridge with an observable `engine.jsonl` record (T2a), and
|
||||
`drone` resolving + subprocess-executing a real branch (T3). Runs on a 3-OS
|
||||
matrix (ubuntu/windows/macos, `fail-fast: false`). Ran red-first on Windows by
|
||||
design and immediately earned its keep — it caught two real, *previously
|
||||
uncovered* Windows wiring bugs (`aipass init` preflight + `drone` stdout
|
||||
encoding, both fixed below). Notably the layers we most feared — clean-wheel
|
||||
install (T0) and hook firing (T2a) — passed on Windows. (DPLAN-0194 /
|
||||
FPLAN-0239)
|
||||
- **`drone rm` — provider-agnostic safe delete** — a contained recursive delete
|
||||
that lets agents clean up scratch dirs without tripping the `rm -rf` block.
|
||||
Deletes are confined to the project root and the system temp dirs (`/tmp` and
|
||||
`$TMPDIR`), refusing anything outside (home, `/etc`, `/`, etc.). Even inside
|
||||
those roots it hard-refuses protected internals — `.git`, `.trinity/`,
|
||||
`.aipass/`, `.codex/`, `.agents/`, and sibling-branch worktrees — mirroring the
|
||||
filesystem boundary an OS-sandboxed agent (e.g. Codex) enforces, so behavior is
|
||||
consistent across CLIs. Pure-Python (`shutil.rmtree`), with a red-team test
|
||||
suite for containment escapes (symlinks, traversal, sibling branches). (#630)
|
||||
- **`rm_gate` hook — block raw recursive `rm`, teach the safe path** — a
|
||||
PreToolUse gate (mirroring `git_gate`) that blocks raw `rm -r`/`-rf`/`-fr`/
|
||||
`--recursive` and redirects the agent to `drone rm`. Provider-agnostic (runs in
|
||||
the hook engine, not tied to Claude Code permission rules), conservative
|
||||
(unparseable targets are blocked, not allowed), and skips `drone rm` itself.
|
||||
This makes the safe-delete path discoverable at the moment of friction. (#630)
|
||||
- **Hook engine logs `agent_type` / `agent_id` per fire** — the engine now
|
||||
records which agent triggered each hook (e.g. `agent=main` vs `agent=Explore`)
|
||||
in both `engine.jsonl` and the prax monitor stream. Previously the payload
|
||||
flowed into handlers but was never logged, leaving no way to tell an internal
|
||||
main-turn fire from a real sub-agent fire. Pure visibility; no behavior change.
|
||||
Groundwork for #606. (#606)
|
||||
- **OpenSSF Best Practices passing badge** — AIPass earned the OpenSSF Best
|
||||
Practices (CII) **passing** badge (100% of criteria), added to the README badge
|
||||
cluster. Self-certified across all six categories — basics, change control,
|
||||
reporting, quality, security, and analysis. Complements the existing OpenSSF
|
||||
Scorecard, lifting the `CII-Best-Practices` check from 0. (DPLAN-0193)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Standards floor raised to genuine 100% across all 13 branches** — completed
|
||||
the campaign that lifted the seedgo gate threshold from 80 to 100. Rather than
|
||||
bypass failing files, two check *flaws* were fixed at the root: (1) the
|
||||
**file-size / architecture check is now advisory** (warn-only for 700–1500 line
|
||||
files with no docstring nudge, hard-fail only above 1500) — large files are a
|
||||
smell, not a defect; (2) **readme-freshness now compares against git history,
|
||||
not file mtime** — `git checkout`/`merge` reset mtimes without any semantic
|
||||
change, so the old check false-positived (flow + prax shared an identical
|
||||
mtime from one git event, not real edits). It now diffs the README's "Last
|
||||
Updated" against the last commit that touched `.py`. Genuine content fixes
|
||||
where warranted (aipass requirements template + handler routing; honest README
|
||||
content refreshes on flow, prax, devpulse). The readme-freshness **failure
|
||||
message now teaches** the right fix ("update README content, then set the date
|
||||
— don't just bump it"). Also optimized the devpulse watchdog poll cadence
|
||||
(2s → 5s; the loop is cheap, so the tighter interval was wasted CPU). (#631)
|
||||
|
||||
- **Retired the blanket `rm` deny from provider settings** — `setup.sh` and
|
||||
`aipass init` no longer ship `Bash(rm -rf*)` / `Bash(rm -r *)` deny rules
|
||||
(they were mis-filed among git rules, blocked all `/tmp` cleanup, and gave a
|
||||
bare "permission denied" with no guidance). The `rm_gate` hook + `drone rm`
|
||||
now own this — cross-provider, path-aware, and they teach. `aipass doctor`
|
||||
detects the stale rules on existing installs and `aipass doctor --fix` removes
|
||||
them (idempotent, preserves all other rules). Claude Code still natively
|
||||
circuit-breaks `rm -rf /` and `rm -rf ~`. (#630)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`Windows Test` / `macOS Test` are no longer path-filtered — they were
|
||||
stalling PRs as required checks.** Both workflows only triggered when
|
||||
`setup.sh`/`drone/cli.py`/`handlers/__init__.py`/`pyproject.toml` changed, but
|
||||
branch protection lists `windows-setup`/`macos-setup` as *required*. On any PR
|
||||
that didn't touch those paths the workflows never ran, so GitHub parked the
|
||||
required checks as "Expected — waiting for status" indefinitely, blocking the
|
||||
merge (the tests themselves were green — they simply didn't fire). They now run
|
||||
on every push/PR to main/dev, like the other required lanes. (A required check
|
||||
must never be path-filtered.)
|
||||
- **`seedgo-audit` CI gate was red despite 100% local audits — four checkers
|
||||
validated the working tree instead of committed source.** CI audits a clean
|
||||
`git checkout` (tracked files only — git ships no empty or gitignored dirs),
|
||||
but the working tree carries runtime dirs (`logs/`, `*_json/`, `artifacts/`,
|
||||
`.trinity/`, `passport.json`), so every branch scored ~97% in CI while passing
|
||||
at 100% locally. Reproduced exactly with a tracked-only tree (`git archive HEAD`
|
||||
audits to CI's 97%). Four checkers now measure what git actually ships:
|
||||
`log_structure` no longer fails when the gitignored `logs/` dir is absent (it
|
||||
still enforces no-hardcoded-paths); `readme` cross-references `.gitignore`
|
||||
(via `git check-ignore` with a fallback list) and skips gitignored dirs/links
|
||||
in the directory-tree and dead-link checks; `encapsulation` infers the branch
|
||||
from the path when the gitignored `AIPASS_REGISTRY.json` is unavailable (and no
|
||||
longer collides on the `aipass` branch); `architecture` skips cleanly when the
|
||||
gitignored `passport.json` is absent. A follow-up refined `readme`'s
|
||||
`git check-ignore` use: `.gitignore` dir-only patterns (trailing slash —
|
||||
`logs/`, `**/*_json/`, `.trinity/`) don't match a clean checkout's
|
||||
non-existent paths unless directory intent is signalled, so the check now
|
||||
also tests the trailing-slash form (this was the last 1% — `readme` flagged
|
||||
`cli_json`/`logs`/`artifacts` as "missing on disk" in CI only). The CI gate
|
||||
(`.github/scripts/seedgo_audit.py`) now also prints the failing standards and
|
||||
their check messages, so a sub-100 result says *why*, not just the percentage.
|
||||
Finally, the `seedgo-audit` CI job now installs the `memory` extra
|
||||
(`pip install -e ".[dev,memory]"`): the `diagnostics` standard runs pyright over
|
||||
every branch, and memory's handlers import `chromadb`/`numpy` at module level —
|
||||
without those declared deps installed, pyright reported them as unresolved
|
||||
(`reportMissingImports=error`) and memory scored 55%, a false failure from a
|
||||
missing CI dep rather than a code defect. Clean-tree and working-tree audits
|
||||
both report 13/13 = 100%. (DPLAN-0195)
|
||||
- **Two latent Windows portability bugs caught by the new e2e harness** — both
|
||||
were always present in the code; they only surfaced now because this is the
|
||||
first CI to run `aipass init` scaffolding and real-branch `drone` routing on
|
||||
Windows (the old Windows CI ran an editable install, `aipass`-less, and only
|
||||
routed to in-process modules, so both paths had zero Windows coverage). Pure
|
||||
portability fixes — Linux/macOS behaviour is unchanged.
|
||||
- **`aipass init` crashed on Windows (surfaced as a misleading "Unknown
|
||||
command: init").** Init scaffolded the project correctly, then crashed
|
||||
*printing its `✓ Project initialized` banner* — Rich wrote the ✓/box glyphs
|
||||
through a cp1252 stdout, raising `UnicodeEncodeError ('charmap')`; the error
|
||||
handler's `✗` message hit the same wall, bubbling up to the command router
|
||||
which mislabeled it. The `aipass` entry point now reconfigures stdout/stderr
|
||||
to UTF-8 in place on Windows. (The init preflight ancestor-walk was also
|
||||
hardened to skip un-enumerable Windows drive-root entries — defensive, not
|
||||
the trigger.)
|
||||
- **`drone @branch` crashed on Windows with the same `UnicodeEncodeError
|
||||
('charmap')`.** `drone` resolved + subprocessed the branch correctly, then
|
||||
crashed *printing* the captured output through cp1252 stdout. The existing
|
||||
`PYTHONUTF8` guard only affected child interpreters, not the live process
|
||||
streams — `drone`'s entry point now also `reconfigure()`s stdout/stderr to
|
||||
UTF-8 in place.
|
||||
- **CI unit lane no longer runs the e2e wheel tests.** `ci.yml`'s
|
||||
`pytest --rootdir=.` swept in `tests/e2e/` (which build a wheel per the
|
||||
dedicated `e2e-wheel.yml`), failing the unit lane; it now `--ignore`s them.
|
||||
(DPLAN-0194)
|
||||
- **A release merge can no longer destroy the `dev` branch** — `drone @git merge`
|
||||
passed `--delete-branch` to `gh pr merge` unconditionally, so merging a
|
||||
`dev`→`main` PR deleted the persistent `dev` branch on the remote and stranded
|
||||
the working tree on `main` (the next commit silently landing on main). Merge now
|
||||
looks up the PR's head ref and **only deletes non-protected branches** — `dev`
|
||||
and `main` are never deleted, and an undeterminable head ref fails safe (no
|
||||
delete). After a merge it returns the working tree to `dev` (loud warning if it
|
||||
can't). `drone @git branches` now runs `fetch --prune` before listing so it
|
||||
reflects the live remote instead of stale cached refs, and a new
|
||||
`drone @git prune-temp` cleans up merged temp PR branches. (#625)
|
||||
- **`drone @git status`/`diff` show their scope** — when scoped to a branch (no
|
||||
`--all`), output now appends "(showing <branch> scope — use --all for full
|
||||
repo)", so an empty scoped view is no longer mistaken for a clean repo. (#623)
|
||||
- **External projects can call AIPass branches via drone** — `drone @api ...`
|
||||
(and any `drone @X`) now resolves from a non-AIPass project CWD instead of
|
||||
being blocked with "path escapes project root." The resolver was validating a
|
||||
branch's path against the *primary* registry root even when the branch was
|
||||
found via the `AIPASS_HOME` fallback, so any external project (Vera Studio,
|
||||
Daemon) hit a false security block. `resolve_branch()` now validates
|
||||
containment against the registry the branch was actually found in. Security is
|
||||
unchanged — each branch is still contained within its own declaring registry's
|
||||
root; genuine path escapes remain blocked. (#618)
|
||||
- **`aipass <command>` runs instead of printing an introspection banner** —
|
||||
`aipass` is a user-facing binary, so `aipass doctor` (and every other command)
|
||||
must execute, not describe itself. All 7 modules (`doctor`, `doctor_fix`,
|
||||
`doctor_wire`, `handoff`, `help_chat`, `init_flow`, `profile`) previously hit a
|
||||
no-args→introspection gate (a standard meant for `drone @branch <module>`
|
||||
discovery) and showed a banner on bare invocation. Now bare invocation runs the
|
||||
command or shows usage; the introspection banner moved to `--info`. The seedgo
|
||||
introspection standard is bypassed for these binary-invoked modules (documented).
|
||||
- **Dashboard plan counts no longer zeroed on refresh** — a branch's
|
||||
`active_plans` was reset to `0` by every `drone @prax dashboard refresh`, because
|
||||
`PLANS.central.json` only held Flow's own plans (`location==FLOW_ROOT` filter).
|
||||
The central file is now comprehensive: all plans grouped per-branch, so refresh
|
||||
reports each branch's real count (e.g. devpulse now shows its 12 open plans
|
||||
instead of 0).
|
||||
|
||||
### Security
|
||||
|
||||
- **`dependency-scan` (pip-audit) green again — upgrade pip, drop stale ignores.**
|
||||
The `Security Scan` workflow's `dependency-scan` job had gone red: pip-audit
|
||||
scans the whole environment, and the runner's bundled pip (26.1.1) carries
|
||||
advisory PYSEC-2026-196 (fixed in 26.1.2). The job now runs
|
||||
`python -m pip install --upgrade pip` before auditing (it was the only CI job
|
||||
not upgrading pip), removing the vulnerable version outright rather than
|
||||
suppressing it. 26.1.2 also resolves CVE-2026-3219 and CVE-2026-6357, so the
|
||||
two now-stale `--ignore-vuln` entries were removed — verified against a clean
|
||||
reproduction of the job's environment, which audits to "No known
|
||||
vulnerabilities found" with nothing ignored.
|
||||
- **Pinned the `requests` floor to a non-vulnerable version** — raised
|
||||
`requests` to `>=2.34.2` in `pyproject.toml` and the API branch's
|
||||
`requirements.project.txt` (which previously listed it unconstrained). This
|
||||
clears six OSV advisories the OpenSSF Scorecard flagged against the dependency
|
||||
(PYSEC-2014-13, PYSEC-2014-14, PYSEC-2018-28, GHSA-9wx4-h78v-vm56,
|
||||
GHSA-9hjg-9r4m-mvj7, GHSA-gc5v-m9x4-r6x2) — the oldest surfaced only because the
|
||||
dependency was declared without a version bound. No runtime change (the AIPass
|
||||
venv already ran a fixed release). (DPLAN-0193)
|
||||
- **Pinned the test container base image by digest** — `Dockerfile.test` now pins
|
||||
`ubuntu:24.04` to its registry digest (`sha256:786a8b55…`) so the test image is
|
||||
reproducible and tamper-evident, clearing the Scorecard `containerImage not
|
||||
pinned by hash` finding. (DPLAN-0193)
|
||||
|
||||
---
|
||||
|
||||
## [2026-05-30]
|
||||
|
||||
### Added
|
||||
|
||||
@@ -70,7 +556,7 @@ and this project uses [Calendar Versioning](https://calver.org/) in the format
|
||||
|
||||
---
|
||||
|
||||
## [2026.W21] - 2026-05-25
|
||||
## [2026-05-25]
|
||||
|
||||
First weekly release. AIPass now follows a Sunday release cadence: changes
|
||||
accumulate on `dev` throughout the week and merge to `main` as a single
|
||||
|
||||
@@ -10,10 +10,9 @@ On any greeting, silently run this sequence — no narration, no announcing step
|
||||
|
||||
These steps are sequential and dependent — run each ONCE, wait for the result, then proceed. Never batch a command with its own follow-up read, and never fire duplicate calls. If output looks blank, wait — don't retry.
|
||||
|
||||
- Read: `.trinity/passport.json`, `.trinity/local.json`, `.trinity/observations.json`, `README.md`, `STATUS.local.md`
|
||||
- Read: `.trinity/passport.json`, `.trinity/local.json`, `.trinity/observations.json`, `README.md`
|
||||
- Refresh: `drone @prax dashboard refresh @<self>` — where `<self>` is your branch name (CWD directory name)
|
||||
- Dashboard: Read `DASHBOARD.local.json` — act on what needs attention (new mail → check inbox, active plans → note them). This is your single status glance.
|
||||
- Refresh: If `STATUS.local.md` is stale (last updated date older than latest session in local.json), update it from your memories. Keep Current Work accurate.
|
||||
|
||||
Use drone commands for all operations. Never raw git, gh, file access, or python -m when drone provides it.
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM ubuntu:24.04
|
||||
FROM ubuntu:24.04@sha256:786a8b558f7be160c6c8c4a54f9a57274f3b4fb1491cf65146521ae77ff1dc54
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
|
||||
@@ -2,19 +2,17 @@
|
||||
[](pyproject.toml)
|
||||
[](LICENSE)
|
||||
[](https://pypi.org/project/aipass/)
|
||||
[](#cli-support)
|
||||
[](https://github.com/AIOSAI/AIPass/issues/new?template=feedback.yml)
|
||||
[](https://codecov.io/gh/AIOSAI/AIPass)
|
||||
[](https://github.com/volotat/OSS-Health-Monitor)
|
||||
[](https://scorecard.dev/viewer/?uri=github.com/AIOSAI/AIPass)
|
||||
[](https://www.bestpractices.dev/projects/13095)
|
||||
[](https://hvtracker.net/agents/aipass)
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/logo.png" alt="AIPass" width="400" />
|
||||
</p>
|
||||
<p align="center"><strong>Persistent Agent Workspace</strong></p>
|
||||
<p align="center"><em>AI agents that remember, collaborate, and never start from zero.</em></p>
|
||||
<p align="center">
|
||||
<a href="https://scorecard.dev/viewer/?uri=github.com/AIOSAI/AIPass"><img src="https://api.scorecard.dev/projects/github.com/AIOSAI/AIPass/badge" alt="OpenSSF Scorecard" /></a>
|
||||
</p>
|
||||
|
||||

|
||||
|
||||
@@ -49,7 +47,7 @@ my-project/
|
||||
├── .aipass/ # Project config + prompts
|
||||
├── .claude/ # Hooks (injected automatically)
|
||||
├── src/my_project/
|
||||
│ └── my-agent/
|
||||
│ └── my_agent/
|
||||
│ ├── .trinity/ # Identity + memory (3 JSON files)
|
||||
│ ├── .ai_mail.local/ # Local mailbox
|
||||
│ ├── apps/ # Your agent's code
|
||||
@@ -93,7 +91,7 @@ That's it. Your agent has identity, memory, a mailbox, and access to every AIPas
|
||||
|
||||
```bash
|
||||
aipass init # Just the scaffold (no guided setup)
|
||||
aipass init agent my-agent # Add another agent
|
||||
aipass init agent my_agent # Add another agent
|
||||
aipass doctor # Check system health
|
||||
```
|
||||
|
||||
@@ -114,8 +112,8 @@ claude # Talk to the orchestrator
|
||||
|
||||
```bash
|
||||
# Things you can do:
|
||||
aipass doctor # Check system health (15+ checks)
|
||||
drone @seedgo audit aipass # Run 36 quality checks across all agents
|
||||
aipass doctor # Check system health
|
||||
drone @seedgo audit aipass # Run automated quality checks across all agents
|
||||
drone @flow create . "Add user auth" # Create a work plan
|
||||
drone @ai_mail dispatch @agent "Sub" "Body" # Send task + wake an agent
|
||||
```
|
||||
@@ -143,7 +141,7 @@ drone @branch command [args] # Every agent, every task. Drone handles routing
|
||||
```
|
||||
|
||||
```bash
|
||||
drone @seedgo audit my-project # Run quality checks on everything
|
||||
drone @seedgo audit my_project # Run quality checks on everything
|
||||
drone @flow create . "Refactor auth module" # Create a work plan
|
||||
drone @ai_mail dispatch @agent "Archive old sessions" "Find sessions older than 30 days"
|
||||
```
|
||||
@@ -163,7 +161,7 @@ AIPass ships with 13 core agents that maintain and develop the framework itself
|
||||
devpulse (orchestrator)
|
||||
├── aipass — concierge + onboarding (aipass init, doctor, profile)
|
||||
├── drone — command routing + @agent resolution
|
||||
├── seedgo — 36 automated quality standards
|
||||
├── seedgo — automated quality standards
|
||||
├── prax — real-time monitoring across all agents
|
||||
├── ai_mail — agent-to-agent communication + task dispatch
|
||||
├── flow — plan lifecycle, templates, auto-archival
|
||||
@@ -197,9 +195,9 @@ These agents work on the **same filesystem, same project, same time** — no san
|
||||
|
||||
| Agent | Role |
|
||||
|-------|------|
|
||||
| [**seedgo**](src/aipass/seedgo/README.md) | 36 automated quality standards, enforced across all agents |
|
||||
| [**seedgo**](src/aipass/seedgo/README.md) | Automated quality standards, enforced across all agents |
|
||||
| [**prax**](src/aipass/prax/README.md) | Real-time monitoring, logs, dashboards |
|
||||
| [**flow**](src/aipass/flow/README.md) | Plan lifecycle — 6 template types, auto-archival, vector verification |
|
||||
| [**flow**](src/aipass/flow/README.md) | Plan lifecycle — multiple template types, auto-archival, vector verification |
|
||||
| [**hooks**](src/aipass/hooks/README.md) | Hook engine — per-project config, sound control, event dispatch |
|
||||
| [**trigger**](src/aipass/trigger/README.md) | Event-driven automation + self-healing |
|
||||
| [**cli**](src/aipass/cli/README.md) | Terminal formatting and rich output |
|
||||
@@ -214,7 +212,7 @@ AIPass is built and tested with **Claude Code** on Linux/WSL.
|
||||
|
||||
| CLI | Autonomous Mode | Status |
|
||||
|-----|----------------|--------|
|
||||
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `claude -p "prompt" --permission-mode bypassPermissions` | Fully tested |
|
||||
| [Claude Code](https://code.claude.com/docs) | `claude -p "prompt" --permission-mode bypassPermissions` | Fully tested |
|
||||
| [Codex](https://github.com/openai/codex) | `codex exec "prompt" --dangerously-bypass-approvals-and-sandbox` | Experimental |
|
||||
|
||||
setup.sh auto-detects which CLIs are installed and configures hooks for each.
|
||||
@@ -227,11 +225,11 @@ setup.sh auto-detects which CLIs are installed and configures hooks for each.
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Version | 2.4.0 |
|
||||
| Version | [](https://pypi.org/project/aipass/) |
|
||||
| Agents | 13 core + user-created |
|
||||
| Quality standards | 36 automated checks |
|
||||
| Tests | 8,400+ (across all agents) |
|
||||
| PRs merged | 600+ (human-AI collaboration) |
|
||||
| Quality | Automated standards enforced across every agent |
|
||||
| Coverage | [](https://codecov.io/gh/AIOSAI/AIPass) — 75% minimum, CI-gated |
|
||||
| Tests | Extensive — every agent ships its own suite |
|
||||
|
||||
Each agent documents its own operational status in its branch README — what works, what doesn't, and why.
|
||||
|
||||
@@ -240,7 +238,7 @@ Each agent documents its own operational status in its branch README — what wo
|
||||
## Requirements
|
||||
|
||||
- Python 3.10+
|
||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
|
||||
- [Claude Code](https://code.claude.com/docs)
|
||||
- Linux, macOS, or WSL (all CI-tested)
|
||||
- `sudo` access optional (for `/usr/local/bin` symlinks — falls back to `~/.local/bin` without sudo)
|
||||
- API keys optional (OpenRouter/OpenAI — for optional add-on agents)
|
||||
@@ -266,7 +264,7 @@ AIPass stores everything locally in your project directory. To remove it:
|
||||
```bash
|
||||
# Remove AIPass files from your project
|
||||
rm -rf .aipass/ .claude/ .ai_mail.local/ hooks/ src/
|
||||
rm -f CLAUDE.md AGENTS.md STATUS.local.md *_REGISTRY.json .gitignore
|
||||
rm -f CLAUDE.md AGENTS.md *_REGISTRY.json .gitignore
|
||||
|
||||
# If you installed via pip
|
||||
pip uninstall aipass
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "aipass"
|
||||
version = "2.5.0"
|
||||
version = "2.5.3"
|
||||
description = "A local multi-agent framework where your AI agents keep their memory, work together, and never ask you to re-explain context"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
@@ -28,7 +28,7 @@ classifiers = [
|
||||
dependencies = [
|
||||
"rich>=13.0",
|
||||
"watchdog>=3.0",
|
||||
"requests>=2.28",
|
||||
"requests>=2.34.2",
|
||||
"psutil>=5.9",
|
||||
"questionary>=2.0",
|
||||
]
|
||||
|
||||
@@ -226,6 +226,92 @@ if [ "$IS_WINDOWS" -eq 1 ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Sandbox prerequisites (kernel FS boundary) ---
|
||||
echo ""
|
||||
echo "Checking sandbox prerequisites ..."
|
||||
|
||||
if [ "$IS_WINDOWS" -eq 1 ] || [ "$IS_MACOS" -eq 1 ]; then
|
||||
echo " kernel sandbox: Linux-only for now, skipping"
|
||||
else
|
||||
SB_MISSING=()
|
||||
|
||||
# bwrap
|
||||
if command -v bwrap &>/dev/null; then
|
||||
echo " bwrap ... $(bwrap --version 2>/dev/null || echo 'found')"
|
||||
else
|
||||
echo " bwrap ... MISSING"
|
||||
echo " sudo apt install bubblewrap"
|
||||
SB_MISSING+=("bwrap")
|
||||
fi
|
||||
|
||||
# node
|
||||
if command -v node &>/dev/null; then
|
||||
echo " node ... $(node --version 2>/dev/null)"
|
||||
else
|
||||
echo " node ... MISSING"
|
||||
echo " Install Node.js: https://nodejs.org/"
|
||||
SB_MISSING+=("node")
|
||||
fi
|
||||
|
||||
# npm (needed for srt install)
|
||||
if command -v npm &>/dev/null; then
|
||||
echo " npm ... $(npm --version 2>/dev/null)"
|
||||
else
|
||||
echo " npm ... MISSING"
|
||||
SB_MISSING+=("npm")
|
||||
fi
|
||||
|
||||
# @anthropic-ai/sandbox-runtime — resolve same way as _srt_resolve.mjs
|
||||
if command -v node &>/dev/null; then
|
||||
SRT_PATH=$(node -e "
|
||||
const p = require('path');
|
||||
const fs = require('fs');
|
||||
const prefix = p.dirname(p.dirname(process.execPath));
|
||||
const entry = p.join(prefix, 'lib/node_modules/@anthropic-ai/sandbox-runtime/dist/index.js');
|
||||
if (fs.existsSync(entry)) process.stdout.write(entry);
|
||||
else process.exit(1);
|
||||
" 2>/dev/null) || SRT_PATH=""
|
||||
if [ -n "$SRT_PATH" ]; then
|
||||
echo " srt ... $SRT_PATH"
|
||||
else
|
||||
echo " srt ... MISSING"
|
||||
if command -v npm &>/dev/null; then
|
||||
echo " Attempting: npm install -g @anthropic-ai/sandbox-runtime"
|
||||
if npm install -g @anthropic-ai/sandbox-runtime 2>/dev/null; then
|
||||
echo " srt ... installed"
|
||||
else
|
||||
echo " Install failed (may need sudo). Run manually:"
|
||||
echo " sudo npm install -g @anthropic-ai/sandbox-runtime"
|
||||
SB_MISSING+=("srt")
|
||||
fi
|
||||
else
|
||||
echo " Install node+npm first, then: npm install -g @anthropic-ai/sandbox-runtime"
|
||||
SB_MISSING+=("srt")
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo " srt ... skipped (no node)"
|
||||
SB_MISSING+=("srt")
|
||||
fi
|
||||
|
||||
# rg (ripgrep)
|
||||
if command -v rg &>/dev/null; then
|
||||
echo " rg ... $(rg --version 2>/dev/null | head -1)"
|
||||
elif [ -f "$HOME/.local/bin/rg" ]; then
|
||||
echo " rg ... $HOME/.local/bin/rg"
|
||||
else
|
||||
echo " rg ... MISSING"
|
||||
echo " sudo apt install ripgrep"
|
||||
SB_MISSING+=("rg")
|
||||
fi
|
||||
|
||||
if [ ${#SB_MISSING[@]} -eq 0 ]; then
|
||||
echo " sandbox prereqs: READY"
|
||||
else
|
||||
echo " sandbox prereqs: INCOMPLETE (${SB_MISSING[*]} missing) — aipass doctor for details"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Verify CLI entry points ---
|
||||
FAIL=0
|
||||
|
||||
@@ -540,15 +626,16 @@ else:
|
||||
settings = {}
|
||||
|
||||
# Build hooks config — bridge pattern
|
||||
# UserPromptSubmit: 4 separate entries (EventType:hook_name) to avoid output merging
|
||||
# UserPromptSubmit: 5 separate entries (EventType:hook_name) to avoid output merging
|
||||
# PreToolUse, PostToolUse, SubagentStop, Stop, Notification: single aggregate entries
|
||||
# PreCompact: 2 hooks x 2 matchers (manual + auto) = 4 entries
|
||||
# PreCompact: 3 hooks x 2 matchers (manual + auto) = 6 entries
|
||||
settings["hooks"] = {
|
||||
"UserPromptSubmit": [
|
||||
{"hooks": [{"type": "command", "command": f"{bridge} UserPromptSubmit:global_prompt"}]},
|
||||
{"hooks": [{"type": "command", "command": f"{bridge} UserPromptSubmit:branch_prompt"}]},
|
||||
{"hooks": [{"type": "command", "command": f"{bridge} UserPromptSubmit:identity_injector"}]},
|
||||
{"hooks": [{"type": "command", "command": f"{bridge} UserPromptSubmit:email_notification"}]},
|
||||
{"hooks": [{"type": "command", "command": f"{bridge} UserPromptSubmit:auto_process", "timeout": 120}]},
|
||||
],
|
||||
"PreToolUse": [
|
||||
{"matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task",
|
||||
@@ -572,6 +659,8 @@ settings["hooks"] = {
|
||||
{"matcher": "auto", "hooks": [{"type": "command", "command": f"{bridge} PreCompact:pre_compact", "timeout": 60}]},
|
||||
{"matcher": "manual", "hooks": [{"type": "command", "command": f"{bridge} PreCompact:pre_compact_rollover", "timeout": 120}]},
|
||||
{"matcher": "auto", "hooks": [{"type": "command", "command": f"{bridge} PreCompact:pre_compact_rollover", "timeout": 120}]},
|
||||
{"matcher": "manual", "hooks": [{"type": "command", "command": f"{bridge} PreCompact:auto_process", "timeout": 120}]},
|
||||
{"matcher": "auto", "hooks": [{"type": "command", "command": f"{bridge} PreCompact:auto_process", "timeout": 120}]},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -606,7 +695,6 @@ git_deny = [
|
||||
"Bash(git push -f *)",
|
||||
"Bash(git rebase*)",
|
||||
"Bash(git clean*)",
|
||||
"Bash(rm -rf*)",
|
||||
"Bash(git reset*)",
|
||||
"Bash(git merge*)",
|
||||
"Bash(git config*)",
|
||||
@@ -617,7 +705,6 @@ git_deny = [
|
||||
"Bash(git branch -D*)",
|
||||
"Bash(git stash drop*)",
|
||||
"Bash(git stash clear*)",
|
||||
"Bash(rm -r *)",
|
||||
"Bash(git checkout -b*)",
|
||||
"Bash(git switch -c*)",
|
||||
"Bash(git switch --create*)",
|
||||
|
||||
@@ -4,4 +4,4 @@ pip install aipass
|
||||
https://github.com/AIOSAI/AIPass
|
||||
"""
|
||||
|
||||
__version__ = "2.5.0"
|
||||
__version__ = "2.5.3"
|
||||
|
||||
@@ -93,7 +93,12 @@
|
||||
{
|
||||
"file": "apps/handlers/dispatch/dispatch_monitor.py",
|
||||
"standard": "handlers",
|
||||
"reason": "Imports notify.send_notification — same-branch cross-handler import for bounce/completion notifications."
|
||||
"reason": "Imports notify.send_notification (same-branch cross-handler) for bounce/completion notifications. Lazy-imports drone.apps.handlers.broker.client.create_identified_connection inside _connect_broker() for Phase 6b broker-fd handshake (FPLAN-0250) — cross-branch handler import authorized by brief."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/dispatch/dispatch_monitor.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Lazy-imports drone.apps.handlers.broker.client.create_identified_connection inside _connect_broker() — cross-branch handler import for Phase 6b broker-fd handshake (FPLAN-0250). Brief explicitly authorizes this import path."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/dispatch/wake.py",
|
||||
|
||||
@@ -23,6 +23,8 @@ is guaranteed.
|
||||
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import socket
|
||||
import sys
|
||||
import subprocess
|
||||
import time
|
||||
@@ -41,6 +43,43 @@ HARD_TIMEOUT = 7200 # 2 hours
|
||||
POLL_INTERVAL = 5
|
||||
|
||||
|
||||
def _is_sandbox_enabled() -> bool:
|
||||
"""Check if dispatch sandbox is enabled via AIPASS_SANDBOX_ENABLED env var."""
|
||||
return os.environ.get("AIPASS_SANDBOX_ENABLED", "").lower() in ("1", "true", "yes")
|
||||
|
||||
|
||||
def _wrap_for_sandbox(cmd: list, branch_path: Path) -> list:
|
||||
"""Wrap a claude command in the srt kernel sandbox.
|
||||
|
||||
Uses @hooks sandbox building blocks to resolve the bwrap command,
|
||||
then returns a shell invocation list compatible with Popen.
|
||||
|
||||
Raises on ANY failure — caller must not silently fall back to unsandboxed.
|
||||
"""
|
||||
from aipass.hooks.apps.modules.sandbox import build_policy, build_srt_config, resolve_bwrap_command
|
||||
|
||||
policy = build_policy(branch_path)
|
||||
srt_config = build_srt_config(policy)
|
||||
cmd_str = shlex.join(cmd)
|
||||
bwrap_cmd = resolve_bwrap_command(cmd_str, srt_config)
|
||||
return ["/bin/bash", "-c", bwrap_cmd]
|
||||
|
||||
|
||||
def _connect_broker(repo_root: Path, branch_name: str) -> socket.socket:
|
||||
"""Create an identified broker connection for the target branch.
|
||||
|
||||
Returns a connected, HMAC-authenticated socket ready to be inherited
|
||||
by the sandboxed child via pass_fds + AIPASS_BROKER_FD.
|
||||
|
||||
Raises on ANY failure — caller must not silently skip the broker.
|
||||
"""
|
||||
from aipass.drone.apps.handlers.broker.client import create_identified_connection
|
||||
|
||||
socket_path = repo_root / ".ai_central" / "drone_broker.sock"
|
||||
secret_path = repo_root / ".ai_central" / "broker_secret"
|
||||
return create_identified_connection(socket_path, secret_path, branch_name)
|
||||
|
||||
|
||||
def _send_bounce(branch_email: str, reason: str, sender: str, lock_file: str, stderr_log: str) -> bool:
|
||||
"""Send return-to-sender bounce email via drone."""
|
||||
subject = f"BOUNCE: Dispatch to {branch_email} failed"
|
||||
@@ -176,7 +215,7 @@ def _kill_process(process: subprocess.Popen, branch_email: str):
|
||||
|
||||
|
||||
def _run_with_startup_check(
|
||||
claude_cmd: list, stdout_log: str, stderr_fh, cwd: str, spawn_env: dict, branch_email: str
|
||||
claude_cmd: list, stdout_log: str, stderr_fh, cwd: str, spawn_env: dict, branch_email: str, pass_fds: tuple = ()
|
||||
) -> tuple:
|
||||
"""
|
||||
Run claude with startup timeout check.
|
||||
@@ -194,13 +233,16 @@ def _run_with_startup_check(
|
||||
logger.warning("[monitor] Failed to open stdout log %s: %s", stdout_log, e)
|
||||
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
claude_cmd,
|
||||
stdout=stdout_fh if stdout_fh is not None else subprocess.DEVNULL,
|
||||
stderr=stderr_fh,
|
||||
cwd=cwd,
|
||||
env=spawn_env,
|
||||
)
|
||||
popen_kwargs = {
|
||||
"stdout": stdout_fh if stdout_fh is not None else subprocess.DEVNULL,
|
||||
"stderr": stderr_fh,
|
||||
"cwd": cwd,
|
||||
"env": spawn_env,
|
||||
}
|
||||
if pass_fds:
|
||||
popen_kwargs["close_fds"] = True
|
||||
popen_kwargs["pass_fds"] = pass_fds
|
||||
process = subprocess.Popen(claude_cmd, **popen_kwargs)
|
||||
except Exception as e:
|
||||
logger.warning("[monitor] Failed to spawn %s: %s", branch_email, e)
|
||||
if stdout_fh is not None:
|
||||
@@ -338,6 +380,11 @@ def main():
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
# ─── Sandbox Gate ─────────────────────────────────────
|
||||
sandbox_enabled = _is_sandbox_enabled()
|
||||
if sandbox_enabled:
|
||||
logger.info("[monitor] Sandbox ENABLED for %s", branch_email)
|
||||
|
||||
# ─── Retry Loop: 3 Strikes ─────────────────────────────
|
||||
# Strike 1: original command (resume if -c was passed)
|
||||
# Strike 2: same command again (transient failure)
|
||||
@@ -356,14 +403,60 @@ def main():
|
||||
cmd = claude_cmd
|
||||
mode = "resume" if has_resume else "fresh"
|
||||
|
||||
# Sandbox wrap + broker fd: when enabled, wrap cmd and connect broker.
|
||||
# On failure: abort — NEVER silently launch unsandboxed.
|
||||
run_cmd = cmd
|
||||
broker_sock = None
|
||||
attempt_pass_fds: tuple = ()
|
||||
if sandbox_enabled:
|
||||
try:
|
||||
run_cmd = _wrap_for_sandbox(cmd, branch_path)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"[monitor] Sandbox init FAILED for %s: %s — ABORTING (will NOT launch unsandboxed)",
|
||||
branch_email,
|
||||
e,
|
||||
)
|
||||
exit_code = -4
|
||||
attempts.append({"attempt": attempt, "exit_code": exit_code, "startup_failed": False, "mode": mode})
|
||||
break
|
||||
|
||||
try:
|
||||
broker_sock = _connect_broker(_repo_root, branch_email.lstrip("@"))
|
||||
broker_fd = broker_sock.fileno()
|
||||
spawn_env["AIPASS_BROKER_FD"] = str(broker_fd)
|
||||
attempt_pass_fds = (broker_fd,)
|
||||
logger.info("[monitor] Broker fd %d connected for %s", broker_fd, branch_email)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"[monitor] Broker connect FAILED for %s: %s — ABORTING",
|
||||
branch_email,
|
||||
e,
|
||||
)
|
||||
exit_code = -4
|
||||
attempts.append({"attempt": attempt, "exit_code": exit_code, "startup_failed": False, "mode": mode})
|
||||
break
|
||||
|
||||
if stderr_fh is not None:
|
||||
stderr_fh.write(f"\n--- Attempt {attempt}/3 ({mode}) at {time.strftime('%H:%M:%S')} ---\n")
|
||||
stderr_fh.flush()
|
||||
|
||||
exit_code, startup_failed = _run_with_startup_check(
|
||||
cmd, stdout_log, stderr_fh if stderr_fh is not None else subprocess.DEVNULL, cwd, spawn_env, branch_email
|
||||
run_cmd,
|
||||
stdout_log,
|
||||
stderr_fh if stderr_fh is not None else subprocess.DEVNULL,
|
||||
cwd,
|
||||
spawn_env,
|
||||
branch_email,
|
||||
pass_fds=attempt_pass_fds,
|
||||
)
|
||||
|
||||
# Close parent's broker socket copy — child owns the fd now.
|
||||
if broker_sock is not None:
|
||||
broker_sock.close()
|
||||
broker_sock = None
|
||||
spawn_env.pop("AIPASS_BROKER_FD", None)
|
||||
|
||||
attempts.append({"attempt": attempt, "exit_code": exit_code, "startup_failed": startup_failed, "mode": mode})
|
||||
|
||||
# Success — done
|
||||
|
||||
@@ -22,7 +22,6 @@ STANDARD_FOOTER = """
|
||||
⚠️ TASK CHECKLIST (before marking complete):
|
||||
□ SEEDGO CHECK → drone @seedgo audit @branch (80%+)
|
||||
□ UPDATE MEMORIES → Your .trinity/local.json records this work
|
||||
□ UPDATE STATUS → Your STATUS.local.md reflects current state
|
||||
□ CLOSE FPLAN → drone @flow close <plan_id>
|
||||
□ EMAIL SENDER → drone @ai_mail email @<sender> "Subject" "Summary"
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ def parse_send_args(args: List[str]) -> Dict[str, Any]:
|
||||
if recipients and len(rest) >= 2:
|
||||
mode = "direct"
|
||||
subject = rest[0]
|
||||
message = rest[1]
|
||||
message = " ".join(rest[1:])
|
||||
elif not recipients and not rest:
|
||||
mode = "interactive"
|
||||
subject = None
|
||||
|
||||
@@ -408,23 +408,26 @@ def _orchestrate_daemon() -> bool:
|
||||
def print_introspection():
|
||||
"""Display module introspection info."""
|
||||
console.print()
|
||||
console.print("dispatch Module")
|
||||
console.print("[bold cyan]dispatch Module[/bold cyan]")
|
||||
console.print(
|
||||
"Orchestrates dispatch commands: combined send+wake, status tracking, daemon management, and manual wake."
|
||||
"[dim]Orchestrates dispatch commands: combined send+wake,"
|
||||
" status tracking, daemon management, and manual wake.[/dim]"
|
||||
)
|
||||
console.print()
|
||||
console.print("Connected Handlers:")
|
||||
console.print(" handlers/dispatch/")
|
||||
console.print(" - status.py (load_dispatch_log — load dispatch log entries)")
|
||||
console.print(" - status.py (check_pid_status — check if a spawned process is still running)")
|
||||
console.print(" - status.py (calculate_age — calculate age string from timestamp)")
|
||||
console.print(" - wake.py (wake_branch — manually wake a branch by spawning an agent)")
|
||||
console.print(" - daemon.py (run_daemon — start the continuous dispatch daemon)")
|
||||
console.print(" handlers/email/ (used by combined dispatch)")
|
||||
console.print(" - send.py (resolve_sender_info, send_to_single — send email pipeline)")
|
||||
console.print(" - create.py (create_email_file, load_email_file — email file creation)")
|
||||
console.print(" - delivery.py (deliver_email_to_branch — inbox delivery)")
|
||||
console.print(" - header.py (prepend_dispatch_header — dispatch header injection)")
|
||||
console.print("[yellow]Connected Handlers:[/yellow]")
|
||||
console.print(" [cyan]handlers/dispatch/[/cyan]")
|
||||
console.print(" - [cyan]status.py[/cyan] [dim](load_dispatch_log — load dispatch log entries)[/dim]")
|
||||
console.print(
|
||||
" - [cyan]status.py[/cyan] [dim](check_pid_status — check if a spawned process is still running)[/dim]"
|
||||
)
|
||||
console.print(" - [cyan]status.py[/cyan] [dim](calculate_age — calculate age string from timestamp)[/dim]")
|
||||
console.print(" - [cyan]wake.py[/cyan] [dim](wake_branch — manually wake a branch by spawning an agent)[/dim]")
|
||||
console.print(" - [cyan]daemon.py[/cyan] [dim](run_daemon — start the continuous dispatch daemon)[/dim]")
|
||||
console.print(" [cyan]handlers/email/[/cyan] [dim](used by combined dispatch)[/dim]")
|
||||
console.print(" - [cyan]send.py[/cyan] [dim](resolve_sender_info, send_to_single — send email pipeline)[/dim]")
|
||||
console.print(" - [cyan]create.py[/cyan] [dim](create_email_file, load_email_file — email file creation)[/dim]")
|
||||
console.print(" - [cyan]delivery.py[/cyan] [dim](deliver_email_to_branch — inbox delivery)[/dim]")
|
||||
console.print(" - [cyan]header.py[/cyan] [dim](prepend_dispatch_header — dispatch header injection)[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
|
||||
@@ -277,7 +277,8 @@ def handle_reply(args: List[str]) -> bool:
|
||||
if not original:
|
||||
error(f"Message not found: {args[0]}")
|
||||
return True
|
||||
success, message, reply_id = send_reply(branch_path, original, args[1])
|
||||
reply_message = " ".join(args[1:])
|
||||
success, message, reply_id = send_reply(branch_path, original, reply_message)
|
||||
if success:
|
||||
console.print(f"[green]{message}[/green]")
|
||||
else:
|
||||
|
||||
@@ -250,18 +250,18 @@ def _send_broadcast(subject, message, user_info, auto_execute, no_memory_save, r
|
||||
|
||||
def print_introspection():
|
||||
"""Print module introspection for seedgo compliance."""
|
||||
console.print("\n" + "=" * 70)
|
||||
console.print("EMAIL SEND ORCHESTRATION")
|
||||
console.print("=" * 70)
|
||||
console.print("\nFunctions provided:")
|
||||
console.print(" - handle_send(args) -> bool")
|
||||
console.print(" - _send_direct(...) -> bool")
|
||||
console.print(" - _send_interactive() -> bool")
|
||||
console.print(" - _send_broadcast(...) -> bool")
|
||||
console.print(" - _fire_dispatch_trigger(to_branch, subject) -> None")
|
||||
console.print(" - _delivery_callback(branch_path, new_count, opened_count, total)")
|
||||
console.print()
|
||||
console.print("=" * 70 + "\n")
|
||||
console.print("[bold cyan]email_send Module[/bold cyan]")
|
||||
console.print("[dim]Send orchestration — direct, interactive, and broadcast email delivery.[/dim]")
|
||||
console.print()
|
||||
console.print("[yellow]Functions provided:[/yellow]")
|
||||
console.print(" - [cyan]handle_send[/cyan][dim](args) -> bool[/dim]")
|
||||
console.print(" - [cyan]_send_direct[/cyan][dim](...) -> bool[/dim]")
|
||||
console.print(" - [cyan]_send_interactive[/cyan][dim]() -> bool[/dim]")
|
||||
console.print(" - [cyan]_send_broadcast[/cyan][dim](...) -> bool[/dim]")
|
||||
console.print(" - [cyan]_fire_dispatch_trigger[/cyan][dim](to_branch, subject) -> None[/dim]")
|
||||
console.print(" - [cyan]_delivery_callback[/cyan][dim](branch_path, new_count, opened_count, total)[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# S84: Multi-line Reply Body Truncation — Root Cause & Fix
|
||||
|
||||
## Bug
|
||||
|
||||
Reply and send commands silently truncate multi-line message bodies to the first argument.
|
||||
|
||||
**Reported:** @devpulse dispatch 7b6a70b9 (2026-06-08)
|
||||
**Evidence:** @hooks sent two replies with full multi-line bodies; both arrived in devpulse inbox as first line only (60 chars / 48 chars). @memory's reply arrived intact (951 chars).
|
||||
|
||||
## Root Cause
|
||||
|
||||
Two code paths only captured the second positional CLI argument as the message body, dropping everything after it:
|
||||
|
||||
1. **`email.py:handle_reply`** (line 280):
|
||||
```python
|
||||
send_reply(branch_path, original, args[1]) # args[2:] silently dropped
|
||||
```
|
||||
|
||||
2. **`send_args.py:parse_send_args`** (line 106):
|
||||
```python
|
||||
message = rest[1] # rest[2:] silently dropped
|
||||
```
|
||||
|
||||
When an agent's bash command produces multiple args from a message body (shell word-splitting on unquoted text, or subprocess argument handling), only the first segment survives. The rest is discarded with no warning.
|
||||
|
||||
The entire Python delivery pipeline (reply.py, delivery.py, create.py) handles multi-line strings correctly — the truncation happens at the CLI argument boundary.
|
||||
|
||||
## Why @memory Worked
|
||||
|
||||
@memory's reply body was a single properly-quoted argument that arrived as one `args[1]` entry. @hooks' body was split into multiple args (likely unquoted or shell-expanded), so only the first piece reached `send_reply()`.
|
||||
|
||||
## Fix
|
||||
|
||||
Both locations now join all remaining args:
|
||||
|
||||
1. **`email.py:handle_reply`**: `reply_message = " ".join(args[1:])`
|
||||
2. **`send_args.py:parse_send_args`**: `message = " ".join(rest[1:])`
|
||||
|
||||
Backwards-compatible: single-arg messages pass through unchanged. Multi-arg messages are reconstructed.
|
||||
|
||||
## Tests Added (6)
|
||||
|
||||
- `test_reply.py`: `test_send_reply_multiline_body_preserved` — multi-line body stored intact in delivery and sent copy
|
||||
- `test_email_module.py`: `TestHandleReplyMultiArg` — handle_reply joins split args; single arg unchanged
|
||||
- `test_send_helpers.py`: 3 tests — parse_send_args joins split message; single arg unchanged; embedded newlines preserved
|
||||
|
||||
718 tests pass (712 + 6 new).
|
||||
@@ -9,7 +9,9 @@
|
||||
"""Tests for dispatch_monitor -- startup check, retry loop, bounce, rate limiting."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
@@ -20,11 +22,13 @@ from aipass.ai_mail.apps.handlers.dispatch.dispatch_monitor import (
|
||||
_check_jsonl_activity,
|
||||
_check_rate_limited,
|
||||
_get_jsonl_projects_dir,
|
||||
_is_sandbox_enabled,
|
||||
_kill_process,
|
||||
_make_fresh_cmd,
|
||||
_run_with_startup_check,
|
||||
_send_bounce,
|
||||
_snapshot_jsonl_sizes,
|
||||
_wrap_for_sandbox,
|
||||
main,
|
||||
)
|
||||
|
||||
@@ -634,7 +638,7 @@ def test_max_turns_changes_notification_status(monkeypatch, main_argv):
|
||||
stdout_log = Path(str(lock_file)).parent.parent / "logs" / "dispatch_stdout.log"
|
||||
stdout_log.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def fake_run(cmd, stdout_log_path, stderr_fh, cwd, env, branch):
|
||||
def fake_run(cmd, stdout_log_path, stderr_fh, cwd, env, branch, **kwargs):
|
||||
# Simulate writing max_turns output
|
||||
stdout_log.write_text('{"stop_reason":"max_turns"}', encoding="utf-8")
|
||||
return (0, False)
|
||||
@@ -810,7 +814,7 @@ def test_env_vars_set_correctly(monkeypatch, main_argv):
|
||||
|
||||
captured_env = {}
|
||||
|
||||
def capture_run(cmd, stdout_log, stderr_fh, cwd, env, branch):
|
||||
def capture_run(cmd, stdout_log, stderr_fh, cwd, env, branch, **kwargs):
|
||||
captured_env.update(env)
|
||||
return (0, False)
|
||||
|
||||
@@ -900,7 +904,7 @@ def test_main_max_turns_detected(monkeypatch, main_argv):
|
||||
stdout_log = branch_dir / "logs" / "dispatch_stdout.log"
|
||||
stdout_log.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def fake_run(cmd, stdout_log_path, stderr_fh, cwd, env, branch):
|
||||
def fake_run(cmd, stdout_log_path, stderr_fh, cwd, env, branch, **kwargs):
|
||||
# Write max_turns stop_reason into stdout log
|
||||
Path(stdout_log_path).write_text('{"stop_reason":"max_turns"}', encoding="utf-8")
|
||||
return (0, False)
|
||||
@@ -1075,7 +1079,7 @@ def test_env_vars_setup(monkeypatch, main_argv):
|
||||
|
||||
captured_env = {}
|
||||
|
||||
def capture_run(cmd, stdout_log, stderr_fh, cwd, env, branch):
|
||||
def capture_run(cmd, stdout_log, stderr_fh, cwd, env, branch, **kwargs):
|
||||
captured_env.update(env)
|
||||
return (0, False)
|
||||
|
||||
@@ -1194,3 +1198,626 @@ def test_check_jsonl_activity_no_change(tmp_path):
|
||||
def test_check_jsonl_activity_missing_dir(tmp_path):
|
||||
"""Nonexistent directory -> False."""
|
||||
assert _check_jsonl_activity(tmp_path / "nope", {}) is False
|
||||
|
||||
|
||||
# --- Sandbox gate tests (Phase 4 FPLAN-0250) --------------------------------
|
||||
|
||||
|
||||
class TestIsSandboxEnabled:
|
||||
"""_is_sandbox_enabled reads AIPASS_SANDBOX_ENABLED from env."""
|
||||
|
||||
def test_unset_returns_false(self, monkeypatch):
|
||||
monkeypatch.delenv("AIPASS_SANDBOX_ENABLED", raising=False)
|
||||
assert _is_sandbox_enabled() is False
|
||||
|
||||
def test_empty_returns_false(self, monkeypatch):
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "")
|
||||
assert _is_sandbox_enabled() is False
|
||||
|
||||
def test_false_string_returns_false(self, monkeypatch):
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "false")
|
||||
assert _is_sandbox_enabled() is False
|
||||
|
||||
def test_zero_returns_false(self, monkeypatch):
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "0")
|
||||
assert _is_sandbox_enabled() is False
|
||||
|
||||
def test_one_returns_true(self, monkeypatch):
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1")
|
||||
assert _is_sandbox_enabled() is True
|
||||
|
||||
def test_true_returns_true(self, monkeypatch):
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "true")
|
||||
assert _is_sandbox_enabled() is True
|
||||
|
||||
def test_yes_returns_true(self, monkeypatch):
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "yes")
|
||||
assert _is_sandbox_enabled() is True
|
||||
|
||||
def test_TRUE_case_insensitive(self, monkeypatch):
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "TRUE")
|
||||
assert _is_sandbox_enabled() is True
|
||||
|
||||
|
||||
class TestFlagOffOldPath:
|
||||
"""Flag OFF (default): dispatch uses the original cmd, no sandbox wrapping."""
|
||||
|
||||
def test_flag_off_cmd_unchanged(self, monkeypatch, main_argv):
|
||||
argv, lock_file, stderr_log = main_argv
|
||||
monkeypatch.delenv("AIPASS_SANDBOX_ENABLED", raising=False)
|
||||
|
||||
captured_cmds = []
|
||||
|
||||
def capture_run(cmd, *args, **kwargs):
|
||||
captured_cmds.append(cmd)
|
||||
return (0, False)
|
||||
|
||||
monkeypatch.setattr("sys.argv", argv)
|
||||
monkeypatch.setattr(mod, "_run_with_startup_check", capture_run)
|
||||
monkeypatch.setattr(mod, "_send_bounce", MagicMock())
|
||||
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
|
||||
MagicMock(return_value=Path("/fake/repo")),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
|
||||
assert exc_info.value.code == 0
|
||||
assert len(captured_cmds) == 1
|
||||
assert captured_cmds[0] == ["claude", "-c", "--model", "opus"]
|
||||
|
||||
def test_flag_off_wrap_never_called(self, monkeypatch, main_argv):
|
||||
argv, lock_file, stderr_log = main_argv
|
||||
monkeypatch.delenv("AIPASS_SANDBOX_ENABLED", raising=False)
|
||||
|
||||
wrap_calls = []
|
||||
original_wrap = mod._wrap_for_sandbox
|
||||
|
||||
def tracking_wrap(*args, **kwargs):
|
||||
wrap_calls.append(args)
|
||||
return original_wrap(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr("sys.argv", argv)
|
||||
monkeypatch.setattr(mod, "_wrap_for_sandbox", tracking_wrap)
|
||||
monkeypatch.setattr(mod, "_run_with_startup_check", MagicMock(return_value=(0, False)))
|
||||
monkeypatch.setattr(mod, "_send_bounce", MagicMock())
|
||||
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
|
||||
MagicMock(return_value=Path("/fake/repo")),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
main()
|
||||
|
||||
assert wrap_calls == []
|
||||
|
||||
|
||||
class TestFlagOnSandboxPath:
|
||||
"""Flag ON: dispatch wraps cmd via _wrap_for_sandbox."""
|
||||
|
||||
def test_flag_on_cmd_wrapped(self, monkeypatch, main_argv):
|
||||
argv, lock_file, stderr_log = main_argv
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1")
|
||||
|
||||
captured_cmds = []
|
||||
|
||||
def capture_run(cmd, *args, **kwargs):
|
||||
captured_cmds.append(cmd)
|
||||
return (0, False)
|
||||
|
||||
monkeypatch.setattr("sys.argv", argv)
|
||||
monkeypatch.setattr(mod, "_run_with_startup_check", capture_run)
|
||||
monkeypatch.setattr(mod, "_send_bounce", MagicMock())
|
||||
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
|
||||
MagicMock(return_value=Path("/fake/repo")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"_wrap_for_sandbox",
|
||||
lambda cmd, bp: ["/bin/bash", "-c", "bwrap --sandbox " + " ".join(cmd)],
|
||||
)
|
||||
mock_sock = MagicMock()
|
||||
mock_sock.fileno.return_value = 99
|
||||
monkeypatch.setattr(mod, "_connect_broker", MagicMock(return_value=mock_sock))
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
|
||||
assert exc_info.value.code == 0
|
||||
assert len(captured_cmds) == 1
|
||||
assert captured_cmds[0][0] == "/bin/bash"
|
||||
assert captured_cmds[0][1] == "-c"
|
||||
assert "bwrap --sandbox" in captured_cmds[0][2]
|
||||
|
||||
def test_wrap_calls_building_blocks(self, monkeypatch, tmp_path):
|
||||
call_log = []
|
||||
|
||||
def mock_build_policy(bp):
|
||||
call_log.append("build_policy")
|
||||
return {"allow_write": [str(bp)], "deny_write": [], "deny_read": []}
|
||||
|
||||
def mock_build_srt_config(policy):
|
||||
call_log.append("build_srt_config")
|
||||
return {"filesystem": {"allowWrite": policy["allow_write"]}}
|
||||
|
||||
def mock_resolve_bwrap(cmd_str, srt_config):
|
||||
call_log.append("resolve_bwrap_command")
|
||||
return f"bwrap --ro-bind / / {cmd_str}"
|
||||
|
||||
monkeypatch.setattr("aipass.hooks.apps.modules.sandbox.build_policy", mock_build_policy)
|
||||
monkeypatch.setattr(
|
||||
"aipass.hooks.apps.modules.sandbox.build_srt_config",
|
||||
mock_build_srt_config,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.hooks.apps.modules.sandbox.resolve_bwrap_command",
|
||||
mock_resolve_bwrap,
|
||||
)
|
||||
|
||||
result = _wrap_for_sandbox(["claude", "--model", "opus"], tmp_path)
|
||||
|
||||
assert call_log == ["build_policy", "build_srt_config", "resolve_bwrap_command"]
|
||||
assert result[0] == "/bin/bash"
|
||||
assert result[1] == "-c"
|
||||
assert "claude" in result[2]
|
||||
|
||||
|
||||
class TestBrokenSandboxFailsLoud:
|
||||
"""Flag ON but sandbox init fails: ABORT, never silently unsandbox."""
|
||||
|
||||
def test_sandbox_init_failure_aborts(self, monkeypatch, main_argv):
|
||||
argv, lock_file, stderr_log = main_argv
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1")
|
||||
|
||||
run_calls = []
|
||||
|
||||
def capture_run(cmd, *args, **kwargs):
|
||||
run_calls.append(cmd)
|
||||
return (0, False)
|
||||
|
||||
def broken_wrap(cmd, bp):
|
||||
raise RuntimeError("srt resolve failed: node not found")
|
||||
|
||||
monkeypatch.setattr("sys.argv", argv)
|
||||
monkeypatch.setattr(mod, "_run_with_startup_check", capture_run)
|
||||
monkeypatch.setattr(mod, "_wrap_for_sandbox", broken_wrap)
|
||||
monkeypatch.setattr(mod, "_send_bounce", MagicMock())
|
||||
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
|
||||
MagicMock(return_value=Path("/fake/repo")),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
|
||||
assert run_calls == []
|
||||
assert exc_info.value.code != 0
|
||||
|
||||
def test_sandbox_failure_sends_bounce(self, monkeypatch, main_argv):
|
||||
argv, lock_file, stderr_log = main_argv
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1")
|
||||
|
||||
def broken_wrap(cmd, bp):
|
||||
raise FileNotFoundError("node not found in PATH")
|
||||
|
||||
mock_bounce = MagicMock()
|
||||
|
||||
monkeypatch.setattr("sys.argv", argv)
|
||||
monkeypatch.setattr(mod, "_run_with_startup_check", MagicMock(return_value=(0, False)))
|
||||
monkeypatch.setattr(mod, "_wrap_for_sandbox", broken_wrap)
|
||||
monkeypatch.setattr(mod, "_send_bounce", mock_bounce)
|
||||
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
|
||||
MagicMock(return_value=Path("/fake/repo")),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
main()
|
||||
|
||||
mock_bounce.assert_called_once()
|
||||
reason = mock_bounce.call_args[0][1]
|
||||
assert "sandbox" in reason.lower() or "-4" in reason
|
||||
|
||||
def test_never_falls_back_to_unsandboxed(self, monkeypatch, main_argv):
|
||||
argv, lock_file, stderr_log = main_argv
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1")
|
||||
|
||||
wrap_calls = [0]
|
||||
run_calls = []
|
||||
|
||||
def counting_broken_wrap(cmd, bp):
|
||||
wrap_calls[0] += 1
|
||||
raise RuntimeError("srt unavailable")
|
||||
|
||||
def capture_run(cmd, *args, **kwargs):
|
||||
run_calls.append(cmd)
|
||||
return (0, False)
|
||||
|
||||
monkeypatch.setattr("sys.argv", argv)
|
||||
monkeypatch.setattr(mod, "_run_with_startup_check", capture_run)
|
||||
monkeypatch.setattr(mod, "_wrap_for_sandbox", counting_broken_wrap)
|
||||
monkeypatch.setattr(mod, "_send_bounce", MagicMock())
|
||||
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
|
||||
MagicMock(return_value=Path("/fake/repo")),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
main()
|
||||
|
||||
assert wrap_calls[0] == 1
|
||||
assert run_calls == []
|
||||
|
||||
|
||||
# --- Broker-fd handshake tests (Phase 6b FPLAN-0250) -------------------------
|
||||
|
||||
|
||||
class TestFlagOffNoBroker:
|
||||
"""Flag OFF: no broker connection attempted at all."""
|
||||
|
||||
def test_flag_off_no_broker_activity(self, monkeypatch, main_argv):
|
||||
argv, lock_file, stderr_log = main_argv
|
||||
monkeypatch.delenv("AIPASS_SANDBOX_ENABLED", raising=False)
|
||||
|
||||
connect_calls = []
|
||||
|
||||
def tracking_connect(*args, **kwargs):
|
||||
connect_calls.append(args)
|
||||
raise RuntimeError("should never be called")
|
||||
|
||||
monkeypatch.setattr(mod, "_connect_broker", tracking_connect)
|
||||
monkeypatch.setattr("sys.argv", argv)
|
||||
monkeypatch.setattr(mod, "_run_with_startup_check", MagicMock(return_value=(0, False)))
|
||||
monkeypatch.setattr(mod, "_send_bounce", MagicMock())
|
||||
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
|
||||
MagicMock(return_value=Path("/fake/repo")),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
|
||||
assert exc_info.value.code == 0
|
||||
assert connect_calls == []
|
||||
|
||||
def test_flag_off_no_broker_fd_in_env(self, monkeypatch, main_argv):
|
||||
argv, lock_file, stderr_log = main_argv
|
||||
monkeypatch.delenv("AIPASS_SANDBOX_ENABLED", raising=False)
|
||||
|
||||
captured_env = {}
|
||||
|
||||
def capture_run(cmd, stdout_log, stderr_fh, cwd, env, branch, **kwargs):
|
||||
captured_env.update(env)
|
||||
return (0, False)
|
||||
|
||||
monkeypatch.setattr("sys.argv", argv)
|
||||
monkeypatch.setattr(mod, "_run_with_startup_check", capture_run)
|
||||
monkeypatch.setattr(mod, "_send_bounce", MagicMock())
|
||||
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
|
||||
MagicMock(return_value=Path("/fake/repo")),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
main()
|
||||
|
||||
assert "AIPASS_BROKER_FD" not in captured_env
|
||||
|
||||
|
||||
class TestBrokerDownFailsLoud:
|
||||
"""Broker down + flag ON → exit -4, agent never spawned."""
|
||||
|
||||
def test_broker_connect_failure_aborts(self, monkeypatch, main_argv):
|
||||
argv, lock_file, stderr_log = main_argv
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1")
|
||||
|
||||
run_calls = []
|
||||
|
||||
def capture_run(cmd, *args, **kwargs):
|
||||
run_calls.append(cmd)
|
||||
return (0, False)
|
||||
|
||||
monkeypatch.setattr("sys.argv", argv)
|
||||
monkeypatch.setattr(mod, "_run_with_startup_check", capture_run)
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"_wrap_for_sandbox",
|
||||
lambda cmd, bp: ["/bin/bash", "-c", "bwrap " + " ".join(cmd)],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"_connect_broker",
|
||||
MagicMock(side_effect=OSError("broker socket not found")),
|
||||
)
|
||||
monkeypatch.setattr(mod, "_send_bounce", MagicMock())
|
||||
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
|
||||
MagicMock(return_value=Path("/fake/repo")),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
|
||||
assert run_calls == []
|
||||
assert exc_info.value.code != 0
|
||||
|
||||
def test_broker_bad_hmac_aborts(self, monkeypatch, main_argv):
|
||||
argv, lock_file, stderr_log = main_argv
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1")
|
||||
|
||||
run_calls = []
|
||||
|
||||
def capture_run(cmd, *args, **kwargs):
|
||||
run_calls.append(cmd)
|
||||
return (0, False)
|
||||
|
||||
monkeypatch.setattr("sys.argv", argv)
|
||||
monkeypatch.setattr(mod, "_run_with_startup_check", capture_run)
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"_wrap_for_sandbox",
|
||||
lambda cmd, bp: ["/bin/bash", "-c", "bwrap " + " ".join(cmd)],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"_connect_broker",
|
||||
MagicMock(side_effect=RuntimeError("Broker identify failed: bad HMAC")),
|
||||
)
|
||||
monkeypatch.setattr(mod, "_send_bounce", MagicMock())
|
||||
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
|
||||
MagicMock(return_value=Path("/fake/repo")),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
|
||||
assert run_calls == []
|
||||
assert exc_info.value.code != 0
|
||||
|
||||
def test_broker_failure_sends_bounce(self, monkeypatch, main_argv):
|
||||
argv, lock_file, stderr_log = main_argv
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1")
|
||||
|
||||
mock_bounce = MagicMock()
|
||||
monkeypatch.setattr("sys.argv", argv)
|
||||
monkeypatch.setattr(mod, "_run_with_startup_check", MagicMock(return_value=(0, False)))
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"_wrap_for_sandbox",
|
||||
lambda cmd, bp: ["/bin/bash", "-c", "bwrap " + " ".join(cmd)],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"_connect_broker",
|
||||
MagicMock(side_effect=OSError("socket missing")),
|
||||
)
|
||||
monkeypatch.setattr(mod, "_send_bounce", mock_bounce)
|
||||
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
|
||||
MagicMock(return_value=Path("/fake/repo")),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
main()
|
||||
|
||||
mock_bounce.assert_called_once()
|
||||
|
||||
|
||||
class TestBrokerFdHandshake:
|
||||
"""Flag ON + broker up: fd passed to child, parent closes after spawn."""
|
||||
|
||||
def test_broker_fd_in_env_and_pass_fds(self, monkeypatch, main_argv):
|
||||
argv, lock_file, stderr_log = main_argv
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1")
|
||||
|
||||
captured_env = {}
|
||||
captured_pass_fds = []
|
||||
|
||||
def capture_run(cmd, stdout_log, stderr_fh, cwd, env, branch, pass_fds=()):
|
||||
captured_env.update(env)
|
||||
captured_pass_fds.append(pass_fds)
|
||||
return (0, False)
|
||||
|
||||
mock_sock = MagicMock()
|
||||
mock_sock.fileno.return_value = 42
|
||||
|
||||
monkeypatch.setattr("sys.argv", argv)
|
||||
monkeypatch.setattr(mod, "_run_with_startup_check", capture_run)
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"_wrap_for_sandbox",
|
||||
lambda cmd, bp: ["/bin/bash", "-c", "bwrap " + " ".join(cmd)],
|
||||
)
|
||||
monkeypatch.setattr(mod, "_connect_broker", MagicMock(return_value=mock_sock))
|
||||
monkeypatch.setattr(mod, "_send_bounce", MagicMock())
|
||||
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
|
||||
MagicMock(return_value=Path("/fake/repo")),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
|
||||
assert exc_info.value.code == 0
|
||||
assert captured_env.get("AIPASS_BROKER_FD") == "42"
|
||||
assert captured_pass_fds == [(42,)]
|
||||
mock_sock.close.assert_called_once()
|
||||
|
||||
def test_parent_closes_socket_after_spawn(self, monkeypatch, main_argv):
|
||||
argv, lock_file, stderr_log = main_argv
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1")
|
||||
|
||||
mock_sock = MagicMock()
|
||||
mock_sock.fileno.return_value = 7
|
||||
|
||||
monkeypatch.setattr("sys.argv", argv)
|
||||
monkeypatch.setattr(mod, "_run_with_startup_check", MagicMock(return_value=(0, False)))
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"_wrap_for_sandbox",
|
||||
lambda cmd, bp: ["/bin/bash", "-c", "bwrap " + " ".join(cmd)],
|
||||
)
|
||||
monkeypatch.setattr(mod, "_connect_broker", MagicMock(return_value=mock_sock))
|
||||
monkeypatch.setattr(mod, "_send_bounce", MagicMock())
|
||||
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
|
||||
MagicMock(return_value=Path("/fake/repo")),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
main()
|
||||
|
||||
mock_sock.close.assert_called_once()
|
||||
|
||||
def test_broker_fd_cleaned_from_env_after_spawn(self, monkeypatch, main_argv):
|
||||
"""After spawn+close, AIPASS_BROKER_FD removed from spawn_env."""
|
||||
argv, lock_file, stderr_log = main_argv
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1")
|
||||
|
||||
env_snapshots = []
|
||||
|
||||
def capture_run(cmd, stdout_log, stderr_fh, cwd, env, branch, pass_fds=()):
|
||||
env_snapshots.append(dict(env))
|
||||
return (0, False)
|
||||
|
||||
mock_sock = MagicMock()
|
||||
mock_sock.fileno.return_value = 10
|
||||
|
||||
monkeypatch.setattr("sys.argv", argv)
|
||||
monkeypatch.setattr(mod, "_run_with_startup_check", capture_run)
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"_wrap_for_sandbox",
|
||||
lambda cmd, bp: ["/bin/bash", "-c", "bwrap " + " ".join(cmd)],
|
||||
)
|
||||
monkeypatch.setattr(mod, "_connect_broker", MagicMock(return_value=mock_sock))
|
||||
monkeypatch.setattr(mod, "_send_bounce", MagicMock())
|
||||
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
|
||||
MagicMock(return_value=Path("/fake/repo")),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
main()
|
||||
|
||||
# During the run, env had the FD
|
||||
assert env_snapshots[0]["AIPASS_BROKER_FD"] == "10"
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "linux", reason="AF_UNIX broker daemon is Linux-only")
|
||||
class TestBrokerRealE2E:
|
||||
"""Real multi-process e2e: broker daemon, identified connection, child reads fd."""
|
||||
|
||||
def test_child_inherits_broker_fd(self, tmp_path):
|
||||
"""Start real broker, create identified conn, spawn child that reads AIPASS_BROKER_FD."""
|
||||
import time as time_mod
|
||||
from aipass.drone.apps.handlers.broker.daemon import BrokerDaemon
|
||||
from aipass.drone.apps.handlers.broker.client import create_identified_connection
|
||||
|
||||
# Set up repo root with branch dir
|
||||
repo_root = tmp_path / "repo"
|
||||
branch_dir = repo_root / "src" / "aipass" / "testbranch"
|
||||
branch_dir.mkdir(parents=True)
|
||||
target_file = branch_dir / "deleteme.txt"
|
||||
target_file.write_text("delete me", encoding="utf-8")
|
||||
|
||||
# Start real broker
|
||||
sock_path = tmp_path / "broker.sock"
|
||||
audit_path = tmp_path / "audit.jsonl"
|
||||
secret_path = tmp_path / "secret"
|
||||
broker = BrokerDaemon(
|
||||
repo_root=repo_root,
|
||||
socket_path=sock_path,
|
||||
audit_path=audit_path,
|
||||
secret_path=secret_path,
|
||||
)
|
||||
t = broker.start_background()
|
||||
time_mod.sleep(0.5)
|
||||
|
||||
try:
|
||||
# Create identified connection (as the launcher would)
|
||||
sock = create_identified_connection(sock_path, secret_path, "testbranch")
|
||||
broker_fd = sock.fileno()
|
||||
|
||||
# Spawn a real child that reads AIPASS_BROKER_FD and sends a delete
|
||||
child_script = tmp_path / "child.py"
|
||||
child_script.write_text(
|
||||
"""
|
||||
import os, socket, json
|
||||
|
||||
fd = int(os.environ["AIPASS_BROKER_FD"])
|
||||
s = socket.socket(fileno=fd)
|
||||
try:
|
||||
req = json.dumps({"op": "delete", "path": "deleteme.txt", "request_id": "e2e1"}) + "\\n"
|
||||
s.sendall(req.encode())
|
||||
data = b""
|
||||
while b"\\n" not in data:
|
||||
chunk = s.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
resp = json.loads(data.decode())
|
||||
# Write result to a file so parent can verify
|
||||
with open(os.environ["RESULT_FILE"], "w") as f:
|
||||
json.dump(resp, f)
|
||||
finally:
|
||||
s.detach()
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result_file = tmp_path / "result.json"
|
||||
env = os.environ.copy()
|
||||
env["AIPASS_BROKER_FD"] = str(broker_fd)
|
||||
env["RESULT_FILE"] = str(result_file)
|
||||
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, str(child_script)],
|
||||
env=env,
|
||||
pass_fds=(broker_fd,),
|
||||
close_fds=True,
|
||||
)
|
||||
# Parent closes its copy
|
||||
sock.close()
|
||||
|
||||
proc.wait(timeout=10)
|
||||
assert proc.returncode == 0
|
||||
|
||||
# Verify the delete happened
|
||||
assert not target_file.exists()
|
||||
|
||||
# Verify the child got a success response
|
||||
import json as json_mod
|
||||
|
||||
result = json_mod.loads(result_file.read_text(encoding="utf-8"))
|
||||
assert result["ok"] is True
|
||||
|
||||
# Verify audit log carries identity
|
||||
audit_lines = audit_path.read_text(encoding="utf-8").strip().splitlines()
|
||||
delete_entries = [
|
||||
json_mod.loads(line) for line in audit_lines if json_mod.loads(line).get("op") == "delete"
|
||||
]
|
||||
assert len(delete_entries) >= 1
|
||||
assert delete_entries[-1]["identity"] == "testbranch"
|
||||
assert delete_entries[-1]["result"] == "DELETED"
|
||||
|
||||
finally:
|
||||
broker.stop()
|
||||
t.join(timeout=3)
|
||||
|
||||
@@ -1708,7 +1708,7 @@ class TestEmailSendIntrospection:
|
||||
|
||||
print_introspection()
|
||||
combined = "\n".join(printed)
|
||||
assert "EMAIL SEND ORCHESTRATION" in combined
|
||||
assert "email_send Module" in combined
|
||||
assert "handle_send" in combined
|
||||
assert "_send_direct" in combined
|
||||
assert "_send_broadcast" in combined
|
||||
@@ -1761,3 +1761,62 @@ class TestSendInteractiveExtended:
|
||||
assert result is True
|
||||
assert any("@alpha" in p for p in printed)
|
||||
assert any("sent" in p.lower() for p in printed)
|
||||
|
||||
|
||||
class TestHandleReplyMultiArg:
|
||||
"""Regression tests for multi-line reply body truncation (S84 fix)."""
|
||||
|
||||
def test_reply_joins_split_args_into_body(self, tmp_path, monkeypatch):
|
||||
"""When shell splits body into multiple args, all are joined into message."""
|
||||
original = {"id": "msg1", "from": "@devpulse", "subject": "test dispatch"}
|
||||
captured_msg = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.modules.email._resolve_branch_path",
|
||||
lambda: tmp_path,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.modules.email.get_email_by_id",
|
||||
lambda inbox_file, msg_id: original,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.modules.email.send_reply",
|
||||
lambda bp, orig, msg: (captured_msg.append(msg), "Reply sent", "r1")[1:],
|
||||
)
|
||||
mock_console = MagicMock()
|
||||
monkeypatch.setattr("aipass.ai_mail.apps.modules.email.console", mock_console)
|
||||
_write_inbox(tmp_path)
|
||||
|
||||
from aipass.ai_mail.apps.modules.email import handle_reply
|
||||
|
||||
result = handle_reply(["msg1", "Line one", "Line two", "Line three"])
|
||||
assert result is True
|
||||
assert len(captured_msg) == 1
|
||||
assert captured_msg[0] == "Line one Line two Line three"
|
||||
|
||||
def test_reply_single_arg_unchanged(self, tmp_path, monkeypatch):
|
||||
"""Single-arg reply body remains unchanged (no extra spaces)."""
|
||||
original = {"id": "msg1", "from": "@devpulse", "subject": "test"}
|
||||
captured_msg = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.modules.email._resolve_branch_path",
|
||||
lambda: tmp_path,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.modules.email.get_email_by_id",
|
||||
lambda inbox_file, msg_id: original,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.modules.email.send_reply",
|
||||
lambda bp, orig, msg: (captured_msg.append(msg), "Reply sent", "r1")[1:],
|
||||
)
|
||||
mock_console = MagicMock()
|
||||
monkeypatch.setattr("aipass.ai_mail.apps.modules.email.console", mock_console)
|
||||
_write_inbox(tmp_path)
|
||||
|
||||
from aipass.ai_mail.apps.modules.email import handle_reply
|
||||
|
||||
result = handle_reply(["msg1", "Complete single-line reply"])
|
||||
assert result is True
|
||||
assert captured_msg[0] == "Complete single-line reply"
|
||||
|
||||
@@ -268,3 +268,40 @@ def test_send_reply_re_prefix_not_duplicated(tmp_path):
|
||||
assert success is True
|
||||
# Should keep "RE: Already replied", not "RE: RE: Already replied"
|
||||
assert deliver_calls[0][1]["subject"] == "RE: Already replied"
|
||||
|
||||
|
||||
def test_send_reply_multiline_body_preserved(tmp_path):
|
||||
"""Multi-line reply body is stored intact, not truncated to first line."""
|
||||
from_branch_path = tmp_path / "hooks"
|
||||
from_branch_path.mkdir()
|
||||
sender_info = {"email": "@hooks", "name": "HOOKS"}
|
||||
target_branch = {"email": "@devpulse", "name": "DEVPULSE", "path": str(tmp_path / "devpulse")}
|
||||
original = _make_original_email()
|
||||
|
||||
deliver_calls = []
|
||||
|
||||
def mock_deliver(to_branch, email_data):
|
||||
deliver_calls.append((to_branch, email_data))
|
||||
return (True, "")
|
||||
|
||||
multiline_body = (
|
||||
"Investigation: cadence findings\n\nDetails:\n1. First finding\n2. Second finding\n3. Third finding"
|
||||
)
|
||||
|
||||
with (
|
||||
patch(_PATCH_BRANCH_DETECTION, return_value=sender_info),
|
||||
patch(_PATCH_DELIVERY, side_effect=mock_deliver),
|
||||
patch(_PATCH_ALL_BRANCHES, return_value=[target_branch]),
|
||||
patch(_PATCH_CLOSE_ARCHIVE, return_value=(True, "closed")),
|
||||
):
|
||||
success, _message, _reply_id = send_reply(from_branch_path, original, multiline_body)
|
||||
|
||||
assert success is True
|
||||
assert deliver_calls[0][1]["message"] == multiline_body
|
||||
|
||||
sent_folder = from_branch_path / ".ai_mail.local" / "sent"
|
||||
sent_files = list(sent_folder.glob("*.json"))
|
||||
assert len(sent_files) == 1
|
||||
with open(sent_files[0], "r", encoding="utf-8") as f:
|
||||
sent_data = json.load(f)
|
||||
assert sent_data["message"] == multiline_body
|
||||
|
||||
@@ -357,3 +357,34 @@ def test_resolve_dispatch_target_tilde_path():
|
||||
result = resolve_dispatch_target("~/Projects/flow", True, get_branch_info_fn=None)
|
||||
|
||||
assert result == "@flow"
|
||||
|
||||
|
||||
# ---- parse_send_args multi-arg message tests (S84 fix) --------
|
||||
|
||||
|
||||
def test_parse_send_args_joins_split_message():
|
||||
"""When message body is split into multiple args, all are joined."""
|
||||
from aipass.ai_mail.apps.handlers.email.send_args import parse_send_args
|
||||
|
||||
result = parse_send_args(["@target", "Subject", "Line one", "Line two", "Line three"])
|
||||
assert result["mode"] == "direct"
|
||||
assert result["subject"] == "Subject"
|
||||
assert result["message"] == "Line one Line two Line three"
|
||||
|
||||
|
||||
def test_parse_send_args_single_message_unchanged():
|
||||
"""Single message arg is not altered."""
|
||||
from aipass.ai_mail.apps.handlers.email.send_args import parse_send_args
|
||||
|
||||
result = parse_send_args(["@target", "Subject", "Complete body here"])
|
||||
assert result["mode"] == "direct"
|
||||
assert result["message"] == "Complete body here"
|
||||
|
||||
|
||||
def test_parse_send_args_multiline_body_preserved():
|
||||
"""A single arg with embedded newlines passes through intact."""
|
||||
from aipass.ai_mail.apps.handlers.email.send_args import parse_send_args
|
||||
|
||||
body = "First line\nSecond line\nThird line"
|
||||
result = parse_send_args(["@target", "Subject", body])
|
||||
assert result["message"] == body
|
||||
|
||||
@@ -570,13 +570,18 @@ class TestDispatchEnvIsolation:
|
||||
)
|
||||
|
||||
def test_dispatch_monitor_passes_spawn_env_to_subprocess(self):
|
||||
"""dispatch_monitor.py must pass env=spawn_env to subprocess.run.
|
||||
"""dispatch_monitor.py must pass spawn_env as the subprocess env.
|
||||
|
||||
Without this, all env var isolation is useless — the subprocess
|
||||
would inherit os.environ instead of the cleaned spawn_env.
|
||||
Accepts either the direct kwarg form (env=spawn_env) or the
|
||||
popen_kwargs dict form ("env": spawn_env) introduced with the
|
||||
sandbox broker-fd wiring (FPLAN-0250 Phase 6b).
|
||||
"""
|
||||
active_source = self._load_active_source()
|
||||
assert "env=spawn_env" in active_source, "dispatch_monitor.py must pass env=spawn_env to subprocess.run"
|
||||
assert "env=spawn_env" in active_source or '"env": spawn_env' in active_source, (
|
||||
"dispatch_monitor.py must pass spawn_env as the subprocess env"
|
||||
)
|
||||
|
||||
def test_detect_resolves_identity_when_cwd_is_wrong(self, clean_env, tmp_path, list_format_registry):
|
||||
"""When AIPASS_CALLER_BRANCH is set but CWD is outside any branch,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# AIPASS — Branch Prompt
|
||||
|
||||
*Injected every turn. Breadcrumbs only — details: README, --help, .trinity/ memories, STATUS.local.md.*
|
||||
*Injected every turn. Breadcrumbs only — details: README, --help, .trinity/ memories.*
|
||||
|
||||
## Identity
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"metadata": {
|
||||
"version": "2.0.0",
|
||||
"created": "2026-04-16",
|
||||
"description": "Bypass config for @aipass citizen. While under construction (DPLAN-0136 Phase 0-3), module and handler files exist as documented placeholders with no implementation body. Each placeholder raises NotImplementedError and declares its phase. Bypass standards that fire on structural requirements the placeholders intentionally skip — json_handler import, print_introspection, CLI service wiring. Remove these entries in Phase N as each module gets its real body.",
|
||||
"last_updated": "2026-04-16"
|
||||
"description": "Bypass config for @aipass citizen. Modules operational: concierge/init/doctor/handoff/profile built and tested. Bypasses cover: binary-invocation introspection pattern (bare aipass <cmd> runs, --info for introspection), thin entry-point router (aipass.py uses bare print, no CLI imports), pure-python bootstrap (bootstrap.py/scaffold_content.py run before AIPass services exist), test-isolation patterns (architecture/encapsulation for tests/ directory), and CLI flag name references (permission_flags in test assertions and handoff platform).",
|
||||
"last_updated": "2026-06-02"
|
||||
},
|
||||
"bypass": [
|
||||
{
|
||||
@@ -31,45 +31,20 @@
|
||||
"standard": "cli",
|
||||
"reason": "Session info must print immediately after tmux spawn — returning data to module layer would lose the timing context. User needs attach/kill instructions right when the session starts."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/handoff.py",
|
||||
"standard": "introspection",
|
||||
"reason": "Phase 4 placeholder — print_introspection() added with handoff build."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/handoff.py",
|
||||
"standard": "json_structure",
|
||||
"reason": "Phase 4 placeholder — json_handler import added with handoff build."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/handoff.py",
|
||||
"standard": "cli",
|
||||
"reason": "Phase 4 placeholder — CLI service imports added with handoff build."
|
||||
},
|
||||
{
|
||||
"file": "apps/aipass.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Phase 0 entry-point stub from spawn template. Full 3-layer wiring (modules/ discovery, handlers/ imports) added when first module comes online (Phase 1)."
|
||||
},
|
||||
{
|
||||
"file": "apps/aipass.py",
|
||||
"standard": "cli",
|
||||
"reason": "Phase 0 entry-point stub — CLI service imports (console, header) added when modules come online (Phase 1+)."
|
||||
"reason": "Thin command router — discovers and routes to modules, which own the CLI service layer. Adding console/header imports here couples the bootstrap entry point to Rich for 4 status lines."
|
||||
},
|
||||
{
|
||||
"file": "apps/aipass.py",
|
||||
"standard": "debug_print",
|
||||
"reason": "Phase 0 entry-point stub uses bare print() for scaffold visibility. Replaced with console.print() when CLI services are wired in Phase 1+."
|
||||
"reason": "Thin command router uses bare print() for version output and help banner (4 calls). These run before module discovery — importing Rich console for bootstrap output adds startup overhead for minimal benefit."
|
||||
},
|
||||
{
|
||||
"file": "apps/aipass.py",
|
||||
"standard": "introspection",
|
||||
"reason": "Phase 0 — spawn template does not emit print_introspection(). Added when modules come online (Phase 1+)."
|
||||
},
|
||||
{
|
||||
"file": "apps/aipass.py",
|
||||
"standard": "log_structure",
|
||||
"reason": "Phase 0 — logs/ directory exists (spawn-created), but prax logger import not wired yet. Added when first module uses it."
|
||||
"reason": "Thin command router, not a module — it has no domain to introspect. Modules handle their own introspection via --info. No print_introspection() needed."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/doctor.py",
|
||||
@@ -260,6 +235,106 @@
|
||||
"file": "apps/handlers/init/scaffold_content.py",
|
||||
"standard": "json_structure",
|
||||
"reason": "scaffold_content.py is Pure Python only (no module/prax/cli imports) by design — pure string-returning template generators extracted from bootstrap.py. Same constraint as bootstrap.py."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/doctor.py",
|
||||
"standard": "introspection",
|
||||
"reason": "aipass is binary-invoked: aipass doctor runs the command; introspection via --info"
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/doctor_fix.py",
|
||||
"standard": "introspection",
|
||||
"reason": "aipass is binary-invoked: bare invocation shows usage; introspection via --info"
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/doctor_wire.py",
|
||||
"standard": "introspection",
|
||||
"reason": "aipass is binary-invoked: bare invocation shows usage; introspection via --info"
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/handoff.py",
|
||||
"standard": "introspection",
|
||||
"reason": "aipass is binary-invoked: aipass handoff shows usage; introspection via --info"
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/help_chat.py",
|
||||
"standard": "introspection",
|
||||
"reason": "aipass is binary-invoked: aipass help shows usage; introspection via --info"
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/init_flow.py",
|
||||
"standard": "introspection",
|
||||
"reason": "aipass is binary-invoked: aipass init shows usage; introspection via --info"
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/profile.py",
|
||||
"standard": "introspection",
|
||||
"reason": "aipass is binary-invoked: aipass profile runs the command; introspection via --info"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/json/json_handler.py",
|
||||
"standard": "test_quality",
|
||||
"reason": "save_json now raises ValueError on invalid structure (aipass.aipass.shared contract, TDPLAN-0006 P2). Tested via pytest.raises — no False return path to test."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_sandbox_check.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test file lives in tests/ by convention — not in apps/. Standard 3-layer structure applies to production code only."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_sandbox_check.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Unit tests must import handlers directly (sandbox_checker, progress) to test them in isolation. Entry-point imports would defeat the purpose of unit testing."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_sandbox_check.py",
|
||||
"standard": "documentation",
|
||||
"reason": "Test methods use descriptive names (test_flag_off_by_default, test_bwrap_functional_live) that are self-documenting. Adding docstrings to 41 test functions adds noise without value."
|
||||
},
|
||||
{
|
||||
"file": "shared/json_handler.py",
|
||||
"standard": "architecture",
|
||||
"reason": "pre-infra leaf — stdlib-only by design, must not import branch dependencies (loads pre-drone for aipass init)"
|
||||
},
|
||||
{
|
||||
"file": "shared/json_handler.py",
|
||||
"standard": "log_visibility",
|
||||
"reason": "pre-infra leaf — stdlib-only by design, must not import branch dependencies (loads pre-drone for aipass init)"
|
||||
},
|
||||
{
|
||||
"file": "shared/json_handler.py",
|
||||
"standard": "trigger",
|
||||
"reason": "pre-infra leaf — stdlib-only by design, must not import branch dependencies (loads pre-drone for aipass init)"
|
||||
},
|
||||
{
|
||||
"file": "shared/json_ops.py",
|
||||
"standard": "architecture",
|
||||
"reason": "pre-infra leaf — stdlib-only by design, must not import branch dependencies (loads pre-drone for aipass init)"
|
||||
},
|
||||
{
|
||||
"file": "shared/json_ops.py",
|
||||
"standard": "log_visibility",
|
||||
"reason": "pre-infra leaf — stdlib-only by design, must not import branch dependencies (loads pre-drone for aipass init)"
|
||||
},
|
||||
{
|
||||
"file": "shared/json_ops.py",
|
||||
"standard": "trigger",
|
||||
"reason": "pre-infra leaf — stdlib-only by design, must not import branch dependencies (loads pre-drone for aipass init)"
|
||||
},
|
||||
{
|
||||
"file": "shared/registry_discovery.py",
|
||||
"standard": "architecture",
|
||||
"reason": "pre-infra leaf — stdlib-only by design, must not import branch dependencies (loads pre-drone for aipass init)"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_shared_bootstrap_safety.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test file lives in tests/ by convention — not in apps/. Standard 3-layer structure applies to production code only."
|
||||
},
|
||||
{
|
||||
"file": "shared/json_ops.py",
|
||||
"standard": "unused_function",
|
||||
"reason": "backup_json() is consumed by @spawn (cross-branch caller). Appears unused in @aipass-only scan but is a shared API."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -17,9 +17,9 @@ aipass/
|
||||
│ ├── modules/
|
||||
│ │ ├── doctor.py # System health aggregation
|
||||
│ │ ├── doctor_fix.py # Remediation report (--fix, --json)
|
||||
│ │ ├── doctor_wire.py # Auto-wire prompt helpers
|
||||
│ │ ├── doctor_wire.py # Auto-wire provider settings + stale-deny re-export
|
||||
│ │ ├── handoff.py # CLI handoff (placeholder)
|
||||
│ │ ├── help_chat.py # README-backed Q&A
|
||||
│ │ ├── help_chat.py # README-backed Q&A (reads via readme_map handler)
|
||||
│ │ ├── init_flow.py # 12-stage guided setup
|
||||
│ │ └── profile.py # User profile read/write
|
||||
│ ├── handlers/
|
||||
@@ -27,12 +27,14 @@ aipass/
|
||||
│ │ ├── init/ # bootstrap.py, scaffold_content.py
|
||||
│ │ ├── json/ # JSON read/write utilities
|
||||
│ │ ├── ping_sweep/ # Branch reachability verification
|
||||
│ │ ├── provider_reconcile.py # Stale deny-rule detection + fix
|
||||
│ │ ├── readme_map/ # Live file reads + branch routing
|
||||
│ │ ├── structure_scan/ # Agent placement + pollution detection
|
||||
│ │ ├── system_detect/ # OS, shell, Python, RAM, CPU
|
||||
│ │ └── ui/ # Progress bars, menus, banners
|
||||
│ └── plugins/
|
||||
├── tests/ # 412 passing
|
||||
├── tests/ # 432 passing
|
||||
├── requirements.project.txt # Project-specific Python dependencies
|
||||
├── .trinity/ # Identity + session history + observations
|
||||
└── README.md
|
||||
```
|
||||
@@ -68,7 +70,7 @@ Humans only. Nothing in AIPass depends on this branch.
|
||||
|
||||
## Tests
|
||||
|
||||
412 passing — `pytest src/aipass/aipass/tests/`
|
||||
432 passing — `pytest src/aipass/aipass/tests/`
|
||||
|
||||
## Known Issues
|
||||
|
||||
@@ -76,4 +78,4 @@ Humans only. Nothing in AIPass depends on this branch.
|
||||
|
||||
## Last Updated
|
||||
|
||||
Last Updated: 2026-05-28
|
||||
Last Updated: 2026-06-05
|
||||
|
||||
@@ -15,11 +15,25 @@ Auto-discovery architecture:
|
||||
- No manual imports or routing needed
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
from typing import List, Any
|
||||
|
||||
# Windows terminals/pipes default to cp1252, which can't encode the Unicode
|
||||
# Rich emits (✓/✗, box-drawing, arrows). PYTHONUTF8 only affects child
|
||||
# interpreters, not this process's already-open stdout/stderr — so we also
|
||||
# reconfigure the live streams to UTF-8 in place (Python 3.7+). Without this,
|
||||
# `aipass init` scaffolds correctly but crashes printing its success banner
|
||||
# with UnicodeEncodeError ('charmap') on Windows. Mirrors drone/cli.py.
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1") # for child subprocesses
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
from aipass.prax import logger
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -15,11 +15,10 @@ Business logic for `aipass init`. Creates the project scaffold:
|
||||
3. CLAUDE.md — project prompt (Claude Code reads this)
|
||||
4. AGENTS.md — Codex equivalent of CLAUDE.md
|
||||
5. README.md — getting started guide
|
||||
6. STATUS.local.md — project status
|
||||
7. .gitignore — standard AIPass ignores
|
||||
8. .claude/settings.json — Claude Code hooks configuration
|
||||
9. src/ — directory where agents live
|
||||
10. .ai_mail.local/inbox.json — empty project mailbox
|
||||
6. .gitignore — standard AIPass ignores
|
||||
7. .claude/settings.json — Claude Code hooks configuration
|
||||
8. src/ — directory where agents live
|
||||
9. .ai_mail.local/inbox.json — empty project mailbox
|
||||
|
||||
Projects are NOT citizens — no .trinity/ directory. Identity lives in the
|
||||
registry JSON. Init is re-runnable: existing files are skipped, not errors.
|
||||
@@ -213,7 +212,6 @@ def _claude_settings(aipass_home: str | None = None) -> str:
|
||||
|
||||
data["permissions"] = {
|
||||
"deny": [
|
||||
"Bash(rm -rf *)",
|
||||
"Bash(git push --force*)",
|
||||
"Bash(git reset --hard*)",
|
||||
"EnterPlanMode",
|
||||
@@ -350,6 +348,9 @@ def init_project(target: Path, project_name: str | None = None) -> dict:
|
||||
content = template.read_text(encoding="utf-8").replace("{name}", name)
|
||||
dest.write_text(content, encoding="utf-8")
|
||||
created.append(str(dest))
|
||||
elif md_name == "AGENTS.md":
|
||||
dest.write_text(sc.agents_md(name), encoding="utf-8")
|
||||
created.append(str(dest))
|
||||
else:
|
||||
source = Path(aipass_home) / md_name if aipass_home else None
|
||||
if source and source.is_file():
|
||||
@@ -365,16 +366,7 @@ def init_project(target: Path, project_name: str | None = None) -> dict:
|
||||
readme_md_path.write_text(readme_content, encoding="utf-8")
|
||||
created.append(str(readme_md_path))
|
||||
|
||||
# 7. STATUS.local.md
|
||||
status_md_path = target / "STATUS.local.md"
|
||||
if not status_md_path.exists():
|
||||
status_md_path.write_text(
|
||||
f"# {name}\n\n**State:** New\n**Last update:** {today}\n\n## Current Work\n\n## Known Issues\n- None\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
created.append(str(status_md_path))
|
||||
|
||||
# 8. .gitignore
|
||||
# 7. .gitignore
|
||||
gitignore_path = target / ".gitignore"
|
||||
if not gitignore_path.exists():
|
||||
gitignore_path.write_text(sc.gitignore(), encoding="utf-8")
|
||||
@@ -451,7 +443,7 @@ def update_project(target: Path) -> dict:
|
||||
"""Update managed scaffold files in an existing AIPass project.
|
||||
|
||||
Overwrites managed prompt and config files with the latest templates while
|
||||
leaving all user-owned files (registry, README, STATUS.local.md, .gitignore,
|
||||
leaving all user-owned files (registry, README, .gitignore,
|
||||
src/) untouched.
|
||||
|
||||
Args:
|
||||
@@ -587,7 +579,6 @@ def update_project(target: Path) -> dict:
|
||||
for skip_name in (
|
||||
str(registry_path),
|
||||
str(target / "README.md"),
|
||||
str(target / "STATUS.local.md"),
|
||||
str(target / ".gitignore"),
|
||||
):
|
||||
skipped.append(skip_name)
|
||||
|
||||
@@ -37,11 +37,11 @@ def readme_md(name: str) -> str:
|
||||
"aipass init agent my_agent\n"
|
||||
"\n"
|
||||
"# 2. Start a session\n"
|
||||
"cd src/my_agent/\n"
|
||||
f"cd src/{name.lower()}/my_agent/\n"
|
||||
"claude # or your preferred AI CLI\n"
|
||||
"\n"
|
||||
"# 3. Check project status\n"
|
||||
"cat STATUS.local.md\n"
|
||||
"# 3. Check project health\n"
|
||||
"drone @seedgo audit .\n"
|
||||
"```\n"
|
||||
"\n"
|
||||
"## Project Structure\n"
|
||||
@@ -52,8 +52,7 @@ def readme_md(name: str) -> str:
|
||||
" .aipass/ # Prompts (injected per-turn)\n"
|
||||
" CLAUDE.md # Claude Code instructions\n"
|
||||
" AGENTS.md # Codex instructions\n"
|
||||
" STATUS.local.md # Project status\n"
|
||||
" src/ # Agent directories live here\n"
|
||||
f" src/{name.lower()}/ # Project package\n"
|
||||
" <agent_name>/ # Created via aipass init agent\n"
|
||||
"```\n"
|
||||
"\n"
|
||||
@@ -83,6 +82,30 @@ def readme_md(name: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def agents_md(name: str) -> str:
|
||||
"""Generate AGENTS.md content — Codex equivalent of CLAUDE.md for projects."""
|
||||
return (
|
||||
f"# {name}\n"
|
||||
"\n"
|
||||
"Agent workspace powered by AIPass.\n"
|
||||
"\n"
|
||||
"# Startup protocol\n"
|
||||
"\n"
|
||||
"On any greeting, silently run this sequence — no narration, no announcing "
|
||||
"steps. Just do it and respond with the status.\n"
|
||||
"\n"
|
||||
" - Read: `.trinity/passport.json`, `.trinity/local.json`, "
|
||||
"`.trinity/observations.json`, `README.md`\n"
|
||||
"\n"
|
||||
"Use drone commands for all operations. Never raw git, gh, or file access "
|
||||
"when drone provides it.\n"
|
||||
"\n"
|
||||
"# Memories\n"
|
||||
"\n"
|
||||
"Update `.trinity/` at natural breakpoints, after milestones, and on `/memo`.\n"
|
||||
)
|
||||
|
||||
|
||||
def global_prompt_md(name: str) -> str:
|
||||
"""Generate .aipass/aipass_global_prompt.md — injected every turn."""
|
||||
return (
|
||||
@@ -212,7 +235,6 @@ def gitignore() -> str:
|
||||
".trinity/\n"
|
||||
".ai_mail.local/\n"
|
||||
"*.local.*\n"
|
||||
"!STATUS.local.md\n"
|
||||
"\n"
|
||||
"# Plans (local working docs)\n"
|
||||
"DPLAN-*\n"
|
||||
@@ -279,9 +301,6 @@ def prep_md() -> str:
|
||||
"- **`.trinity/observations.json`** — YOUR MEMORY OF THE USER. "
|
||||
"Collaboration insights, preferences, friction points. Skip if nothing "
|
||||
"new about the user this session.\n"
|
||||
"- **`STATUS.local.md`** — PUBLIC STATUS BEACON. Current work, known "
|
||||
"issues, todos, notepad. Auto-synced to central STATUS.md on PR events "
|
||||
"— this is how other branches see you. Keep Current Work accurate.\n"
|
||||
"\n"
|
||||
"## 2. Active Plans\n"
|
||||
"\n"
|
||||
@@ -308,7 +327,7 @@ def prep_md() -> str:
|
||||
"- Flag anything in-flight: running background agents, dispatched "
|
||||
"branches waiting for replies, pending decisions\n"
|
||||
"- If anything can't survive compaction (e.g., agent IDs needed for "
|
||||
"resume), write it to STATUS.local.md Notepad\n"
|
||||
"resume), write it to local.json key_learnings\n"
|
||||
"\n"
|
||||
"## Confirm\n"
|
||||
"\n"
|
||||
@@ -317,7 +336,6 @@ def prep_md() -> str:
|
||||
"Prep complete:\n"
|
||||
"- local.json: [what was added]\n"
|
||||
"- observations.json: [updated / skipped]\n"
|
||||
"- STATUS.local.md: [updated / skipped]\n"
|
||||
"- Plans: [which ones updated]\n"
|
||||
"- Git: [branch, uncommitted count, suggestion]\n"
|
||||
"- Inbox: [count, action taken]\n"
|
||||
|
||||
@@ -1,254 +1,88 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: json_handler.py
|
||||
# Description: Auto-Creating JSON Handler for aipass branch
|
||||
# Version: 1.0.0
|
||||
# Description: Branch-local shim — delegates to aipass.aipass.shared.json_handler
|
||||
# Version: 2.0.0
|
||||
# Created: 2026-04-16
|
||||
# Modified: 2026-04-16
|
||||
# Modified: 2026-06-06
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
JSON Handler - Auto-Creating & Self-Healing JSON System
|
||||
"""Branch-local JSON handler — thin shim over the shared ``aipass.aipass.shared`` library.
|
||||
|
||||
Handles default JSON files (config, data, log) for aipass modules.
|
||||
Never manually create JSONs - they build themselves.
|
||||
All logic lives in ``aipass.aipass.shared.json_handler.JsonHandler``.
|
||||
This module binds a ``JsonHandler`` instance to the aipass branch's
|
||||
``aipass_json/`` directory and re-exports the public API as module-level
|
||||
functions so existing callers (``json_handler.log_operation(...)``) keep working.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.aipass.shared.json_handler import JsonHandler
|
||||
|
||||
|
||||
def _get_caller_module_name() -> str:
|
||||
"""Auto-detect calling module name from call stack."""
|
||||
stack = inspect.stack()
|
||||
if len(stack) > 2:
|
||||
caller_path = Path(stack[2].filename)
|
||||
module_name = caller_path.stem
|
||||
if module_name and not module_name.startswith("_"):
|
||||
return module_name
|
||||
return "unknown"
|
||||
|
||||
# =============================================================================
|
||||
# INFRASTRUCTURE SETUP
|
||||
# =============================================================================
|
||||
|
||||
# json_handler.py lives at: src/aipass/aipass/apps/handlers/json/json_handler.py
|
||||
# parents[0] = json/, [1] = handlers/, [2] = apps/, [3] = aipass/, [4] = src/aipass/
|
||||
_PKG_ROOT = Path(__file__).resolve().parents[4]
|
||||
|
||||
# Constants
|
||||
AIPASS_BRANCH_ROOT = _PKG_ROOT / "aipass"
|
||||
AIPASS_JSON_DIR = AIPASS_BRANCH_ROOT / "aipass_json"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# INTERNAL HELPERS
|
||||
# =============================================================================
|
||||
def _handler() -> JsonHandler:
|
||||
"""Create a handler bound to the current AIPASS_JSON_DIR."""
|
||||
return JsonHandler(AIPASS_JSON_DIR)
|
||||
|
||||
|
||||
def _get_caller_module_name() -> str:
|
||||
"""Auto-detect calling module name from call stack.
|
||||
|
||||
Returns:
|
||||
Module name (e.g., "doctor" from doctor.py)
|
||||
"""
|
||||
try:
|
||||
stack = inspect.stack()
|
||||
# Skip frames: [0]=this function, [1]=log_operation, [2]=actual caller
|
||||
if len(stack) > 2:
|
||||
caller_frame = stack[2]
|
||||
caller_path = Path(caller_frame.filename)
|
||||
module_name = caller_path.stem
|
||||
|
||||
if module_name and not module_name.startswith("_"):
|
||||
return module_name
|
||||
|
||||
return "unknown"
|
||||
except Exception as exc:
|
||||
logger.warning("[json_handler] Failed to detect caller module name: %s", exc)
|
||||
return "unknown"
|
||||
def load_path(file_path: Path) -> Optional[dict]:
|
||||
"""Load JSON from an arbitrary file path."""
|
||||
return JsonHandler.read_json(file_path)
|
||||
|
||||
|
||||
def _default_template(json_type: str, module_name: str) -> Any:
|
||||
"""Return inline default structure for a JSON type — no file templates needed."""
|
||||
today = datetime.now().date().isoformat()
|
||||
if json_type == "config":
|
||||
return {
|
||||
"module_name": module_name,
|
||||
"version": "1.0.0",
|
||||
"config": {
|
||||
"max_log_entries": 100,
|
||||
},
|
||||
"created": today,
|
||||
}
|
||||
if json_type == "data":
|
||||
return {
|
||||
"created": today,
|
||||
"last_updated": today,
|
||||
}
|
||||
if json_type == "log":
|
||||
return []
|
||||
return None
|
||||
|
||||
|
||||
def _atomic_write_json(target_path: Path, data: Any) -> None:
|
||||
"""Write JSON data atomically via temp file + rename.
|
||||
|
||||
Prevents corruption from concurrent processes writing the same file.
|
||||
"""
|
||||
fd, tmp_path = tempfile.mkstemp(dir=str(target_path.parent), suffix=".tmp", prefix=target_path.stem)
|
||||
succeeded = False
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
os.replace(tmp_path, str(target_path))
|
||||
succeeded = True
|
||||
finally:
|
||||
if not succeeded and Path(tmp_path).exists():
|
||||
logger.warning("[json_handler] Cleaning up temp file after write failure: %s", tmp_path)
|
||||
os.unlink(tmp_path)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# VALIDATION
|
||||
# =============================================================================
|
||||
def save_path(file_path: Path, data: Any, indent: int = 2) -> bool:
|
||||
"""Write JSON data to an arbitrary file path atomically."""
|
||||
return JsonHandler.write_json(file_path, data, indent)
|
||||
|
||||
|
||||
def validate_json_structure(data: Any, json_type: str) -> bool:
|
||||
"""Validate JSON structure matches expected type."""
|
||||
if json_type == "config":
|
||||
if not isinstance(data, dict):
|
||||
return False
|
||||
required = ["module_name", "version", "config"]
|
||||
return all(key in data for key in required)
|
||||
|
||||
elif json_type == "data":
|
||||
if not isinstance(data, dict):
|
||||
return False
|
||||
required = ["created", "last_updated"]
|
||||
return all(key in data for key in required)
|
||||
|
||||
elif json_type == "log":
|
||||
return isinstance(data, list)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PUBLIC API
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def load_path(path: Path) -> Any:
|
||||
"""Load JSON from an arbitrary file path with consistent error handling."""
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
logger.warning("[json_handler] Failed to load %s: %s", path, exc)
|
||||
return None
|
||||
|
||||
|
||||
def save_path(path: Path, data: Any) -> bool:
|
||||
"""Write JSON data to an arbitrary file path atomically."""
|
||||
os.makedirs(path.parent, exist_ok=True)
|
||||
fd, tmp_path = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp", prefix=path.stem)
|
||||
succeeded = False
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
f.write("\n")
|
||||
os.replace(tmp_path, str(path))
|
||||
succeeded = True
|
||||
return True
|
||||
except OSError as exc:
|
||||
logger.warning("[json_handler] Failed to save %s: %s", path, exc)
|
||||
return False
|
||||
finally:
|
||||
if not succeeded and Path(tmp_path).exists():
|
||||
os.unlink(tmp_path)
|
||||
"""Validate that data matches the expected shape for json_type."""
|
||||
return JsonHandler.validate_json_structure(data, json_type)
|
||||
|
||||
|
||||
def get_json_path(module_name: str, json_type: str) -> Path:
|
||||
"""Get path for module JSON file."""
|
||||
filename = f"{module_name}_{json_type}.json"
|
||||
return AIPASS_JSON_DIR / filename
|
||||
"""Return the filesystem path for a module's JSON file."""
|
||||
return _handler().get_json_path(module_name, json_type)
|
||||
|
||||
|
||||
def ensure_json_exists(module_name: str, json_type: str) -> bool:
|
||||
"""Ensure JSON file exists, create from template if missing."""
|
||||
AIPASS_JSON_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
json_path = get_json_path(module_name, json_type)
|
||||
|
||||
if json_path.exists():
|
||||
try:
|
||||
with open(json_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
if validate_json_structure(data, json_type):
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[json_handler] Corrupted JSON file for '%s/%s', regenerating: %s",
|
||||
module_name,
|
||||
json_type,
|
||||
exc,
|
||||
)
|
||||
|
||||
template = _default_template(json_type, module_name)
|
||||
if template is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
_atomic_write_json(json_path, template)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"[json_handler] Failed to write JSON template for '%s/%s': %s",
|
||||
module_name,
|
||||
json_type,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def load_json(module_name: str, json_type: str) -> Optional[Any]:
|
||||
"""Load JSON file, auto-create if missing."""
|
||||
if not ensure_json_exists(module_name, json_type):
|
||||
return None
|
||||
|
||||
json_path = get_json_path(module_name, json_type)
|
||||
|
||||
try:
|
||||
with open(json_path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception as exc:
|
||||
logger.error("[json_handler] Failed to load JSON for '%s/%s': %s", module_name, json_type, exc)
|
||||
return None
|
||||
|
||||
|
||||
def save_json(module_name: str, json_type: str, data: Any) -> bool:
|
||||
"""Save JSON file."""
|
||||
json_path = get_json_path(module_name, json_type)
|
||||
|
||||
if not validate_json_structure(data, json_type):
|
||||
return False
|
||||
|
||||
if json_type == "data" and isinstance(data, dict):
|
||||
data["last_updated"] = datetime.now().date().isoformat()
|
||||
|
||||
try:
|
||||
_atomic_write_json(json_path, data)
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.error("[json_handler] Failed to save JSON for '%s/%s': %s", module_name, json_type, exc)
|
||||
return False
|
||||
"""Ensure a single JSON file exists; create with defaults if missing."""
|
||||
return _handler().ensure_json_exists(module_name, json_type)
|
||||
|
||||
|
||||
def ensure_module_jsons(module_name: str) -> bool:
|
||||
"""Ensure all 3 JSON files exist for a module."""
|
||||
ensure_json_exists(module_name, "config")
|
||||
ensure_json_exists(module_name, "data")
|
||||
ensure_json_exists(module_name, "log")
|
||||
return True
|
||||
"""Ensure all three JSON files (config, data, log) exist for a module."""
|
||||
return _handler().ensure_module_jsons(module_name)
|
||||
|
||||
|
||||
def load_json(module_name: str, json_type: str) -> Optional[Any]:
|
||||
"""Load a module's JSON file, auto-creating it if missing."""
|
||||
return _handler().load_json(module_name, json_type)
|
||||
|
||||
|
||||
def save_json(module_name: str, json_type: str, data: Any) -> bool:
|
||||
"""Save JSON file. Raises ValueError on invalid structure."""
|
||||
return _handler().save_json(module_name, json_type, data)
|
||||
|
||||
|
||||
def log_operation(
|
||||
@@ -256,42 +90,7 @@ def log_operation(
|
||||
data: Dict[str, Any] | None = None,
|
||||
module_name: str | None = None,
|
||||
) -> bool:
|
||||
"""Add entry to module log with automatic rotation.
|
||||
|
||||
Auto-detects calling module if module_name not provided.
|
||||
Implements config-controlled log limits to prevent unbounded growth.
|
||||
When max_log_entries is reached, removes oldest entries (FIFO).
|
||||
|
||||
Args:
|
||||
operation: Operation name to log
|
||||
data: Optional data dict
|
||||
module_name: Optional module name (auto-detected if not provided)
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
"""Add entry to module operation log with automatic rotation."""
|
||||
if module_name is None:
|
||||
module_name = _get_caller_module_name()
|
||||
|
||||
ensure_module_jsons(module_name)
|
||||
|
||||
config = load_json(module_name, "config")
|
||||
max_entries = 100
|
||||
if config and "config" in config:
|
||||
max_entries = config["config"].get("max_log_entries", 100)
|
||||
|
||||
log = load_json(module_name, "log")
|
||||
if log is None:
|
||||
log = []
|
||||
|
||||
entry: Dict[str, Any] = {"timestamp": datetime.now().isoformat(), "operation": operation}
|
||||
|
||||
if data:
|
||||
entry["data"] = data
|
||||
|
||||
log.append(entry)
|
||||
|
||||
if len(log) > max_entries:
|
||||
log = log[-max_entries:]
|
||||
|
||||
return save_json(module_name, "log", log)
|
||||
return _handler().log_operation(operation, data, module_name)
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: provider_reconcile.py
|
||||
# Description: Detect and fix stale rules in provider settings
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-06-05
|
||||
# Modified: 2026-06-05
|
||||
# =============================================
|
||||
|
||||
"""provider_reconcile — detect and fix stale rules in ~/.claude/settings.json."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax import logger
|
||||
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
|
||||
_MODULE_NAME = "provider_reconcile"
|
||||
|
||||
_STALE_RM_DENY_RULES = frozenset({"Bash(rm -rf*)", "Bash(rm -r *)"})
|
||||
|
||||
GLYPH_PASS = "[green]✓[/green]"
|
||||
GLYPH_WARN = "[yellow]![/yellow]"
|
||||
|
||||
|
||||
def reconcile_stale_deny(fix: bool = False) -> list:
|
||||
"""Detect and optionally remove stale rm deny rules from provider settings.
|
||||
|
||||
Returns list of (label, glyph, detail, remediation) tuples matching
|
||||
doctor.CheckResult shape — imported as tuples to avoid circular import.
|
||||
"""
|
||||
results: list = []
|
||||
settings_path = Path.home() / ".claude" / "settings.json"
|
||||
|
||||
if not settings_path.exists():
|
||||
json_handler.log_operation(
|
||||
"reconcile_stale_deny",
|
||||
data={"fix": fix, "skipped": "no settings file"},
|
||||
module_name=_MODULE_NAME,
|
||||
)
|
||||
return results
|
||||
|
||||
data = json_handler.load_path(settings_path)
|
||||
if data is None:
|
||||
json_handler.log_operation(
|
||||
"reconcile_stale_deny",
|
||||
data={"fix": fix, "skipped": "could not load settings"},
|
||||
module_name=_MODULE_NAME,
|
||||
)
|
||||
return results
|
||||
|
||||
deny = data.get("permissions", {}).get("deny", [])
|
||||
stale = [r for r in deny if r in _STALE_RM_DENY_RULES]
|
||||
|
||||
if not stale:
|
||||
results.append(("rm deny migration", GLYPH_PASS, "no stale rules", ""))
|
||||
elif fix:
|
||||
deny_cleaned = [r for r in deny if r not in _STALE_RM_DENY_RULES]
|
||||
data.setdefault("permissions", {})["deny"] = deny_cleaned
|
||||
json_handler.save_path(settings_path, data)
|
||||
removed = ", ".join(stale)
|
||||
results.append(("rm deny migration", GLYPH_PASS, f"removed: {removed}", ""))
|
||||
logger.info("[doctor] removed stale deny rules: %s", stale)
|
||||
else:
|
||||
found = ", ".join(stale)
|
||||
results.append(
|
||||
(
|
||||
"rm deny migration",
|
||||
GLYPH_WARN,
|
||||
f"stale rules: {found}",
|
||||
"Run aipass doctor --fix to remove (rm_gate + drone rm replace these)",
|
||||
)
|
||||
)
|
||||
|
||||
json_handler.log_operation(
|
||||
"reconcile_stale_deny",
|
||||
data={"fix": fix, "stale_found": len(stale)},
|
||||
module_name=_MODULE_NAME,
|
||||
)
|
||||
return results
|
||||
@@ -117,3 +117,18 @@ def list_branches() -> list[str]:
|
||||
Reflects the filesystem state at the time the map was first built.
|
||||
"""
|
||||
return list(_get_map().keys())
|
||||
|
||||
|
||||
def read_readme_lines(branch: str) -> list[str] | None:
|
||||
"""Live-read README.md for a branch. Returns list of lines, or None on error.
|
||||
|
||||
Content is NEVER cached — every call reads the current file.
|
||||
"""
|
||||
readme_path = get_readme_path(branch)
|
||||
if readme_path is None:
|
||||
return None
|
||||
try:
|
||||
with open(readme_path, encoding="utf-8") as fh:
|
||||
return fh.readlines()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"""sandbox_check — Kernel sandbox prerequisite detection for aipass doctor."""
|
||||
|
||||
from aipass.aipass.apps.handlers.sandbox_check.sandbox_checker import ( # type: ignore[import-not-found]
|
||||
check_broker_alive,
|
||||
check_bwrap_functional,
|
||||
check_bwrap_present,
|
||||
check_node_present,
|
||||
check_rg_present,
|
||||
check_sandbox_flag,
|
||||
check_srt_resolvable,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"check_broker_alive",
|
||||
"check_bwrap_functional",
|
||||
"check_bwrap_present",
|
||||
"check_node_present",
|
||||
"check_rg_present",
|
||||
"check_sandbox_flag",
|
||||
"check_srt_resolvable",
|
||||
]
|
||||
@@ -0,0 +1,253 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: sandbox_checker.py
|
||||
# Description: Kernel sandbox prerequisite checks for aipass doctor
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-06-10
|
||||
# Modified: 2026-06-10
|
||||
# =============================================
|
||||
|
||||
"""Sandbox prerequisite checker — detects bwrap, node, srt, rg, broker.
|
||||
|
||||
Returns plain dicts with facts about sandbox readiness.
|
||||
No Rich markup — display concerns belong to the UI layer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
|
||||
|
||||
def check_sandbox_flag() -> Dict[str, Any]:
|
||||
"""Check AIPASS_SANDBOX_ENABLED env var state.
|
||||
|
||||
Returns:
|
||||
enabled: bool
|
||||
raw_value: str — the raw env value (empty if unset)
|
||||
"""
|
||||
raw = os.environ.get("AIPASS_SANDBOX_ENABLED", "")
|
||||
enabled = raw.lower() in ("1", "true", "yes")
|
||||
json_handler.log_operation("sandbox_check_flag", {"enabled": enabled, "raw": raw})
|
||||
return {"enabled": enabled, "raw_value": raw}
|
||||
|
||||
|
||||
def check_bwrap_present() -> Dict[str, Any]:
|
||||
"""Check if bubblewrap (bwrap) binary is on PATH.
|
||||
|
||||
Returns:
|
||||
found: bool
|
||||
path: str | None — resolved path if found
|
||||
"""
|
||||
path = shutil.which("bwrap")
|
||||
json_handler.log_operation("sandbox_check_bwrap_present", {"found": bool(path)})
|
||||
return {"found": bool(path), "path": path}
|
||||
|
||||
|
||||
def check_bwrap_functional() -> Dict[str, Any]:
|
||||
"""Run a trivial bwrap sandbox to verify it actually works.
|
||||
|
||||
Catches AppArmor/userns restrictions that make bwrap present but blocked.
|
||||
|
||||
Returns:
|
||||
ok: bool
|
||||
detail: str — success message or error detail
|
||||
sysctl_value: str | None — kernel.apparmor_restrict_unprivileged_userns on failure
|
||||
"""
|
||||
bwrap = shutil.which("bwrap")
|
||||
if not bwrap:
|
||||
return {"ok": False, "detail": "bwrap not found", "sysctl_value": None}
|
||||
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[bwrap, "--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "true"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
json_handler.log_operation("sandbox_check_bwrap_functional", {"ok": True})
|
||||
return {"ok": True, "detail": "trivial sandbox succeeded", "sysctl_value": None}
|
||||
|
||||
sysctl_val = _read_userns_sysctl()
|
||||
detail = f"exit {proc.returncode}"
|
||||
if proc.stderr.strip():
|
||||
detail = f"{detail}: {proc.stderr.strip()[:200]}"
|
||||
json_handler.log_operation("sandbox_check_bwrap_functional", {"ok": False, "detail": detail})
|
||||
return {"ok": False, "detail": detail, "sysctl_value": sysctl_val}
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("[sandbox_check] bwrap functional test timed out")
|
||||
return {"ok": False, "detail": "timed out (10s)", "sysctl_value": None}
|
||||
except OSError as exc:
|
||||
logger.warning("[sandbox_check] bwrap functional test error: %s", exc)
|
||||
return {"ok": False, "detail": str(exc), "sysctl_value": None}
|
||||
|
||||
|
||||
def _read_userns_sysctl() -> str | None:
|
||||
"""Read kernel.apparmor_restrict_unprivileged_userns sysctl if available."""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["sysctl", "-n", "kernel.apparmor_restrict_unprivileged_userns"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
return proc.stdout.strip()
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as exc:
|
||||
logger.info("[sandbox_check] sysctl read failed (expected on non-Ubuntu): %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def check_node_present() -> Dict[str, Any]:
|
||||
"""Check if node binary is on PATH.
|
||||
|
||||
Returns:
|
||||
found: bool
|
||||
path: str | None — resolved path if found
|
||||
"""
|
||||
path = shutil.which("node")
|
||||
json_handler.log_operation("sandbox_check_node", {"found": bool(path)})
|
||||
return {"found": bool(path), "path": path}
|
||||
|
||||
|
||||
def check_srt_resolvable() -> Dict[str, Any]:
|
||||
"""Check if @anthropic-ai/sandbox-runtime is resolvable via node.
|
||||
|
||||
Mirrors _srt_resolve.mjs resolution: derive node prefix from process.execPath,
|
||||
then check <prefix>/lib/node_modules/@anthropic-ai/sandbox-runtime/dist/index.js.
|
||||
|
||||
Returns:
|
||||
found: bool
|
||||
path: str | None — resolved entry path if found
|
||||
install_hint: str — npm install command if missing
|
||||
"""
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
return {
|
||||
"found": False,
|
||||
"path": None,
|
||||
"install_hint": "Install node first, then: npm install -g @anthropic-ai/sandbox-runtime",
|
||||
}
|
||||
|
||||
try:
|
||||
script = (
|
||||
"const p = require('path');"
|
||||
"const prefix = p.dirname(p.dirname(process.execPath));"
|
||||
"const entry = p.join(prefix, 'lib/node_modules/@anthropic-ai/sandbox-runtime/dist/index.js');"
|
||||
"const fs = require('fs');"
|
||||
"if (fs.existsSync(entry)) { process.stdout.write(entry); }"
|
||||
"else { process.exit(1); }"
|
||||
)
|
||||
proc = subprocess.run(
|
||||
[node, "-e", script],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode == 0 and proc.stdout.strip():
|
||||
path = proc.stdout.strip()
|
||||
json_handler.log_operation("sandbox_check_srt", {"found": True, "path": path})
|
||||
return {"found": True, "path": path, "install_hint": ""}
|
||||
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as exc:
|
||||
logger.warning("[sandbox_check] srt resolve error: %s", exc)
|
||||
|
||||
json_handler.log_operation("sandbox_check_srt", {"found": False})
|
||||
return {
|
||||
"found": False,
|
||||
"path": None,
|
||||
"install_hint": "npm install -g @anthropic-ai/sandbox-runtime",
|
||||
}
|
||||
|
||||
|
||||
def check_rg_present() -> Dict[str, Any]:
|
||||
"""Check if ripgrep (rg) is available — matches hooks' fallback logic.
|
||||
|
||||
Returns:
|
||||
found: bool
|
||||
path: str | None — resolved path if found
|
||||
"""
|
||||
rg = shutil.which("rg")
|
||||
if rg:
|
||||
json_handler.log_operation("sandbox_check_rg", {"found": True, "path": rg})
|
||||
return {"found": True, "path": rg}
|
||||
|
||||
fallback = Path.home() / ".local" / "bin" / "rg"
|
||||
if fallback.is_file():
|
||||
path = str(fallback)
|
||||
json_handler.log_operation("sandbox_check_rg", {"found": True, "path": path})
|
||||
return {"found": True, "path": path}
|
||||
|
||||
json_handler.log_operation("sandbox_check_rg", {"found": False})
|
||||
return {"found": False, "path": None}
|
||||
|
||||
|
||||
def check_broker_alive(repo_root: Path | None = None) -> Dict[str, Any]:
|
||||
"""Check if the broker daemon socket is accepting connections.
|
||||
|
||||
Args:
|
||||
repo_root: Project root containing .ai_central/. Auto-detected if None.
|
||||
|
||||
Returns:
|
||||
alive: bool
|
||||
detail: str — status message
|
||||
"""
|
||||
sock_path = _find_broker_socket(repo_root)
|
||||
if sock_path is None:
|
||||
json_handler.log_operation("sandbox_check_broker", {"alive": False, "reason": "socket_not_found"})
|
||||
return {"alive": False, "detail": "broker socket not found"}
|
||||
|
||||
if not sock_path.exists():
|
||||
json_handler.log_operation("sandbox_check_broker", {"alive": False, "reason": "socket_missing"})
|
||||
return {"alive": False, "detail": f"socket missing: {sock_path}"}
|
||||
|
||||
try:
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
s.settimeout(2)
|
||||
s.connect(str(sock_path))
|
||||
s.close()
|
||||
json_handler.log_operation("sandbox_check_broker", {"alive": True})
|
||||
return {"alive": True, "detail": "connected"}
|
||||
except (OSError, socket.timeout) as exc:
|
||||
logger.info("[sandbox_check] broker connect failed: %s", exc)
|
||||
json_handler.log_operation("sandbox_check_broker", {"alive": False, "reason": str(exc)})
|
||||
return {"alive": False, "detail": f"connect failed: {exc}"}
|
||||
|
||||
|
||||
def _find_broker_socket(repo_root: Path | None) -> Path | None:
|
||||
"""Locate the broker socket under $REPO/.ai_central/drone_broker.sock."""
|
||||
if repo_root and (repo_root / ".ai_central" / "drone_broker.sock").parent.is_dir():
|
||||
return repo_root / ".ai_central" / "drone_broker.sock"
|
||||
|
||||
aipass_home = os.environ.get("AIPASS_HOME", "")
|
||||
if aipass_home:
|
||||
candidate = Path(aipass_home) / ".ai_central" / "drone_broker.sock"
|
||||
if candidate.parent.is_dir():
|
||||
return candidate
|
||||
|
||||
cwd = Path.cwd()
|
||||
for parent in [cwd, *cwd.parents]:
|
||||
candidate = parent / ".ai_central" / "drone_broker.sock"
|
||||
if candidate.parent.is_dir():
|
||||
return candidate
|
||||
if parent == parent.parent:
|
||||
break
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def is_linux() -> bool:
|
||||
"""Return True if running on Linux."""
|
||||
return sys.platform.startswith("linux")
|
||||
@@ -283,12 +283,6 @@ def detect_pollution(agents: List[AgentInfo]) -> List[PollutionHit]:
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def find_registry(project_root: Path) -> Optional[Path]:
|
||||
"""Find *_REGISTRY.json under project_root."""
|
||||
candidates = list(project_root.glob("*_REGISTRY.json"))
|
||||
return candidates[0] if candidates else None
|
||||
|
||||
|
||||
def check_registry_consistency(
|
||||
registry_path: Path,
|
||||
agents: List[AgentInfo],
|
||||
|
||||
@@ -20,7 +20,19 @@ from typing import Dict, List, NamedTuple
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.prax import logger
|
||||
|
||||
from aipass.aipass.shared.registry_discovery import find_registry as _discover_registry
|
||||
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
from aipass.aipass.apps.handlers.sandbox_check.sandbox_checker import (
|
||||
check_broker_alive,
|
||||
check_bwrap_functional,
|
||||
check_bwrap_present,
|
||||
check_node_present,
|
||||
check_rg_present,
|
||||
check_sandbox_flag,
|
||||
check_srt_resolvable,
|
||||
is_linux,
|
||||
)
|
||||
from aipass.aipass.apps.handlers.structure_scan.structure_scanner import (
|
||||
check_placement,
|
||||
check_pyproject,
|
||||
@@ -28,7 +40,6 @@ from aipass.aipass.apps.handlers.structure_scan.structure_scanner import (
|
||||
check_root_artifacts,
|
||||
detect_pollution,
|
||||
find_project_root,
|
||||
find_registry,
|
||||
scan_agents,
|
||||
)
|
||||
from aipass.aipass.apps.modules.doctor_fix import (
|
||||
@@ -38,6 +49,7 @@ from aipass.aipass.apps.modules.doctor_fix import (
|
||||
from aipass.aipass.apps.modules.doctor_wire import (
|
||||
_auto_wire_provider,
|
||||
prompt_auto_wire,
|
||||
reconcile_stale_deny,
|
||||
)
|
||||
from aipass.aipass.apps.handlers.system_detect.system_detector import (
|
||||
detect_cpu,
|
||||
@@ -68,26 +80,10 @@ class CheckResult(NamedTuple):
|
||||
remediation: str
|
||||
|
||||
|
||||
# --- Identity helpers ---
|
||||
|
||||
|
||||
def _find_registry() -> Path | None:
|
||||
"""Walk up from CWD first (user's project), then branch root."""
|
||||
cwd = Path.cwd()
|
||||
for parent in (cwd, *cwd.parents):
|
||||
candidates = list(parent.glob("*_REGISTRY.json"))
|
||||
if candidates:
|
||||
return candidates[0]
|
||||
if parent == parent.parent:
|
||||
break
|
||||
for parent in (_BRANCH_ROOT, *_BRANCH_ROOT.parents):
|
||||
candidate = parent / "AIPASS_REGISTRY.json"
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
# --- Check groups ---
|
||||
"""Find *_REGISTRY.json via shared discovery (walk-up from CWD + branch root)."""
|
||||
result = _discover_registry(package_root=str(_BRANCH_ROOT))
|
||||
return result if result.exists() else None
|
||||
|
||||
|
||||
def _check_system() -> List[CheckResult]:
|
||||
@@ -147,11 +143,10 @@ def _check_identity() -> List[CheckResult]:
|
||||
"""Run Identity group checks."""
|
||||
results: List[CheckResult] = []
|
||||
|
||||
# Project root — derived from registry location
|
||||
reg = _find_registry()
|
||||
project_root = str(reg.parent) if reg else ""
|
||||
if project_root:
|
||||
results.append(CheckResult("AIPASS_HOME", GLYPH_PASS, project_root, ""))
|
||||
# Project root + registry — single lookup
|
||||
reg_path = _find_registry()
|
||||
if reg_path:
|
||||
results.append(CheckResult("AIPASS_HOME", GLYPH_PASS, str(reg_path.parent), ""))
|
||||
else:
|
||||
home = os.environ.get("AIPASS_HOME", "")
|
||||
if home:
|
||||
@@ -166,8 +161,6 @@ def _check_identity() -> List[CheckResult]:
|
||||
)
|
||||
)
|
||||
|
||||
# Registry present
|
||||
reg_path = _find_registry()
|
||||
if reg_path is None:
|
||||
results.append(CheckResult("registry", GLYPH_FAIL, "not found", "Run 'aipass init' to create registry"))
|
||||
return results
|
||||
@@ -451,6 +444,10 @@ def _check_services(verbose: bool = False) -> List[CheckResult]:
|
||||
manifest_checks = _check_provider_manifest()
|
||||
results.extend(manifest_checks)
|
||||
|
||||
# stale rm deny rules — detect only (fix runs in run_doctor when --fix)
|
||||
for tup in reconcile_stale_deny(fix=False):
|
||||
results.append(CheckResult(*tup))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@@ -525,8 +522,8 @@ def _check_structure() -> List[CheckResult]:
|
||||
results.append(CheckResult("pollution", GLYPH_PASS, "no duplicates", ""))
|
||||
|
||||
# Registry consistency
|
||||
reg_path = find_registry(project_root)
|
||||
if reg_path:
|
||||
reg_path = _discover_registry(start_path=project_root)
|
||||
if reg_path and reg_path.exists():
|
||||
reg_issues = check_registry_consistency(reg_path, agents)
|
||||
if reg_issues:
|
||||
for issue in reg_issues:
|
||||
@@ -558,11 +555,105 @@ def _check_structure() -> List[CheckResult]:
|
||||
return results
|
||||
|
||||
|
||||
# --- Sandbox check group ---
|
||||
|
||||
|
||||
def _check_sandbox() -> List[CheckResult]:
|
||||
"""Run Sandbox group checks — kernel sandbox prerequisites."""
|
||||
results: List[CheckResult] = []
|
||||
|
||||
if not is_linux():
|
||||
results.append(CheckResult("sandbox", GLYPH_PASS, "kernel sandbox: Linux-only, not checked", ""))
|
||||
return results
|
||||
|
||||
flag = check_sandbox_flag()
|
||||
flag_on = flag["enabled"]
|
||||
flag_label = "ON" if flag_on else "OFF"
|
||||
results.append(CheckResult("sandbox flag", GLYPH_PASS, f"AIPASS_SANDBOX_ENABLED={flag_label}", ""))
|
||||
|
||||
def _sev(ok: bool) -> str:
|
||||
if ok:
|
||||
return GLYPH_PASS
|
||||
return GLYPH_FAIL if flag_on else GLYPH_WARN
|
||||
|
||||
def _suffix(ok: bool) -> str:
|
||||
if ok or flag_on:
|
||||
return ""
|
||||
return " (inert — flag is off)"
|
||||
|
||||
bwrap = check_bwrap_present()
|
||||
results.append(
|
||||
CheckResult(
|
||||
"bwrap",
|
||||
_sev(bwrap["found"]),
|
||||
bwrap["path"] or "not found" + _suffix(bwrap["found"]),
|
||||
"" if bwrap["found"] else "sudo apt install bubblewrap",
|
||||
)
|
||||
)
|
||||
|
||||
if bwrap["found"]:
|
||||
func = check_bwrap_functional()
|
||||
detail = func["detail"]
|
||||
if not func["ok"] and func["sysctl_value"] is not None:
|
||||
detail = f"{detail} (apparmor_restrict_unprivileged_userns={func['sysctl_value']})"
|
||||
results.append(
|
||||
CheckResult(
|
||||
"bwrap functional",
|
||||
_sev(func["ok"]),
|
||||
detail + _suffix(func["ok"]),
|
||||
"",
|
||||
)
|
||||
)
|
||||
|
||||
node = check_node_present()
|
||||
results.append(
|
||||
CheckResult(
|
||||
"node",
|
||||
_sev(node["found"]),
|
||||
node["path"] or "not found" + _suffix(node["found"]),
|
||||
"" if node["found"] else "Install Node.js: https://nodejs.org/",
|
||||
)
|
||||
)
|
||||
|
||||
srt = check_srt_resolvable()
|
||||
results.append(
|
||||
CheckResult(
|
||||
"srt (@anthropic-ai/sandbox-runtime)",
|
||||
_sev(srt["found"]),
|
||||
srt["path"] or "not found" + _suffix(srt["found"]),
|
||||
"" if srt["found"] else srt["install_hint"],
|
||||
)
|
||||
)
|
||||
|
||||
rg = check_rg_present()
|
||||
results.append(
|
||||
CheckResult(
|
||||
"rg (ripgrep)",
|
||||
_sev(rg["found"]),
|
||||
rg["path"] or "not found" + _suffix(rg["found"]),
|
||||
"" if rg["found"] else "sudo apt install ripgrep (or static binary to ~/.local/bin/rg)",
|
||||
)
|
||||
)
|
||||
|
||||
project_root = find_project_root(Path.cwd())
|
||||
broker = check_broker_alive(project_root)
|
||||
results.append(
|
||||
CheckResult(
|
||||
"broker daemon",
|
||||
_sev(broker["alive"]),
|
||||
broker["detail"] + _suffix(broker["alive"]),
|
||||
"",
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# --- Main doctor run ---
|
||||
|
||||
|
||||
def run_doctor(verbose: bool = False, interactive: bool = False, fix: bool = False) -> int:
|
||||
"""Run all five groups and print results. Returns error count."""
|
||||
"""Run all six groups and print results. Returns error count."""
|
||||
console.print()
|
||||
console.print("[bold cyan]aipass doctor[/bold cyan]")
|
||||
console.print()
|
||||
@@ -573,6 +664,7 @@ def run_doctor(verbose: bool = False, interactive: bool = False, fix: bool = Fal
|
||||
("Services", lambda: _check_services(verbose=verbose)),
|
||||
("Community", _check_community),
|
||||
("Structure", _check_structure),
|
||||
("Sandbox", _check_sandbox),
|
||||
]
|
||||
groups: Dict[str, List[CheckResult]] = {}
|
||||
with make_doctor_progress() as progress:
|
||||
@@ -588,6 +680,12 @@ def run_doctor(verbose: bool = False, interactive: bool = False, fix: bool = Fal
|
||||
r for r in groups.get("Services", []) if r.label not in ("hooks", "env vars", "permissions")
|
||||
] + manifest_results
|
||||
|
||||
if fix:
|
||||
stale_results = [CheckResult(*tup) for tup in reconcile_stale_deny(fix=True)]
|
||||
if stale_results:
|
||||
services = groups.get("Services", [])
|
||||
groups["Services"] = [r for r in services if r.label != "rm deny migration"] + stale_results
|
||||
|
||||
pass_count = 0
|
||||
warn_count = 0
|
||||
error_count = 0
|
||||
@@ -627,7 +725,7 @@ def print_introspection() -> None:
|
||||
console.print("[bold cyan]doctor Module[/bold cyan]")
|
||||
console.print("System health aggregation — flutter-doctor-style output")
|
||||
console.print()
|
||||
console.print("[yellow]Groups:[/yellow] System, Identity, Services, Community, Structure")
|
||||
console.print("[yellow]Groups:[/yellow] System, Identity, Services, Community, Structure, Sandbox")
|
||||
console.print("[yellow]Next:[/yellow] [green]aipass doctor[/green] / [green]aipass doctor --fix[/green]")
|
||||
console.print()
|
||||
|
||||
@@ -664,15 +762,11 @@ def handle_command(command: str, args: list[str]) -> bool:
|
||||
if command != "doctor":
|
||||
return False
|
||||
|
||||
if not args:
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
if args[0] in ("--help", "-h", "help"):
|
||||
if args and args[0] in ("--help", "-h", "help"):
|
||||
print_help()
|
||||
return True
|
||||
|
||||
if args[0] == "--info":
|
||||
if args and args[0] == "--info":
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ from typing import List, NamedTuple
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.prax import logger
|
||||
|
||||
from aipass.aipass.shared.registry_discovery import find_registry as _discover_registry
|
||||
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
from aipass.aipass.apps.handlers.structure_scan.structure_scanner import (
|
||||
check_placement,
|
||||
@@ -32,7 +34,6 @@ from aipass.aipass.apps.handlers.structure_scan.structure_scanner import (
|
||||
check_registry_consistency,
|
||||
check_root_artifacts,
|
||||
detect_pollution,
|
||||
find_registry,
|
||||
scan_agents,
|
||||
)
|
||||
|
||||
@@ -58,8 +59,8 @@ class RemediationItem(NamedTuple):
|
||||
|
||||
def detect_project_name(project_root: Path) -> str:
|
||||
"""Derive project name from registry filename or directory name."""
|
||||
reg = find_registry(project_root)
|
||||
if reg:
|
||||
reg = _discover_registry(start_path=project_root)
|
||||
if reg and reg.exists():
|
||||
name = reg.stem.replace("_REGISTRY", "").lower()
|
||||
if name:
|
||||
return name
|
||||
@@ -83,7 +84,7 @@ def _build_pollution_items(agents: list, project: str) -> List[RemediationItem]:
|
||||
f"Registry pollution: {len(hit.locations)} copies of "
|
||||
f"{hit.agent_name} share registry_id {hit.registry_id}"
|
||||
),
|
||||
fix_command=f"drone @spawn repair @{project} --clean-pollution",
|
||||
fix_command=f"drone @spawn repair @{project} --clean-pollution --apply",
|
||||
)
|
||||
)
|
||||
return items
|
||||
@@ -104,7 +105,7 @@ def _build_placement_items(agents: list, project_root: Path, project: str) -> Li
|
||||
severity="warning",
|
||||
category="placement",
|
||||
description=f"Misplaced agent: {issue.agent_name} at {rel_path}",
|
||||
fix_command=f"drone @spawn repair @{project} --relocate {rel_path} {suggested}",
|
||||
fix_command=f"drone @spawn repair @{project} --relocate {rel_path} {suggested} --apply",
|
||||
)
|
||||
)
|
||||
return items
|
||||
@@ -113,8 +114,8 @@ def _build_placement_items(agents: list, project_root: Path, project: str) -> Li
|
||||
def _build_registry_items(project_root: Path, agents: list, project: str) -> List[RemediationItem]:
|
||||
"""Build remediation items for registry consistency issues."""
|
||||
items: List[RemediationItem] = []
|
||||
reg_path = find_registry(project_root)
|
||||
if not reg_path:
|
||||
reg_path = _discover_registry(start_path=project_root)
|
||||
if not reg_path or not reg_path.exists():
|
||||
return items
|
||||
for issue in check_registry_consistency(reg_path, agents):
|
||||
items.append(
|
||||
@@ -122,7 +123,7 @@ def _build_registry_items(project_root: Path, agents: list, project: str) -> Lis
|
||||
severity="warning",
|
||||
category="registry",
|
||||
description=f"Registry {issue.problem}: {issue.branch_name} at {issue.registered_path}",
|
||||
fix_command=f"drone @spawn repair @{project} --dedup-registry",
|
||||
fix_command=f"drone @spawn repair @{project} --dedup-registry --apply",
|
||||
)
|
||||
)
|
||||
return items
|
||||
@@ -145,7 +146,7 @@ def generate_remediation(project_root: Path) -> List[RemediationItem]:
|
||||
severity="info",
|
||||
category="pyproject",
|
||||
description="Missing pyproject.toml",
|
||||
fix_command=f"drone @spawn repair @{project} --add-pyproject",
|
||||
fix_command=f"drone @spawn repair @{project} --add-pyproject --apply",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -156,7 +157,7 @@ def generate_remediation(project_root: Path) -> List[RemediationItem]:
|
||||
severity=severity,
|
||||
category="root_artifact",
|
||||
description=f"{hit.description}: {hit.name}/",
|
||||
fix_command=f"drone @spawn repair @{project} --relocate-root {hit.name}",
|
||||
fix_command=f"drone @spawn repair @{project} --relocate-root {hit.name} --apply",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -184,7 +185,8 @@ def format_text_report(items: List[RemediationItem], project_name: str) -> str:
|
||||
lines.append(f"[{item.severity.upper()}] {item.description}")
|
||||
lines.append(f" Fix: {item.fix_command}")
|
||||
lines.append("")
|
||||
lines.append(f"Preview all fixes: drone @spawn repair @{project_name} --dry-run")
|
||||
lines.append(f"Preview all fixes: drone @spawn repair @{project_name}")
|
||||
lines.append(f"Apply all fixes: drone @spawn repair @{project_name} --apply")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -304,12 +306,12 @@ def handle_command(command: str, args: list[str]) -> bool:
|
||||
return False
|
||||
|
||||
if not args:
|
||||
print_introspection()
|
||||
json_handler.log_operation("doctor_fix_info", {"command": command})
|
||||
console.print("[dim]Helper module — use: aipass doctor --fix [--json][/dim]")
|
||||
json_handler.log_operation("doctor_fix_usage", {"command": command})
|
||||
return True
|
||||
|
||||
if args[0] in ("--help", "-h", "help"):
|
||||
print_introspection()
|
||||
console.print("[dim]Helper module — use: aipass doctor --fix [--json][/dim]")
|
||||
json_handler.log_operation("doctor_fix_help", {"command": command})
|
||||
return True
|
||||
|
||||
|
||||
@@ -51,6 +51,11 @@ ENV_DESCRIPTIONS: Dict[str, str] = {
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# STALE DENY RULE MIGRATION (implementation in handler; re-exported here)
|
||||
# =============================================================================
|
||||
|
||||
from aipass.aipass.apps.handlers.provider_reconcile import reconcile_stale_deny # noqa: E402, F401
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# AUTO-WIRE
|
||||
@@ -283,13 +288,13 @@ def handle_command(command: str, args: list[str]) -> bool:
|
||||
return False
|
||||
|
||||
if not args:
|
||||
print_introspection()
|
||||
json_handler.log_operation("doctor_wire_info", {"command": command})
|
||||
console.print("[dim]Helper module — use: aipass doctor (auto-wire runs when needed)[/dim]")
|
||||
json_handler.log_operation("doctor_wire_usage", {"command": command})
|
||||
return True
|
||||
|
||||
if args[0] in ("--help", "-h", "help"):
|
||||
print_introspection()
|
||||
json_handler.log_operation("doctor_wire_info", {"command": command})
|
||||
console.print("[dim]Helper module — use: aipass doctor (auto-wire runs when needed)[/dim]")
|
||||
json_handler.log_operation("doctor_wire_help", {"command": command})
|
||||
return True
|
||||
|
||||
if args[0] in ("--info", "info"):
|
||||
|
||||
@@ -134,13 +134,17 @@ def handle_command(command: str, args: list[str]) -> bool:
|
||||
return False
|
||||
|
||||
if not args:
|
||||
print_introspection()
|
||||
print_help()
|
||||
return True
|
||||
|
||||
if args[0] in ("--help", "-h", "help"):
|
||||
print_help()
|
||||
return True
|
||||
|
||||
if args[0] == "--info":
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
if args[0] == "launch":
|
||||
cli, cwd, flag_variant = _parse_launch_args(args[1:])
|
||||
if cli not in CLI_CHOICES:
|
||||
|
||||
@@ -27,7 +27,7 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
from aipass.aipass.apps.handlers.readme_map import get_readme_path, list_branches
|
||||
from aipass.aipass.apps.handlers.readme_map import get_readme_path, list_branches, read_readme_lines
|
||||
from aipass.cli.apps.modules import console, error, header
|
||||
from aipass.prax import logger
|
||||
|
||||
@@ -54,6 +54,20 @@ def print_introspection() -> None:
|
||||
console.print(f"[bold cyan]Version:[/bold cyan] {_VERSION}")
|
||||
|
||||
|
||||
def print_help() -> None:
|
||||
"""Print usage help for the help command."""
|
||||
console.print()
|
||||
console.print("[bold cyan]aipass help[/bold cyan] — README-backed Q&A")
|
||||
console.print()
|
||||
console.print("[yellow]USAGE:[/yellow]")
|
||||
console.print(" [green]aipass help <question>[/green] [dim]# Search branch READMEs[/dim]")
|
||||
console.print()
|
||||
console.print("[yellow]EXAMPLES:[/yellow]")
|
||||
console.print(" [green]aipass help what does drone do[/green]")
|
||||
console.print(" [green]aipass help how does ai_mail work[/green]")
|
||||
console.print()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# KEYWORD EXTRACTION
|
||||
# =============================================================================
|
||||
@@ -152,17 +166,15 @@ def _match_branches(keywords: list[str]) -> list[str]:
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _search_readme(readme_path: Path, keywords: list[str]) -> list[tuple[int, str]]:
|
||||
"""Live-read readme_path. Return (line_num, line_text) for matching lines.
|
||||
def _search_readme(branch: str, keywords: list[str]) -> list[tuple[int, str]]:
|
||||
"""Live-read branch README via handler. Return (line_num, line_text) for matching lines.
|
||||
|
||||
Reads every call — never cached. Scores lines by number of keyword hits.
|
||||
Returns up to 5 best matches.
|
||||
"""
|
||||
try:
|
||||
with open(readme_path, encoding="utf-8") as fh:
|
||||
lines = fh.readlines()
|
||||
except OSError as exc:
|
||||
logger.warning("[help_chat] Could not read README %s: %s", readme_path, exc)
|
||||
lines = read_readme_lines(branch)
|
||||
if lines is None:
|
||||
logger.warning("[help_chat] Could not read README for branch %s", branch)
|
||||
return []
|
||||
|
||||
scored: list[tuple[int, int, str]] = [] # (score, line_num, line_text)
|
||||
@@ -219,10 +231,14 @@ def handle_command(command: str, args: list[str]) -> bool:
|
||||
json_handler.ensure_module_jsons(_MODULE_NAME)
|
||||
|
||||
if not args:
|
||||
print_introspection()
|
||||
print_help()
|
||||
return True
|
||||
|
||||
if args[0] in ("--help", "-h", "help"):
|
||||
print_help()
|
||||
return True
|
||||
|
||||
if args[0] == "--info":
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
@@ -244,7 +260,7 @@ def handle_command(command: str, args: list[str]) -> bool:
|
||||
readme_path = get_readme_path(branch)
|
||||
if not readme_path:
|
||||
continue
|
||||
matches = _search_readme(readme_path, keywords)
|
||||
matches = _search_readme(branch, keywords)
|
||||
if matches:
|
||||
found_any = True
|
||||
answer = _format_answer(branch, readme_path, matches)
|
||||
|
||||
@@ -377,6 +377,54 @@ def stage_5_style_questions(
|
||||
return {"style": style}
|
||||
|
||||
|
||||
def _install_claude_code() -> bool:
|
||||
"""Run the canonical Claude Code installer, platform-aware. Returns True on success."""
|
||||
if sys.platform == "win32":
|
||||
cmd = ["powershell", "-Command", "irm https://claude.ai/install.ps1 | iex"]
|
||||
else:
|
||||
cmd = ["bash", "-c", "curl -fsSL https://claude.ai/install.sh | bash"]
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, timeout=300)
|
||||
if result.returncode == 0 and shutil.which("claude"):
|
||||
return True
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
|
||||
logger.warning("[init_flow] Claude Code installer failed: %s", exc)
|
||||
|
||||
if shutil.which("npm"):
|
||||
console.print("[dim]Native installer didn't work — trying npm fallback...[/dim]")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["npm", "install", "-g", "@anthropic-ai/claude-code"],
|
||||
timeout=300,
|
||||
)
|
||||
if result.returncode == 0 and shutil.which("claude"):
|
||||
return True
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
|
||||
logger.warning("[init_flow] npm fallback install failed: %s", exc)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _handle_missing_claude(non_interactive: bool) -> None:
|
||||
"""Prompt to install Claude Code when missing, or warn in non-interactive mode."""
|
||||
if non_interactive:
|
||||
warning("[bold yellow]Claude Code ('claude') is not installed.[/bold yellow]")
|
||||
console.print(" Stage 11 handoff requires it. Install manually before then.")
|
||||
return
|
||||
|
||||
raw = _prompt("Claude Code ('claude') not found. Install now? [Y/n]", "Y")
|
||||
if raw.lower() in ("y", "yes", ""):
|
||||
console.print("[dim]Installing Claude Code...[/dim]")
|
||||
if _install_claude_code():
|
||||
console.print("[green]✓[/green] Claude Code installed successfully.")
|
||||
else:
|
||||
warning("[bold yellow]Installation failed.[/bold yellow]")
|
||||
console.print(" Install manually: https://claude.ai/download")
|
||||
else:
|
||||
console.print("[dim]Skipped. Stage 11 handoff will need 'claude' on PATH.[/dim]")
|
||||
|
||||
|
||||
def stage_6_tool_choice(
|
||||
non_interactive: bool = False,
|
||||
cli_override: str | None = None,
|
||||
@@ -393,6 +441,9 @@ def stage_6_tool_choice(
|
||||
else:
|
||||
cli_choice = _choose("Which CLI tool do you use?", CLI_CHOICES, default="claude")
|
||||
|
||||
if cli_choice == "claude" and not shutil.which("claude"):
|
||||
_handle_missing_claude(non_interactive)
|
||||
|
||||
if non_interactive:
|
||||
flag_variant = "default"
|
||||
else:
|
||||
@@ -454,9 +505,9 @@ def stage_8_first_agent(non_interactive: bool = False, dry_run: bool = False) ->
|
||||
console.print("Let's create your first AI agent (citizen).")
|
||||
|
||||
if non_interactive:
|
||||
agent_name = "my-agent"
|
||||
agent_name = "my_agent"
|
||||
else:
|
||||
agent_name = _prompt("Agent name (letters, hyphens, no spaces)", "my-agent") or "my-agent"
|
||||
agent_name = _prompt("Agent name (letters, underscores, no spaces)", "my_agent") or "my_agent"
|
||||
|
||||
package_dir = _resolve_package_dir()
|
||||
if package_dir:
|
||||
@@ -560,7 +611,7 @@ def stage_10_smoke_test(non_interactive: bool = False, dry_run: bool = False) ->
|
||||
def stage_11_handoff(
|
||||
cli_choice: str = "claude",
|
||||
flag_variant: str = "default",
|
||||
agent_path: str = "src/my-agent",
|
||||
agent_path: str = "src/my_agent",
|
||||
non_interactive: bool = False,
|
||||
dry_run: bool = False,
|
||||
accumulated: Dict[str, Any] | None = None,
|
||||
@@ -645,13 +696,18 @@ def _write_init_report(agent_path: str, accumulated: Dict[str, Any], dry_run: bo
|
||||
"cli_choice": accumulated.get("cli", "claude"),
|
||||
"total_agents": 1,
|
||||
"system": system_data,
|
||||
"note": "You are the first agent created in this project. You are the orchestrator. After dispatching work to other agents, monitor them with: drone @devpulse watchdog agent @target",
|
||||
"note": (
|
||||
"You are the first agent created in this project. You are the orchestrator."
|
||||
" After dispatching work to other agents, monitor them with:"
|
||||
" drone @devpulse watchdog agent @target"
|
||||
),
|
||||
}
|
||||
provider_gaps = accumulated.get("provider_gaps", {})
|
||||
if provider_gaps:
|
||||
report["provider_gaps"] = provider_gaps
|
||||
report["provider_action"] = (
|
||||
"Provider settings need configuring. Tell the user what is missing and point them to provider_manifest.json for details."
|
||||
"Provider settings need configuring. Tell the user what is missing"
|
||||
" and point them to provider_manifest.json for details."
|
||||
)
|
||||
report_path = dropbox / "init_report.json"
|
||||
report_path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
|
||||
@@ -685,10 +741,24 @@ def _preflight_check() -> str | None:
|
||||
"This directory is an agent branch (has .trinity/passport.json).\n"
|
||||
"Agents are managed by 'drone @spawn', not 'aipass init'."
|
||||
)
|
||||
# Block if inside an existing AIPass project (registry above us)
|
||||
# Block if inside an existing AIPass project (registry above us).
|
||||
# Walking up to the filesystem root can hit ancestors that can't be
|
||||
# enumerated or stat'd — e.g. locked Windows system entries at the drive
|
||||
# root (pagefile.sys) raise OSError. Skip those rather than crash; on
|
||||
# POSIX everything up to / is readable so behaviour is unchanged there.
|
||||
for parent in [cwd] + list(cwd.parents):
|
||||
for f in parent.iterdir():
|
||||
if f.is_file() and f.name.endswith("_REGISTRY.json"):
|
||||
try:
|
||||
entries = list(parent.iterdir())
|
||||
except OSError as exc:
|
||||
logger.info("[init_flow] skipping unreadable ancestor %s: %s", parent, exc)
|
||||
entries = []
|
||||
for f in entries:
|
||||
try:
|
||||
is_registry = f.is_file() and f.name.endswith("_REGISTRY.json")
|
||||
except OSError as exc:
|
||||
logger.info("[init_flow] skipping unstattable entry %s: %s", f, exc)
|
||||
continue
|
||||
if is_registry:
|
||||
return (
|
||||
f"Already inside an AIPass project (found {f.name} at {parent}).\n"
|
||||
"Use 'aipass init update' to upgrade an existing project."
|
||||
@@ -752,7 +822,7 @@ def run_init(
|
||||
lambda: stage_11_handoff(
|
||||
accumulated.get("cli", "claude"),
|
||||
accumulated.get("flag_variant", "default"),
|
||||
accumulated.get("agent_path", "src/my-agent"),
|
||||
accumulated.get("agent_path", "src/my_agent"),
|
||||
non_interactive,
|
||||
dry_run=dry_run,
|
||||
accumulated=accumulated,
|
||||
@@ -874,12 +944,14 @@ def _handle_init_update(args: list[str]) -> int:
|
||||
console.print(f" ({len(current)} already up to date)")
|
||||
# Heal registry: prune stale entries (e.g. cross-project ../paths)
|
||||
try:
|
||||
from aipass.spawn.apps.modules.sync_registry import sync_registry
|
||||
|
||||
sync_result = sync_registry(fix=True)
|
||||
pruned = sync_result.get("stale", [])
|
||||
if pruned:
|
||||
console.print(f" [green]Registry healed:[/green] removed {len(pruned)} stale entry(ies)")
|
||||
sync_proc = subprocess.run(
|
||||
["drone", "@spawn", "sync-registry", "--fix"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if sync_proc.returncode == 0:
|
||||
console.print(" [green]Registry synced.[/green]")
|
||||
except Exception as sync_exc:
|
||||
logger.warning("[init_flow] registry sync during update skipped: %s", sync_exc)
|
||||
|
||||
@@ -917,13 +989,17 @@ def handle_command(command: str, args: list[str]) -> bool:
|
||||
return False
|
||||
|
||||
if not args:
|
||||
print_introspection()
|
||||
print_help()
|
||||
return True
|
||||
|
||||
if args[0] in ("--help", "-h", "help"):
|
||||
print_help()
|
||||
return True
|
||||
|
||||
if args[0] == "--info":
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
if args[0] == "agent":
|
||||
sys.exit(_handle_init_agent(args[1:]))
|
||||
return True
|
||||
|
||||
@@ -141,6 +141,10 @@ def handle_command(command: str, args: list[str]) -> bool:
|
||||
print_help()
|
||||
return True
|
||||
|
||||
if args[0] == "--info":
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
if args[0] == "set":
|
||||
if len(args) < 3:
|
||||
error("Usage: aipass profile set <field> <value>")
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Project-specific Python dependencies beyond the base AIPass install.
|
||||
# Add packages here that the aipass branch requires but are not in the root pyproject.toml.
|
||||
# Install with: pip install -r requirements.project.txt
|
||||
@@ -0,0 +1 @@
|
||||
# aipass.aipass.shared — shared leaf utilities (no branch dependencies, loads pre-drone)
|
||||
@@ -0,0 +1,303 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: json_handler.py
|
||||
# Description: Shared JSON handler with injectable storage directory
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-06-06
|
||||
# Modified: 2026-06-10
|
||||
# =============================================
|
||||
|
||||
"""Shared JSON handler — auto-creating, self-healing JSON system.
|
||||
|
||||
Dependency-free: uses only stdlib. Importable before drone/prax exist.
|
||||
|
||||
Each branch creates a JsonHandler instance with its own json_dir.
|
||||
Contract: save_json raises ValueError on validation failure.
|
||||
"""
|
||||
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_JSON_TYPES: tuple[str, ...] = ("config", "data", "log")
|
||||
|
||||
|
||||
class JsonHandler:
|
||||
"""JSON file handler with injectable storage directory.
|
||||
|
||||
Provides JSON I/O utilities, validation, and operation logging
|
||||
for the three-JSON system (config, data, log).
|
||||
|
||||
Args:
|
||||
json_dir: Directory for module JSON files (config/data/log).
|
||||
"""
|
||||
|
||||
MAX_LOG_ENTRIES = 100
|
||||
|
||||
def __init__(self, json_dir: Path):
|
||||
self._json_dir = Path(json_dir)
|
||||
|
||||
@staticmethod
|
||||
def read_json(file_path: Path) -> Optional[dict]:
|
||||
"""Read and parse a JSON file.
|
||||
|
||||
Args:
|
||||
file_path: Path to the JSON file.
|
||||
|
||||
Returns:
|
||||
Parsed dict, or None on failure.
|
||||
"""
|
||||
try:
|
||||
return json.loads(Path(file_path).read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, FileNotFoundError) as e:
|
||||
logger.warning("Failed to read JSON from %s: %s", file_path, e)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def write_json(file_path: Path, data: Any, indent: int = 2) -> bool:
|
||||
"""Write data to a JSON file atomically (temp file + os.replace).
|
||||
|
||||
Args:
|
||||
file_path: Target path.
|
||||
data: JSON-serializable data.
|
||||
indent: JSON indentation level.
|
||||
|
||||
Returns:
|
||||
True on success, False on OS error.
|
||||
"""
|
||||
file_path = Path(file_path)
|
||||
try:
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
content = json.dumps(data, indent=indent) + "\n"
|
||||
fd, tmp_path = tempfile.mkstemp(dir=file_path.parent, suffix=".tmp")
|
||||
closed = False
|
||||
try:
|
||||
os.write(fd, content.encode("utf-8"))
|
||||
os.fsync(fd)
|
||||
os.close(fd)
|
||||
closed = True
|
||||
os.replace(tmp_path, file_path)
|
||||
except BaseException:
|
||||
if not closed:
|
||||
os.close(fd)
|
||||
if os.path.exists(tmp_path):
|
||||
os.unlink(tmp_path)
|
||||
raise
|
||||
return True
|
||||
except OSError as e:
|
||||
logger.error("Failed to write JSON to %s: %s", file_path, e)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def validate_json_structure(data: Any, json_type: str) -> bool:
|
||||
"""Validate that data matches the expected shape for json_type.
|
||||
|
||||
Args:
|
||||
data: Parsed JSON data to validate.
|
||||
json_type: One of "config", "data", "log".
|
||||
|
||||
Returns:
|
||||
True when the structure is valid, False otherwise.
|
||||
"""
|
||||
if json_type == "config":
|
||||
if not isinstance(data, dict):
|
||||
return False
|
||||
return all(key in data for key in ("module_name", "version", "config"))
|
||||
if json_type == "data":
|
||||
if not isinstance(data, dict):
|
||||
return False
|
||||
return all(key in data for key in ("created", "last_updated"))
|
||||
if json_type == "log":
|
||||
return isinstance(data, list)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _create_default(json_type: str, module_name: str) -> Any:
|
||||
"""Return default content for a given JSON type.
|
||||
|
||||
Args:
|
||||
json_type: One of "config", "data", "log".
|
||||
module_name: Logical module name.
|
||||
|
||||
Returns:
|
||||
Default data structure.
|
||||
|
||||
Raises:
|
||||
ValueError: For unknown json_type.
|
||||
"""
|
||||
today = datetime.now().date().isoformat()
|
||||
if json_type == "config":
|
||||
return {
|
||||
"module_name": module_name,
|
||||
"version": "1.0.0",
|
||||
"config": {
|
||||
"max_log_entries": JsonHandler.MAX_LOG_ENTRIES,
|
||||
},
|
||||
"created": today,
|
||||
"last_updated": today,
|
||||
}
|
||||
if json_type == "data":
|
||||
return {
|
||||
"created": today,
|
||||
"last_updated": today,
|
||||
}
|
||||
if json_type == "log":
|
||||
return []
|
||||
raise ValueError(f"Unknown json_type: {json_type!r}")
|
||||
|
||||
def get_json_path(self, module_name: str, json_type: str) -> Path:
|
||||
"""Return the filesystem path for a module's JSON file.
|
||||
|
||||
Args:
|
||||
module_name: Logical module name.
|
||||
json_type: One of "config", "data", "log".
|
||||
|
||||
Returns:
|
||||
Absolute Path to the JSON file.
|
||||
"""
|
||||
return self._json_dir / f"{module_name}_{json_type}.json"
|
||||
|
||||
def ensure_json_exists(self, module_name: str, json_type: str) -> bool:
|
||||
"""Ensure a single JSON file exists; create with defaults if missing.
|
||||
|
||||
If the file exists but fails validation it is regenerated.
|
||||
|
||||
Args:
|
||||
module_name: Logical module name.
|
||||
json_type: One of "config", "data", "log".
|
||||
|
||||
Returns:
|
||||
True after the file is confirmed present and valid.
|
||||
"""
|
||||
self._json_dir.mkdir(parents=True, exist_ok=True)
|
||||
json_path = self.get_json_path(module_name, json_type)
|
||||
|
||||
if json_path.exists():
|
||||
try:
|
||||
if json_path.stat().st_size == 0:
|
||||
logger.warning("ensure_json_exists: empty file at %s, regenerating", json_path)
|
||||
else:
|
||||
data = json.loads(json_path.read_text(encoding="utf-8"))
|
||||
if self.validate_json_structure(data, json_type):
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning("ensure_json_exists: failed to read %s, regenerating: %s", json_path, exc)
|
||||
|
||||
default = self._create_default(json_type, module_name)
|
||||
self.write_json(json_path, default)
|
||||
return True
|
||||
|
||||
def ensure_module_jsons(self, module_name: str) -> bool:
|
||||
"""Ensure all three JSON files (config, data, log) exist for a module.
|
||||
|
||||
Args:
|
||||
module_name: Logical module name.
|
||||
|
||||
Returns:
|
||||
True when all files are present and valid.
|
||||
"""
|
||||
for json_type in _JSON_TYPES:
|
||||
self.ensure_json_exists(module_name, json_type)
|
||||
return True
|
||||
|
||||
def load_json(self, module_name: str, json_type: str) -> Any | None:
|
||||
"""Load a module's JSON file, auto-creating it if missing.
|
||||
|
||||
Args:
|
||||
module_name: Logical module name.
|
||||
json_type: One of "config", "data", "log".
|
||||
|
||||
Returns:
|
||||
Parsed JSON data, or None on failure.
|
||||
"""
|
||||
if not self.ensure_json_exists(module_name, json_type):
|
||||
return None
|
||||
|
||||
json_path = self.get_json_path(module_name, json_type)
|
||||
try:
|
||||
return json.loads(json_path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
logger.warning("load_json: failed to read %s: %s", json_path, exc)
|
||||
return self._create_default(json_type, module_name)
|
||||
|
||||
def save_json(self, module_name: str, json_type: str, data: Any) -> bool:
|
||||
"""Write data to a module's JSON file after validation.
|
||||
|
||||
For "data" type files the last_updated field is refreshed automatically.
|
||||
|
||||
Args:
|
||||
module_name: Logical module name.
|
||||
json_type: One of "config", "data", "log".
|
||||
data: The data structure to persist.
|
||||
|
||||
Returns:
|
||||
True on success.
|
||||
|
||||
Raises:
|
||||
ValueError: When data fails structure validation.
|
||||
"""
|
||||
if not self.validate_json_structure(data, json_type):
|
||||
raise ValueError(f"Invalid structure for {json_type} JSON")
|
||||
|
||||
if json_type == "data" and isinstance(data, dict):
|
||||
data["last_updated"] = datetime.now().date().isoformat()
|
||||
|
||||
json_path = self.get_json_path(module_name, json_type)
|
||||
return self.write_json(json_path, data)
|
||||
|
||||
def log_operation(self, operation: str, data: Dict[str, Any] | None = None, module_name: str | None = None) -> bool:
|
||||
"""Add entry to module operation log with automatic rotation.
|
||||
|
||||
Auto-detects calling module if module_name not provided.
|
||||
|
||||
Args:
|
||||
operation: Operation name to log.
|
||||
data: Optional data dict.
|
||||
module_name: Optional module name (auto-detected if not provided).
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise.
|
||||
"""
|
||||
if module_name is None:
|
||||
module_name = _get_caller_module_name()
|
||||
|
||||
try:
|
||||
self.ensure_module_jsons(module_name)
|
||||
|
||||
log = self.load_json(module_name, "log")
|
||||
if log is None:
|
||||
log = []
|
||||
|
||||
entry: Dict[str, Any] = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"operation": operation,
|
||||
}
|
||||
if data:
|
||||
entry["data"] = data
|
||||
|
||||
log.append(entry)
|
||||
|
||||
if len(log) > self.MAX_LOG_ENTRIES:
|
||||
log = log[-self.MAX_LOG_ENTRIES :]
|
||||
|
||||
return self.save_json(module_name, "log", log)
|
||||
except Exception as exc:
|
||||
logger.warning("log_operation: failed for %s/%s: %s", module_name, operation, exc)
|
||||
return False
|
||||
|
||||
|
||||
def _get_caller_module_name() -> str:
|
||||
"""Auto-detect calling module name from call stack."""
|
||||
stack = inspect.stack()
|
||||
if len(stack) > 2:
|
||||
caller_path = Path(stack[2].filename)
|
||||
module_name = caller_path.stem
|
||||
if module_name and not module_name.startswith("_"):
|
||||
return module_name
|
||||
return "unknown"
|
||||
@@ -0,0 +1,115 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: json_ops.py
|
||||
# Description: Shared JSON operations — deep merge and backup
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-06-06
|
||||
# Modified: 2026-06-10
|
||||
# =============================================
|
||||
|
||||
"""Shared JSON operations — deep merge and backup utilities.
|
||||
|
||||
Dependency-free: uses only stdlib. Importable before drone/prax exist.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def deep_merge(template_data: Any, existing_data: Any) -> Any:
|
||||
"""Recursively merge template structure with existing data.
|
||||
|
||||
Merge strategy:
|
||||
- Both dicts: merge keys. Template defines structure, existing fills values.
|
||||
- Template has key that existing doesn't: add from template (default).
|
||||
- Existing has key that template doesn't: KEEP existing key (don't prune).
|
||||
- Both lists: keep existing list (don't overwrite user data).
|
||||
- Scalar values: keep existing value (don't overwrite).
|
||||
- If existing is None/empty but template has value: use template value.
|
||||
|
||||
Args:
|
||||
template_data: Template structure (provides fields and defaults).
|
||||
existing_data: Existing data (provides values to preserve).
|
||||
|
||||
Returns:
|
||||
Merged result combining template structure with existing values.
|
||||
"""
|
||||
if existing_data is None:
|
||||
return template_data
|
||||
|
||||
if template_data is None:
|
||||
return existing_data
|
||||
|
||||
if isinstance(template_data, dict) and isinstance(existing_data, dict):
|
||||
result = {}
|
||||
|
||||
for key in template_data:
|
||||
if key in existing_data:
|
||||
result[key] = deep_merge(template_data[key], existing_data[key])
|
||||
else:
|
||||
result[key] = template_data[key]
|
||||
|
||||
for key in existing_data:
|
||||
if key not in result:
|
||||
result[key] = existing_data[key]
|
||||
|
||||
return result
|
||||
|
||||
if isinstance(template_data, list) and isinstance(existing_data, list):
|
||||
if len(existing_data) > 0:
|
||||
return existing_data
|
||||
if len(template_data) > 0:
|
||||
return template_data
|
||||
return []
|
||||
|
||||
if existing_data is not None:
|
||||
if isinstance(existing_data, str) and existing_data == "" and template_data:
|
||||
return template_data
|
||||
return existing_data
|
||||
|
||||
return template_data
|
||||
|
||||
|
||||
def backup_json(file_path: Path, backup_dir: Path | None = None) -> Path:
|
||||
"""Create a timestamped backup of a JSON file.
|
||||
|
||||
By default the backup is placed in a ``.recovery/`` directory alongside the
|
||||
file. Callers can override with ``backup_dir`` to consolidate backups
|
||||
elsewhere (e.g. ``.spawn/.recovery/``).
|
||||
|
||||
Args:
|
||||
file_path: Path to the JSON file to back up.
|
||||
backup_dir: Optional override for the backup destination directory.
|
||||
Defaults to ``file_path.parent / ".recovery"``.
|
||||
|
||||
Returns:
|
||||
Path to the backup file.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If the source file doesn't exist.
|
||||
IOError: If the backup copy fails.
|
||||
"""
|
||||
file_path = Path(file_path)
|
||||
|
||||
if not file_path.exists():
|
||||
raise FileNotFoundError(f"Cannot backup — file not found: {file_path}")
|
||||
|
||||
if backup_dir is None:
|
||||
backup_dir = file_path.parent / ".recovery"
|
||||
backup_dir = Path(backup_dir)
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
backup_name = f"{file_path.name}.{timestamp}.backup"
|
||||
backup_path = backup_dir / backup_name
|
||||
|
||||
try:
|
||||
shutil.copy2(file_path, backup_path)
|
||||
return backup_path
|
||||
except (IOError, OSError) as exc:
|
||||
logger.error("Backup failed for %s: %s", file_path.name, exc)
|
||||
raise
|
||||
@@ -0,0 +1,67 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: registry_discovery.py
|
||||
# Description: Shared registry file discovery (walk-up search)
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-06-06
|
||||
# Modified: 2026-06-10
|
||||
# =============================================
|
||||
|
||||
"""Registry discovery — find *_REGISTRY.json by walking up the directory tree.
|
||||
|
||||
Dependency-free: uses only stdlib. Importable before drone/prax exist.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _glob_registry(directory):
|
||||
"""Find *_REGISTRY.json in a single directory.
|
||||
|
||||
Args:
|
||||
directory: Path to search in.
|
||||
|
||||
Returns:
|
||||
Path to the registry file, or None if not found.
|
||||
"""
|
||||
matches = sorted(directory.glob("*_REGISTRY.json"))
|
||||
return matches[0] if matches else None
|
||||
|
||||
|
||||
def find_registry(start_path=None, package_root=None):
|
||||
"""Find *_REGISTRY.json — walks up from start_path/cwd, then package_root.
|
||||
|
||||
The first *_REGISTRY.json found while walking up IS the project boundary.
|
||||
If multiple exist in the same directory, picks the first alphabetically.
|
||||
|
||||
Priority:
|
||||
1. AIPASS_REGISTRY environment variable
|
||||
2. Walk up from start_path/cwd — first dir containing *_REGISTRY.json
|
||||
3. Walk up from package_root (caller's __file__ location) — fallback
|
||||
4. Last resort: cwd / AIPASS_REGISTRY.json (backwards compat)
|
||||
|
||||
Args:
|
||||
start_path: Directory to start searching from (default: cwd).
|
||||
package_root: Optional fallback directory for package-relative search.
|
||||
|
||||
Returns:
|
||||
Path to *_REGISTRY.json.
|
||||
"""
|
||||
env_path = os.environ.get("AIPASS_REGISTRY")
|
||||
if env_path:
|
||||
return Path(env_path)
|
||||
|
||||
current = Path(start_path).resolve() if start_path else Path.cwd()
|
||||
for parent in [current] + list(current.parents):
|
||||
found = _glob_registry(parent)
|
||||
if found:
|
||||
return found
|
||||
|
||||
if package_root:
|
||||
pkg_dir = Path(package_root).resolve()
|
||||
for parent in [pkg_dir] + list(pkg_dir.parents):
|
||||
found = _glob_registry(parent)
|
||||
if found:
|
||||
return found
|
||||
|
||||
return Path.cwd() / "AIPASS_REGISTRY.json"
|
||||
@@ -102,7 +102,6 @@ def test_init_project_creates_all_expected_files(tmp_path):
|
||||
target / "CLAUDE.md",
|
||||
target / "AGENTS.md",
|
||||
target / "README.md",
|
||||
target / "STATUS.local.md",
|
||||
target / ".gitignore",
|
||||
target / ".claude" / "settings.json",
|
||||
target / ".claude" / "commands" / "prep.md",
|
||||
@@ -127,7 +126,7 @@ def test_init_project_creates_all_expected_files(tmp_path):
|
||||
created_basenames = [Path(f).name for f in result["created_files"]]
|
||||
for f in expected_files:
|
||||
assert f.name in created_basenames or f.exists(), f"Expected {f.name} in created_files"
|
||||
assert len(result["created_files"]) >= 12
|
||||
assert len(result["created_files"]) >= 11
|
||||
|
||||
|
||||
def test_init_project_return_dict_structure(tmp_path):
|
||||
@@ -235,15 +234,15 @@ def test_init_project_raises_on_empty_name(tmp_path):
|
||||
|
||||
|
||||
def test_init_project_agents_md_content(tmp_path):
|
||||
"""AGENTS.md is copied from AIPass source of truth."""
|
||||
"""AGENTS.md contains project-specific content from generator."""
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
|
||||
init_project(target, project_name="alpha")
|
||||
|
||||
content = (target / "AGENTS.md").read_text(encoding="utf-8")
|
||||
assert "# AIPass" in content
|
||||
assert "Multi-agent framework" in content
|
||||
assert "# ALPHA" in content
|
||||
assert "AIPass" in content
|
||||
|
||||
|
||||
def test_init_project_gitignore_content(tmp_path):
|
||||
@@ -325,7 +324,7 @@ def test_init_project_auto_creates_target_dir(tmp_path):
|
||||
|
||||
assert target.is_dir()
|
||||
assert result["project_name"] == "NESTED"
|
||||
assert len(result["created_files"]) >= 12
|
||||
assert len(result["created_files"]) >= 11
|
||||
|
||||
|
||||
def test_init_project_defaults_name_from_directory(tmp_path):
|
||||
@@ -364,7 +363,6 @@ def test_init_project_skips_existing_optional_files(tmp_path):
|
||||
(target / "CLAUDE.md").write_text("# Custom CLAUDE\n", encoding="utf-8")
|
||||
(target / "AGENTS.md").write_text("# Custom AGENTS\n", encoding="utf-8")
|
||||
(target / "README.md").write_text("# Custom README\n", encoding="utf-8")
|
||||
(target / "STATUS.local.md").write_text("# Custom status\n", encoding="utf-8")
|
||||
(target / ".gitignore").write_text("# Custom\n", encoding="utf-8")
|
||||
|
||||
claude_dir = target / ".claude"
|
||||
@@ -413,16 +411,15 @@ def test_init_project_returns_dict(tmp_path):
|
||||
|
||||
|
||||
def test_init_project_agents_md_no_trinity(tmp_path):
|
||||
"""AGENTS.md is copied from AIPass source (may reference .trinity/ as part of agent docs)."""
|
||||
"""AGENTS.md references .trinity/ as part of startup protocol docs."""
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
|
||||
init_project(target, project_name="keep")
|
||||
|
||||
content = (target / "AGENTS.md").read_text(encoding="utf-8")
|
||||
# Source file legitimately references .trinity/ as part of startup protocol docs
|
||||
assert "# AIPass" in content
|
||||
assert "Multi-agent framework" in content
|
||||
assert "# KEEP" in content
|
||||
assert ".trinity/" in content
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -518,7 +515,6 @@ def test_update_project_never_touches_user_owned_files(tmp_path):
|
||||
|
||||
# Modify user-owned files
|
||||
(target / "README.md").write_text("# My custom README\n", encoding="utf-8")
|
||||
(target / "STATUS.local.md").write_text("# Custom status\n", encoding="utf-8")
|
||||
(target / ".gitignore").write_text("# custom\n", encoding="utf-8")
|
||||
|
||||
result = update_project(target)
|
||||
@@ -526,12 +522,10 @@ def test_update_project_never_touches_user_owned_files(tmp_path):
|
||||
skipped = result["skipped_files"]
|
||||
assert any("REGISTRY" in s for s in skipped)
|
||||
assert any("README.md" in s for s in skipped)
|
||||
assert any("STATUS.local.md" in s for s in skipped)
|
||||
assert any(".gitignore" in s for s in skipped)
|
||||
|
||||
# User customisations are preserved
|
||||
assert (target / "README.md").read_text(encoding="utf-8") == "# My custom README\n"
|
||||
assert (target / "STATUS.local.md").read_text(encoding="utf-8") == "# Custom status\n"
|
||||
|
||||
|
||||
def test_update_project_creates_missing_managed_dirs(tmp_path):
|
||||
@@ -556,15 +550,15 @@ def test_update_project_creates_missing_managed_dirs(tmp_path):
|
||||
|
||||
|
||||
def test_update_project_skipped_files_count(tmp_path):
|
||||
"""update_project skips 4 user-owned files + existing mailbox = 5 total."""
|
||||
"""update_project skips 3 user-owned files."""
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
init_project(target, project_name="count")
|
||||
|
||||
result = update_project(target)
|
||||
|
||||
# 4 user-owned (registry, README, STATUS, .gitignore)
|
||||
assert len(result["skipped_files"]) == 4
|
||||
# 3 user-owned (registry, README, .gitignore)
|
||||
assert len(result["skipped_files"]) == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -612,3 +612,133 @@ class TestHooksJsonCheck:
|
||||
assert len(hooks_results) == 1
|
||||
assert hooks_results[0].glyph == GLYPH_WARN
|
||||
assert "init update" in hooks_results[0].remediation
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TestReconcileStaleDeny
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestReconcileStaleDeny:
|
||||
"""Tests for stale rm deny rule migration (DPLAN-0192 Phase 2)."""
|
||||
|
||||
def test_no_settings_file_returns_empty(self, tmp_path) -> None:
|
||||
"""Missing settings.json returns no results."""
|
||||
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
|
||||
|
||||
with patch("aipass.aipass.apps.modules.doctor_wire.Path.home", return_value=tmp_path):
|
||||
results = reconcile_stale_deny(fix=False)
|
||||
assert results == []
|
||||
|
||||
def test_no_stale_rules_returns_pass(self, tmp_path) -> None:
|
||||
"""Settings with no stale rm rules returns PASS."""
|
||||
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
|
||||
|
||||
settings = tmp_path / ".claude" / "settings.json"
|
||||
settings.parent.mkdir(parents=True)
|
||||
settings.write_text(
|
||||
json.dumps({"permissions": {"deny": ["Bash(git push --force*)", "Bash(git reset --hard*)"]}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with patch("aipass.aipass.apps.modules.doctor_wire.Path.home", return_value=tmp_path):
|
||||
results = reconcile_stale_deny(fix=False)
|
||||
assert len(results) == 1
|
||||
assert results[0][1] == GLYPH_PASS
|
||||
assert "no stale" in results[0][2]
|
||||
|
||||
def test_stale_rules_detected_without_fix(self, tmp_path) -> None:
|
||||
"""Stale rm rules present returns WARN when fix=False."""
|
||||
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
|
||||
|
||||
settings = tmp_path / ".claude" / "settings.json"
|
||||
settings.parent.mkdir(parents=True)
|
||||
settings.write_text(
|
||||
json.dumps({"permissions": {"deny": ["Bash(rm -rf*)", "Bash(git push --force*)", "Bash(rm -r *)"]}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with patch("aipass.aipass.apps.modules.doctor_wire.Path.home", return_value=tmp_path):
|
||||
results = reconcile_stale_deny(fix=False)
|
||||
assert len(results) == 1
|
||||
assert results[0][1] == GLYPH_WARN
|
||||
assert "rm -rf" in results[0][2]
|
||||
assert "rm -r " in results[0][2]
|
||||
|
||||
def test_fix_removes_stale_rules(self, tmp_path) -> None:
|
||||
"""fix=True removes stale rules and preserves others."""
|
||||
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
|
||||
|
||||
settings = tmp_path / ".claude" / "settings.json"
|
||||
settings.parent.mkdir(parents=True)
|
||||
original = {
|
||||
"permissions": {"deny": ["Bash(rm -rf*)", "Bash(git push --force*)", "Bash(rm -r *)"]},
|
||||
"env": {"AIPASS_HOME": "/test"},
|
||||
}
|
||||
settings.write_text(json.dumps(original), encoding="utf-8")
|
||||
with patch("aipass.aipass.apps.modules.doctor_wire.Path.home", return_value=tmp_path):
|
||||
results = reconcile_stale_deny(fix=True)
|
||||
assert len(results) == 1
|
||||
assert results[0][1] == GLYPH_PASS
|
||||
assert "removed" in results[0][2]
|
||||
updated = json.loads(settings.read_text(encoding="utf-8"))
|
||||
assert "Bash(rm -rf*)" not in updated["permissions"]["deny"]
|
||||
assert "Bash(rm -r *)" not in updated["permissions"]["deny"]
|
||||
assert "Bash(git push --force*)" in updated["permissions"]["deny"]
|
||||
assert updated["env"]["AIPASS_HOME"] == "/test"
|
||||
|
||||
def test_fix_single_stale_rule(self, tmp_path) -> None:
|
||||
"""fix=True works when only one of two stale rules is present."""
|
||||
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
|
||||
|
||||
settings = tmp_path / ".claude" / "settings.json"
|
||||
settings.parent.mkdir(parents=True)
|
||||
settings.write_text(
|
||||
json.dumps({"permissions": {"deny": ["Bash(rm -rf*)", "Bash(git reset --hard*)"]}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with patch("aipass.aipass.apps.modules.doctor_wire.Path.home", return_value=tmp_path):
|
||||
results = reconcile_stale_deny(fix=True)
|
||||
assert len(results) == 1
|
||||
assert results[0][1] == GLYPH_PASS
|
||||
updated = json.loads(settings.read_text(encoding="utf-8"))
|
||||
assert updated["permissions"]["deny"] == ["Bash(git reset --hard*)"]
|
||||
|
||||
def test_fix_idempotent(self, tmp_path) -> None:
|
||||
"""Running fix twice is safe — second run returns PASS with no stale rules."""
|
||||
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
|
||||
|
||||
settings = tmp_path / ".claude" / "settings.json"
|
||||
settings.parent.mkdir(parents=True)
|
||||
settings.write_text(
|
||||
json.dumps({"permissions": {"deny": ["Bash(rm -rf*)", "Bash(rm -r *)"]}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with patch("aipass.aipass.apps.modules.doctor_wire.Path.home", return_value=tmp_path):
|
||||
reconcile_stale_deny(fix=True)
|
||||
results = reconcile_stale_deny(fix=True)
|
||||
assert len(results) == 1
|
||||
assert results[0][1] == GLYPH_PASS
|
||||
assert "no stale" in results[0][2]
|
||||
|
||||
def test_empty_deny_list_returns_pass(self, tmp_path) -> None:
|
||||
"""Empty deny list returns PASS."""
|
||||
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
|
||||
|
||||
settings = tmp_path / ".claude" / "settings.json"
|
||||
settings.parent.mkdir(parents=True)
|
||||
settings.write_text(json.dumps({"permissions": {"deny": []}}), encoding="utf-8")
|
||||
with patch("aipass.aipass.apps.modules.doctor_wire.Path.home", return_value=tmp_path):
|
||||
results = reconcile_stale_deny(fix=False)
|
||||
assert len(results) == 1
|
||||
assert results[0][1] == GLYPH_PASS
|
||||
|
||||
def test_no_permissions_key_returns_pass(self, tmp_path) -> None:
|
||||
"""Settings without permissions key returns PASS."""
|
||||
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
|
||||
|
||||
settings = tmp_path / ".claude" / "settings.json"
|
||||
settings.parent.mkdir(parents=True)
|
||||
settings.write_text(json.dumps({"env": {"FOO": "bar"}}), encoding="utf-8")
|
||||
with patch("aipass.aipass.apps.modules.doctor_wire.Path.home", return_value=tmp_path):
|
||||
results = reconcile_stale_deny(fix=False)
|
||||
assert len(results) == 1
|
||||
assert results[0][1] == GLYPH_PASS
|
||||
|
||||
@@ -65,8 +65,14 @@ class TestDetectProjectName:
|
||||
|
||||
def test_fallback_to_dirname(self, tmp_path: Path) -> None:
|
||||
"""Falls back to directory name when no registry."""
|
||||
result = detect_project_name(tmp_path)
|
||||
assert result == tmp_path.name.lower()
|
||||
no_reg = tmp_path / "empty_project"
|
||||
no_reg.mkdir()
|
||||
with patch(
|
||||
"aipass.aipass.apps.modules.doctor_fix._discover_registry",
|
||||
return_value=no_reg / "MISSING_REGISTRY.json",
|
||||
):
|
||||
result = detect_project_name(no_reg)
|
||||
assert result == "empty_project"
|
||||
|
||||
def test_registry_name_lowered(self, tmp_path: Path) -> None:
|
||||
"""Registry name is lowercased."""
|
||||
@@ -248,10 +254,13 @@ class TestFormatTextReport:
|
||||
assert "drone @spawn repair @test --relocate a b" in result
|
||||
|
||||
def test_dry_run_hint(self) -> None:
|
||||
"""Report ends with dry-run suggestion."""
|
||||
"""Report shows preview (dry-run default) and explicit --apply hints."""
|
||||
items = [RemediationItem("info", "pyproject", "missing", "fix")]
|
||||
result = format_text_report(items, "myproj")
|
||||
assert "drone @spawn repair @myproj --dry-run" in result
|
||||
# Repair is dry-run by default now: preview form has no flag, apply form is explicit
|
||||
assert "Preview all fixes:" in result
|
||||
assert "drone @spawn repair @myproj" in result
|
||||
assert "drone @spawn repair @myproj --apply" in result
|
||||
|
||||
def test_critical_sorted_first(self) -> None:
|
||||
"""Critical items appear before warning and info."""
|
||||
@@ -364,14 +373,15 @@ class TestDoctorFixHandleCommand:
|
||||
assert handle_command("doctor", []) is False
|
||||
assert handle_command("help", []) is False
|
||||
|
||||
def test_no_args_calls_introspection(self) -> None:
|
||||
"""No args triggers print_introspection."""
|
||||
def test_no_args_shows_usage(self) -> None:
|
||||
"""No args shows usage message (not introspection banner)."""
|
||||
from aipass.aipass.apps.modules.doctor_fix import handle_command
|
||||
|
||||
with patch("aipass.aipass.apps.modules.doctor_fix.print_introspection") as mock:
|
||||
with patch("aipass.aipass.apps.modules.doctor_fix.console") as mock_console:
|
||||
result = handle_command("doctor_fix", [])
|
||||
assert result is True
|
||||
mock.assert_called_once()
|
||||
printed = " ".join(str(c) for c in mock_console.print.call_args_list)
|
||||
assert "aipass doctor --fix" in printed
|
||||
|
||||
def test_info_flag(self) -> None:
|
||||
"""--info triggers print_introspection."""
|
||||
|
||||
@@ -52,20 +52,6 @@ _ENCODING = "utf-8"
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _call_handle_command_no_args():
|
||||
"""Call handle_command('help', []) with json_handler and console mocked."""
|
||||
mock_console = MagicMock()
|
||||
patches = [
|
||||
patch("aipass.aipass.apps.modules.help_chat.json_handler"),
|
||||
patch("aipass.aipass.apps.modules.help_chat.console", mock_console),
|
||||
]
|
||||
with ExitStack() as stack:
|
||||
for p in patches:
|
||||
stack.enter_context(p)
|
||||
result = handle_command("help", [])
|
||||
return result, mock_console
|
||||
|
||||
|
||||
def _call_handle_command_drone_question(readme_content: str, readme_path: Path):
|
||||
"""Call handle_command for 'what does drone do' with file I/O mocked."""
|
||||
patches = [
|
||||
@@ -254,54 +240,57 @@ class TestMatchBranches:
|
||||
|
||||
|
||||
class TestSearchReadme:
|
||||
"""Tests for _search_readme: live file reads, scoring, and error handling."""
|
||||
"""Tests for _search_readme: live file reads via handler, scoring, and error handling."""
|
||||
|
||||
def _mock_lines(self, content):
|
||||
"""Return a patch that makes read_readme_lines return content as lines."""
|
||||
lines = content.splitlines(keepends=True)
|
||||
return patch("aipass.aipass.apps.modules.help_chat.read_readme_lines", return_value=lines)
|
||||
|
||||
def test_returns_matching_lines_with_line_numbers(self):
|
||||
"""Matching lines must be returned as (int, str) tuples."""
|
||||
with patch("builtins.open", mock_open(read_data=_SAMPLE_README)):
|
||||
results = _search_readme(_FAKE_README_PATH, ["drone"])
|
||||
with self._mock_lines(_SAMPLE_README):
|
||||
results = _search_readme("drone", ["drone"])
|
||||
assert len(results) > 0
|
||||
assert all(isinstance(ln, int) for ln, _ in results)
|
||||
|
||||
def test_line_numbers_are_1_indexed(self):
|
||||
"""Line numbers in results must start at 1, not 0."""
|
||||
with patch("builtins.open", mock_open(read_data=_SAMPLE_README)):
|
||||
results = _search_readme(_FAKE_README_PATH, ["drone"])
|
||||
with self._mock_lines(_SAMPLE_README):
|
||||
results = _search_readme("drone", ["drone"])
|
||||
assert all(ln >= 1 for ln, _ in results)
|
||||
|
||||
def test_returns_at_most_5_matches(self):
|
||||
"""Result list must contain no more than 5 entries."""
|
||||
content = "\n".join([f"drone line {i}" for i in range(10)])
|
||||
with patch("builtins.open", mock_open(read_data=content)):
|
||||
results = _search_readme(_FAKE_README_PATH, ["drone"])
|
||||
with self._mock_lines(content):
|
||||
results = _search_readme("drone", ["drone"])
|
||||
assert len(results) <= 5
|
||||
|
||||
def test_no_keyword_match_returns_empty(self):
|
||||
"""Keyword with no hits in the README must return an empty list."""
|
||||
with patch("builtins.open", mock_open(read_data=_SAMPLE_README)):
|
||||
results = _search_readme(_FAKE_README_PATH, ["xyzzy999"])
|
||||
with self._mock_lines(_SAMPLE_README):
|
||||
results = _search_readme("drone", ["xyzzy999"])
|
||||
assert results == []
|
||||
|
||||
def test_oserror_returns_empty_and_logs_warning(self):
|
||||
"""OSError on open must return [] and call logger.warning exactly once."""
|
||||
with patch("builtins.open", side_effect=OSError("not found")):
|
||||
def test_handler_returns_none_returns_empty_and_logs(self):
|
||||
"""None from handler must return [] and call logger.warning."""
|
||||
with patch("aipass.aipass.apps.modules.help_chat.read_readme_lines", return_value=None):
|
||||
with patch("aipass.aipass.apps.modules.help_chat.logger") as mock_logger:
|
||||
results = _search_readme(_FAKE_README_PATH, ["drone"])
|
||||
results = _search_readme("nonexistent", ["drone"])
|
||||
assert results == []
|
||||
mock_logger.warning.assert_called_once()
|
||||
|
||||
def test_matching_is_case_insensitive(self):
|
||||
"""Uppercase keyword in README must still match a lowercase query keyword."""
|
||||
content = "DRONE does routing\n"
|
||||
with patch("builtins.open", mock_open(read_data=content)):
|
||||
results = _search_readme(_FAKE_README_PATH, ["drone"])
|
||||
with self._mock_lines("DRONE does routing\n"):
|
||||
results = _search_readme("drone", ["drone"])
|
||||
assert len(results) == 1
|
||||
|
||||
def test_higher_scoring_lines_ranked_first(self):
|
||||
"""Lines matching more keywords must appear before lines matching fewer."""
|
||||
content = "drone flow spawn\ndrone only\nflow only\n"
|
||||
with patch("builtins.open", mock_open(read_data=content)):
|
||||
results = _search_readme(_FAKE_README_PATH, ["drone", "flow"])
|
||||
with self._mock_lines("drone flow spawn\ndrone only\nflow only\n"):
|
||||
results = _search_readme("drone", ["drone", "flow"])
|
||||
first_text = results[0][1]
|
||||
assert "drone" in first_text and "flow" in first_text
|
||||
|
||||
@@ -367,11 +356,21 @@ class TestHandleCommand:
|
||||
"""COMMAND module constant must equal the string 'help'."""
|
||||
assert COMMAND == "help"
|
||||
|
||||
def test_no_args_returns_true_and_calls_console(self):
|
||||
"""handle_command('help', []) must return True and print usage via console."""
|
||||
result, mock_console = _call_handle_command_no_args()
|
||||
def test_no_args_returns_true_and_shows_help(self):
|
||||
"""handle_command('help', []) must return True and print usage help."""
|
||||
with patch("aipass.aipass.apps.modules.help_chat.print_help") as mock_help:
|
||||
with patch("aipass.aipass.apps.modules.help_chat.json_handler"):
|
||||
result = handle_command("help", [])
|
||||
assert result is True
|
||||
mock_console.print.assert_called()
|
||||
mock_help.assert_called_once()
|
||||
|
||||
def test_info_flag_calls_introspection(self):
|
||||
"""--info flag triggers print_introspection."""
|
||||
with patch("aipass.aipass.apps.modules.help_chat.print_introspection") as mock_intro:
|
||||
with patch("aipass.aipass.apps.modules.help_chat.json_handler"):
|
||||
result = handle_command("help", ["--info"])
|
||||
assert result is True
|
||||
mock_intro.assert_called_once()
|
||||
|
||||
def test_valid_drone_question_returns_true(self):
|
||||
"""A well-formed question about drone must return True."""
|
||||
|
||||
@@ -19,6 +19,7 @@ from aipass.aipass.apps.modules.init_flow import (
|
||||
TOTAL_STAGES,
|
||||
_get_last_completed_stage,
|
||||
_get_setup_progress,
|
||||
_handle_init_update,
|
||||
_save_stage,
|
||||
handle_command,
|
||||
print_help,
|
||||
@@ -198,11 +199,18 @@ class TestHandleCommand:
|
||||
assert handle_command("doctor", []) is False
|
||||
assert handle_command("profile", ["set", "name", "X"]) is False
|
||||
|
||||
def test_no_args_shows_introspection(self, tmp_local_json) -> None:
|
||||
"""'init' with no args calls print_introspection."""
|
||||
with patch("aipass.aipass.apps.modules.init_flow.print_introspection") as mock_intro:
|
||||
def test_no_args_shows_help(self, tmp_local_json) -> None:
|
||||
"""'init' with no args calls print_help (not introspection banner)."""
|
||||
with patch("aipass.aipass.apps.modules.init_flow.print_help") as mock_help:
|
||||
result = handle_command("init", [])
|
||||
assert result is True
|
||||
mock_help.assert_called_once()
|
||||
|
||||
def test_info_flag_calls_introspection(self, tmp_local_json) -> None:
|
||||
"""--info flag calls print_introspection."""
|
||||
with patch("aipass.aipass.apps.modules.init_flow.print_introspection") as mock_intro:
|
||||
result = handle_command("init", ["--info"])
|
||||
assert result is True
|
||||
mock_intro.assert_called_once()
|
||||
|
||||
def test_help_flag(self) -> None:
|
||||
@@ -480,6 +488,66 @@ class TestStages:
|
||||
result = stage_6_tool_choice(non_interactive=True, cli_override="codex")
|
||||
assert result["cli"] == "codex"
|
||||
|
||||
def test_stage_6_claude_present_no_prompt(self, tmp_local_json) -> None:
|
||||
"""When claude is on PATH, no install prompt is shown."""
|
||||
mock_profile_mod = MagicMock()
|
||||
mock_profile_mod.get_user_profile.return_value = {}
|
||||
with patch(f"{_MOD}.console"):
|
||||
with patch(f"{_MOD}.shutil.which", return_value="/usr/bin/claude"):
|
||||
with patch.dict("sys.modules", {"aipass.aipass.apps.modules.profile": mock_profile_mod}):
|
||||
with patch(f"{_MOD}._handle_missing_claude") as mock_handle:
|
||||
result = stage_6_tool_choice(non_interactive=True)
|
||||
mock_handle.assert_not_called()
|
||||
assert result["cli"] == "claude"
|
||||
|
||||
@patch(f"{_MOD}._choose", return_value="default")
|
||||
@patch(f"{_MOD}._install_claude_code", return_value=True)
|
||||
@patch(f"{_MOD}._prompt", return_value="Y")
|
||||
@patch(f"{_MOD}.shutil.which", return_value=None)
|
||||
@patch(f"{_MOD}.console")
|
||||
def test_stage_6_claude_missing_interactive_yes(
|
||||
self, _con, _which, _prompt, mock_install, _choose, tmp_local_json
|
||||
) -> None:
|
||||
"""Missing claude + interactive + yes → installer invoked."""
|
||||
mock_profile_mod = MagicMock()
|
||||
mock_profile_mod.get_user_profile.return_value = {}
|
||||
with patch.dict("sys.modules", {"aipass.aipass.apps.modules.profile": mock_profile_mod}):
|
||||
result = stage_6_tool_choice(non_interactive=False, cli_override="claude")
|
||||
mock_install.assert_called_once()
|
||||
assert result["cli"] == "claude"
|
||||
|
||||
@patch(f"{_MOD}._choose", return_value="default")
|
||||
@patch(f"{_MOD}._install_claude_code")
|
||||
@patch(f"{_MOD}._prompt", return_value="n")
|
||||
@patch(f"{_MOD}.shutil.which", return_value=None)
|
||||
@patch(f"{_MOD}.console")
|
||||
def test_stage_6_claude_missing_interactive_no(
|
||||
self, _con, _which, _prompt, mock_install, _choose, tmp_local_json
|
||||
) -> None:
|
||||
"""Missing claude + interactive + no → no install, continues."""
|
||||
mock_profile_mod = MagicMock()
|
||||
mock_profile_mod.get_user_profile.return_value = {}
|
||||
with patch.dict("sys.modules", {"aipass.aipass.apps.modules.profile": mock_profile_mod}):
|
||||
result = stage_6_tool_choice(non_interactive=False, cli_override="claude")
|
||||
mock_install.assert_not_called()
|
||||
assert result["cli"] == "claude"
|
||||
|
||||
@patch(f"{_MOD}.warning")
|
||||
@patch(f"{_MOD}._install_claude_code")
|
||||
@patch(f"{_MOD}.shutil.which", return_value=None)
|
||||
@patch(f"{_MOD}.console")
|
||||
def test_stage_6_claude_missing_non_interactive_warns(
|
||||
self, _con, _which, mock_install, mock_warn, tmp_local_json
|
||||
) -> None:
|
||||
"""Missing claude + non-interactive → warning, no install."""
|
||||
mock_profile_mod = MagicMock()
|
||||
mock_profile_mod.get_user_profile.return_value = {}
|
||||
with patch.dict("sys.modules", {"aipass.aipass.apps.modules.profile": mock_profile_mod}):
|
||||
result = stage_6_tool_choice(non_interactive=True)
|
||||
mock_install.assert_not_called()
|
||||
mock_warn.assert_called_once()
|
||||
assert result["cli"] == "claude"
|
||||
|
||||
def test_stage_7_skipped_when_no_docker(self, tmp_local_json) -> None:
|
||||
"""Docker offer is skipped when has_docker=False."""
|
||||
with patch(f"{_MOD}.console"):
|
||||
@@ -499,14 +567,14 @@ class TestStages:
|
||||
assert result["docker"] == "skipped"
|
||||
|
||||
def test_stage_8_non_interactive_creates_my_agent(self, tmp_local_json) -> None:
|
||||
"""non_interactive=True uses 'my-agent' as default name."""
|
||||
"""non_interactive=True uses 'my_agent' as default name."""
|
||||
mock_proc = MagicMock(returncode=0)
|
||||
with patch(f"{_MOD}.console"):
|
||||
with patch(f"{_MOD}.subprocess.run", return_value=mock_proc):
|
||||
with patch(f"{_MOD}._resolve_package_dir", return_value=None):
|
||||
result = stage_8_first_agent(non_interactive=True)
|
||||
assert result["agent_name"] == "my-agent"
|
||||
assert result["agent_path"] == "src/my-agent"
|
||||
assert result["agent_name"] == "my_agent"
|
||||
assert result["agent_path"] == "src/my_agent"
|
||||
|
||||
def test_stage_8_drone_not_found(self, tmp_local_json) -> None:
|
||||
"""FileNotFoundError from drone is handled gracefully."""
|
||||
@@ -567,3 +635,87 @@ class TestStages:
|
||||
assert result == {}
|
||||
stored = json.loads(tmp_local_json.read_text())
|
||||
assert stored["setup_progress"]["last_completed_stage"] == 12
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# init_update_registry_sync: subprocess_sync
|
||||
# =============================================================================
|
||||
|
||||
|
||||
_MOD_UPDATE = "aipass.aipass.apps.modules.init_flow"
|
||||
|
||||
|
||||
class TestInitUpdateRegistrySync:
|
||||
"""Tests for registry sync subprocess call in _handle_init_update."""
|
||||
|
||||
def test_sync_success_prints_message(self, tmp_path: Path) -> None:
|
||||
"""Successful drone sync-registry prints 'Registry synced.'"""
|
||||
mock_result = MagicMock(returncode=0)
|
||||
with (
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap.update_project",
|
||||
return_value={"updated_files": [], "already_current": []},
|
||||
),
|
||||
patch(f"{_MOD_UPDATE}.subprocess.run", return_value=mock_result) as mock_run,
|
||||
patch(f"{_MOD_UPDATE}.console") as mock_console,
|
||||
patch(f"{_MOD_UPDATE}.json_handler"),
|
||||
):
|
||||
rc = _handle_init_update([str(tmp_path)])
|
||||
assert rc == 0
|
||||
mock_run.assert_called_once_with(
|
||||
["drone", "@spawn", "sync-registry", "--fix"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
sync_calls = [c for c in mock_console.print.call_args_list if "Registry synced" in str(c)]
|
||||
assert len(sync_calls) == 1
|
||||
|
||||
def test_sync_failure_degrades_silently(self, tmp_path: Path) -> None:
|
||||
"""Non-zero exit from drone sync-registry is silently skipped."""
|
||||
mock_result = MagicMock(returncode=1)
|
||||
with (
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap.update_project",
|
||||
return_value={"updated_files": [], "already_current": []},
|
||||
),
|
||||
patch(f"{_MOD_UPDATE}.subprocess.run", return_value=mock_result),
|
||||
patch(f"{_MOD_UPDATE}.console") as mock_console,
|
||||
patch(f"{_MOD_UPDATE}.json_handler"),
|
||||
):
|
||||
rc = _handle_init_update([str(tmp_path)])
|
||||
assert rc == 0
|
||||
sync_calls = [c for c in mock_console.print.call_args_list if "Registry synced" in str(c)]
|
||||
assert len(sync_calls) == 0
|
||||
|
||||
def test_sync_missing_drone_degrades_silently(self, tmp_path: Path) -> None:
|
||||
"""FileNotFoundError (no drone binary) degrades gracefully."""
|
||||
with (
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap.update_project",
|
||||
return_value={"updated_files": [], "already_current": []},
|
||||
),
|
||||
patch(f"{_MOD_UPDATE}.subprocess.run", side_effect=FileNotFoundError("drone not found")),
|
||||
patch(f"{_MOD_UPDATE}.console") as mock_console,
|
||||
patch(f"{_MOD_UPDATE}.json_handler"),
|
||||
):
|
||||
rc = _handle_init_update([str(tmp_path)])
|
||||
assert rc == 0
|
||||
sync_calls = [c for c in mock_console.print.call_args_list if "Registry synced" in str(c)]
|
||||
assert len(sync_calls) == 0
|
||||
|
||||
def test_sync_timeout_degrades_silently(self, tmp_path: Path) -> None:
|
||||
"""subprocess.TimeoutExpired degrades gracefully."""
|
||||
import subprocess as _sp
|
||||
|
||||
with (
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap.update_project",
|
||||
return_value={"updated_files": [], "already_current": []},
|
||||
),
|
||||
patch(f"{_MOD_UPDATE}.subprocess.run", side_effect=_sp.TimeoutExpired(cmd="drone", timeout=30)),
|
||||
patch(f"{_MOD_UPDATE}.console"),
|
||||
patch(f"{_MOD_UPDATE}.json_handler"),
|
||||
):
|
||||
rc = _handle_init_update([str(tmp_path)])
|
||||
assert rc == 0
|
||||
|
||||
@@ -9,11 +9,12 @@
|
||||
"""Tests for json_handler — default_factory, validate, get_path, ensure_exists, load, save, ensure_module."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
from aipass.aipass.apps.handlers.json.json_handler import (
|
||||
AIPASS_JSON_DIR,
|
||||
_default_template,
|
||||
ensure_json_exists,
|
||||
ensure_module_jsons,
|
||||
get_json_path,
|
||||
@@ -30,31 +31,39 @@ from aipass.aipass.apps.handlers.json.json_handler import (
|
||||
|
||||
|
||||
class TestDefaultFactory:
|
||||
"""Tests for _default_template factory function."""
|
||||
"""Tests for default JSON creation via ensure_json_exists."""
|
||||
|
||||
def test_config_template(self):
|
||||
def test_config_template(self, tmp_path):
|
||||
"""Config template includes module_name, version, config, created."""
|
||||
result = _default_template("config", "test_mod")
|
||||
with patch("aipass.aipass.apps.handlers.json.json_handler.AIPASS_JSON_DIR", tmp_path):
|
||||
ensure_json_exists("test_mod", "config")
|
||||
result = json.loads((tmp_path / "test_mod_config.json").read_text())
|
||||
assert result["module_name"] == "test_mod"
|
||||
assert result["version"] == "1.0.0"
|
||||
assert "config" in result
|
||||
assert "created" in result
|
||||
|
||||
def test_data_template(self):
|
||||
def test_data_template(self, tmp_path):
|
||||
"""Data template includes created and last_updated."""
|
||||
result = _default_template("data", "test_mod")
|
||||
with patch("aipass.aipass.apps.handlers.json.json_handler.AIPASS_JSON_DIR", tmp_path):
|
||||
ensure_json_exists("test_mod", "data")
|
||||
result = json.loads((tmp_path / "test_mod_data.json").read_text())
|
||||
assert "created" in result
|
||||
assert "last_updated" in result
|
||||
|
||||
def test_log_template(self):
|
||||
def test_log_template(self, tmp_path):
|
||||
"""Log template is an empty list."""
|
||||
result = _default_template("log", "test_mod")
|
||||
with patch("aipass.aipass.apps.handlers.json.json_handler.AIPASS_JSON_DIR", tmp_path):
|
||||
ensure_json_exists("test_mod", "log")
|
||||
result = json.loads((tmp_path / "test_mod_log.json").read_text())
|
||||
assert result == []
|
||||
|
||||
def test_unknown_type_returns_none(self):
|
||||
"""Unknown json_type returns None."""
|
||||
result = _default_template("unknown_type", "test_mod")
|
||||
assert result is None
|
||||
def test_unknown_type_raises(self):
|
||||
"""Unknown json_type raises ValueError."""
|
||||
from aipass.aipass.shared.json_handler import JsonHandler
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
JsonHandler._create_default("unknown_type", "test_mod")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -200,10 +209,20 @@ class TestSave:
|
||||
assert saved["module_name"] == "s"
|
||||
|
||||
def test_save_invalid_structure_rejected(self, tmp_path):
|
||||
"""Invalid structure is rejected with False."""
|
||||
"""Invalid structure raises ValueError."""
|
||||
with patch("aipass.aipass.apps.handlers.json.json_handler.AIPASS_JSON_DIR", tmp_path):
|
||||
result = save_json("s", "config", {"bad": True})
|
||||
assert result is False
|
||||
with pytest.raises(ValueError):
|
||||
save_json("s", "config", {"bad": True})
|
||||
|
||||
def test_save_unknown_returns_false(self, tmp_path):
|
||||
"""save_json returns False when write fails (e.g. read-only dir)."""
|
||||
ro_dir = tmp_path / "readonly"
|
||||
ro_dir.mkdir()
|
||||
with patch("aipass.aipass.apps.handlers.json.json_handler.AIPASS_JSON_DIR", ro_dir):
|
||||
data = {"module_name": "s", "version": "1.0.0", "config": {}, "created": "2026-01-01"}
|
||||
with patch("aipass.aipass.shared.json_handler.JsonHandler.write_json", return_value=False):
|
||||
result = save_json("s", "config", data)
|
||||
assert result is False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -239,7 +258,7 @@ class TestLoadPath:
|
||||
result = load_path(f)
|
||||
assert result == {"key": "value"}
|
||||
|
||||
def test_load_missing_file(self, tmp_path):
|
||||
def test_unknown_file_returns_none(self, tmp_path):
|
||||
"""Missing file returns None."""
|
||||
result = load_path(tmp_path / "nope.json")
|
||||
assert result is None
|
||||
@@ -290,7 +309,9 @@ class TestReturnTypeContracts:
|
||||
"""Doctor handle_command returns True for match, False otherwise."""
|
||||
from aipass.aipass.apps.modules.doctor import handle_command as doctor_cmd
|
||||
|
||||
assert doctor_cmd("doctor", []) is True
|
||||
with patch("aipass.aipass.apps.modules.doctor.run_doctor", return_value=0):
|
||||
with patch("aipass.aipass.apps.modules.doctor.json_handler"):
|
||||
assert doctor_cmd("doctor", []) is True
|
||||
assert doctor_cmd("not_doctor", []) is False
|
||||
|
||||
def test_help_chat_handle_command_returns_bool(self):
|
||||
@@ -324,14 +345,14 @@ class TestExceptionContracts:
|
||||
"""Tests that invalid inputs raise appropriate exceptions."""
|
||||
|
||||
def test_invalid_mode_raises(self, tmp_path):
|
||||
"""save_json with invalid structure returns False (not silent pass)."""
|
||||
"""save_json with invalid structure raises ValueError."""
|
||||
with patch("aipass.aipass.apps.handlers.json.json_handler.AIPASS_JSON_DIR", tmp_path):
|
||||
result = save_json("x", "config", [])
|
||||
assert result is False
|
||||
result = save_json("x", "data", "string")
|
||||
assert result is False
|
||||
result = save_json("x", "log", {"not": "a list"})
|
||||
assert result is False
|
||||
with pytest.raises(ValueError):
|
||||
save_json("x", "config", [])
|
||||
with pytest.raises(ValueError):
|
||||
save_json("x", "data", "string")
|
||||
with pytest.raises(ValueError):
|
||||
save_json("x", "log", {"not": "a list"})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -355,3 +376,13 @@ class TestInfrastructureMocking:
|
||||
assert callable(jh_mod.load_json)
|
||||
assert callable(jh_mod.save_json)
|
||||
assert callable(jh_mod.load_path)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# success_failure_paths: unknown_returns_false
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_unknown_returns_false():
|
||||
"""validate_json_structure returns False for unrecognized json_type."""
|
||||
assert validate_json_structure({}, "bogus") is False
|
||||
|
||||
@@ -170,12 +170,19 @@ class TestHandleCommand:
|
||||
assert handle_command("init", ["run"]) is False
|
||||
|
||||
def test_no_args_calls_introspection(self, tmp_local_json) -> None:
|
||||
"""'profile' with no args calls print_introspection."""
|
||||
"""'profile' with no args shows the profile (runs the command)."""
|
||||
with patch("aipass.aipass.apps.modules.profile.print_introspection") as mock_pi:
|
||||
result = handle_command("profile", [])
|
||||
assert result is True
|
||||
mock_pi.assert_called_once()
|
||||
|
||||
def test_info_flag_calls_introspection(self, tmp_local_json) -> None:
|
||||
"""--info flag calls print_introspection."""
|
||||
with patch("aipass.aipass.apps.modules.profile.print_introspection") as mock_pi:
|
||||
result = handle_command("profile", ["--info"])
|
||||
assert result is True
|
||||
mock_pi.assert_called_once()
|
||||
|
||||
def test_help_flag_returns_true(self) -> None:
|
||||
"""--help flag is handled."""
|
||||
with patch("aipass.aipass.apps.modules.profile.print_help"):
|
||||
|
||||
@@ -0,0 +1,585 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_sandbox_check.py
|
||||
# Description: Tests for sandbox prerequisite checker and doctor integration
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-06-10
|
||||
# Modified: 2026-06-10
|
||||
# =============================================
|
||||
|
||||
"""Tests for sandbox prereq checks — handler + doctor integration."""
|
||||
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest # pyright: ignore[reportMissingImports]
|
||||
|
||||
from aipass.aipass.apps.handlers.sandbox_check.sandbox_checker import (
|
||||
check_broker_alive,
|
||||
check_bwrap_functional,
|
||||
check_bwrap_present,
|
||||
check_node_present,
|
||||
check_rg_present,
|
||||
check_sandbox_flag,
|
||||
check_srt_resolvable,
|
||||
is_linux,
|
||||
)
|
||||
from aipass.aipass.apps.handlers.ui.progress import GLYPH_FAIL, GLYPH_PASS, GLYPH_WARN
|
||||
from aipass.aipass.apps.modules.doctor import _check_sandbox
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Fixtures
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_json_handler():
|
||||
"""Suppress json_handler.log_operation side effects in all tests."""
|
||||
with patch("aipass.aipass.apps.handlers.sandbox_check.sandbox_checker.json_handler") as mock:
|
||||
mock.log_operation = MagicMock()
|
||||
yield mock
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# check_sandbox_flag
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCheckSandboxFlag:
|
||||
def test_flag_off_by_default(self, monkeypatch):
|
||||
monkeypatch.delenv("AIPASS_SANDBOX_ENABLED", raising=False)
|
||||
result = check_sandbox_flag()
|
||||
assert result["enabled"] is False
|
||||
assert result["raw_value"] == ""
|
||||
|
||||
def test_flag_on_with_1(self, monkeypatch):
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1")
|
||||
result = check_sandbox_flag()
|
||||
assert result["enabled"] is True
|
||||
|
||||
def test_flag_on_with_true(self, monkeypatch):
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "true")
|
||||
result = check_sandbox_flag()
|
||||
assert result["enabled"] is True
|
||||
|
||||
def test_flag_on_with_yes(self, monkeypatch):
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "yes")
|
||||
result = check_sandbox_flag()
|
||||
assert result["enabled"] is True
|
||||
|
||||
def test_flag_on_case_insensitive(self, monkeypatch):
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "TRUE")
|
||||
result = check_sandbox_flag()
|
||||
assert result["enabled"] is True
|
||||
|
||||
def test_flag_off_with_garbage(self, monkeypatch):
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "maybe")
|
||||
result = check_sandbox_flag()
|
||||
assert result["enabled"] is False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# check_bwrap_present
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCheckBwrapPresent:
|
||||
def test_bwrap_found(self, monkeypatch):
|
||||
monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/bwrap" if name == "bwrap" else None)
|
||||
result = check_bwrap_present()
|
||||
assert result["found"] is True
|
||||
assert result["path"] == "/usr/bin/bwrap"
|
||||
|
||||
def test_bwrap_not_found(self, monkeypatch):
|
||||
monkeypatch.setattr(shutil, "which", lambda name: None)
|
||||
result = check_bwrap_present()
|
||||
assert result["found"] is False
|
||||
assert result["path"] is None
|
||||
|
||||
@pytest.mark.skipif(not shutil.which("bwrap"), reason="bwrap not installed")
|
||||
def test_bwrap_live(self):
|
||||
result = check_bwrap_present()
|
||||
assert result["found"] is True
|
||||
assert "bwrap" in result["path"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# check_bwrap_functional
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCheckBwrapFunctional:
|
||||
def test_bwrap_missing(self, monkeypatch):
|
||||
monkeypatch.setattr(shutil, "which", lambda name: None)
|
||||
result = check_bwrap_functional()
|
||||
assert result["ok"] is False
|
||||
assert "not found" in result["detail"]
|
||||
|
||||
def test_bwrap_succeeds(self, monkeypatch):
|
||||
monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/bwrap" if name == "bwrap" else None)
|
||||
mock_proc = MagicMock(returncode=0, stderr="")
|
||||
with patch(
|
||||
"aipass.aipass.apps.handlers.sandbox_check.sandbox_checker.subprocess.run",
|
||||
return_value=mock_proc,
|
||||
) as mock_run:
|
||||
result = check_bwrap_functional()
|
||||
assert result["ok"] is True
|
||||
argv = mock_run.call_args[0][0]
|
||||
assert argv[0] == "/usr/bin/bwrap"
|
||||
assert "--ro-bind" in argv
|
||||
assert "true" in argv
|
||||
|
||||
def test_bwrap_fails_reports_sysctl(self, monkeypatch):
|
||||
monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/bwrap" if name == "bwrap" else None)
|
||||
mock_proc = MagicMock(returncode=1, stderr="permission denied")
|
||||
with (
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.sandbox_check.sandbox_checker.subprocess.run",
|
||||
return_value=mock_proc,
|
||||
),
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.sandbox_check.sandbox_checker._read_userns_sysctl",
|
||||
return_value="1",
|
||||
),
|
||||
):
|
||||
result = check_bwrap_functional()
|
||||
assert result["ok"] is False
|
||||
assert "exit 1" in result["detail"]
|
||||
assert result["sysctl_value"] == "1"
|
||||
|
||||
def test_bwrap_timeout(self, monkeypatch):
|
||||
monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/bwrap" if name == "bwrap" else None)
|
||||
with patch(
|
||||
"aipass.aipass.apps.handlers.sandbox_check.sandbox_checker.subprocess.run",
|
||||
side_effect=subprocess.TimeoutExpired(cmd="bwrap", timeout=10),
|
||||
):
|
||||
result = check_bwrap_functional()
|
||||
assert result["ok"] is False
|
||||
assert "timed out" in result["detail"]
|
||||
|
||||
@pytest.mark.skipif(not shutil.which("bwrap"), reason="bwrap not installed")
|
||||
def test_bwrap_functional_live(self):
|
||||
result = check_bwrap_functional()
|
||||
assert isinstance(result["ok"], bool)
|
||||
if result["ok"]:
|
||||
assert "succeeded" in result["detail"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# check_node_present
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCheckNodePresent:
|
||||
def test_node_found(self, monkeypatch):
|
||||
monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/node" if name == "node" else None)
|
||||
result = check_node_present()
|
||||
assert result["found"] is True
|
||||
assert result["path"] == "/usr/bin/node"
|
||||
|
||||
def test_node_not_found(self, monkeypatch):
|
||||
monkeypatch.setattr(shutil, "which", lambda name: None)
|
||||
result = check_node_present()
|
||||
assert result["found"] is False
|
||||
|
||||
@pytest.mark.skipif(not shutil.which("node"), reason="node not installed")
|
||||
def test_node_live(self):
|
||||
result = check_node_present()
|
||||
assert result["found"] is True
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# check_srt_resolvable
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCheckSrtResolvable:
|
||||
def test_no_node(self, monkeypatch):
|
||||
monkeypatch.setattr(shutil, "which", lambda name: None)
|
||||
result = check_srt_resolvable()
|
||||
assert result["found"] is False
|
||||
assert "node" in result["install_hint"].lower()
|
||||
|
||||
def test_srt_found(self, monkeypatch):
|
||||
monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/node" if name == "node" else None)
|
||||
mock_proc = MagicMock(returncode=0, stdout="/usr/lib/node_modules/@anthropic-ai/sandbox-runtime/dist/index.js")
|
||||
with patch(
|
||||
"aipass.aipass.apps.handlers.sandbox_check.sandbox_checker.subprocess.run",
|
||||
return_value=mock_proc,
|
||||
) as mock_run:
|
||||
result = check_srt_resolvable()
|
||||
assert result["found"] is True
|
||||
assert "sandbox-runtime" in result["path"]
|
||||
argv = mock_run.call_args[0][0]
|
||||
assert argv[0] == "/usr/bin/node"
|
||||
assert argv[1] == "-e"
|
||||
|
||||
def test_srt_not_found(self, monkeypatch):
|
||||
monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/node" if name == "node" else None)
|
||||
mock_proc = MagicMock(returncode=1, stdout="")
|
||||
with patch(
|
||||
"aipass.aipass.apps.handlers.sandbox_check.sandbox_checker.subprocess.run",
|
||||
return_value=mock_proc,
|
||||
):
|
||||
result = check_srt_resolvable()
|
||||
assert result["found"] is False
|
||||
assert "npm install" in result["install_hint"]
|
||||
|
||||
def test_srt_timeout(self, monkeypatch):
|
||||
monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/node" if name == "node" else None)
|
||||
with patch(
|
||||
"aipass.aipass.apps.handlers.sandbox_check.sandbox_checker.subprocess.run",
|
||||
side_effect=subprocess.TimeoutExpired(cmd="node", timeout=10),
|
||||
):
|
||||
result = check_srt_resolvable()
|
||||
assert result["found"] is False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# check_rg_present
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCheckRgPresent:
|
||||
def test_rg_on_path(self, monkeypatch):
|
||||
monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/rg" if name == "rg" else None)
|
||||
result = check_rg_present()
|
||||
assert result["found"] is True
|
||||
assert result["path"] == "/usr/bin/rg"
|
||||
|
||||
def test_rg_not_on_path_but_in_local_bin(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(shutil, "which", lambda name: None)
|
||||
fake_rg = tmp_path / ".local" / "bin" / "rg"
|
||||
fake_rg.parent.mkdir(parents=True)
|
||||
fake_rg.touch()
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
result = check_rg_present()
|
||||
assert result["found"] is True
|
||||
assert str(fake_rg) == result["path"]
|
||||
|
||||
def test_rg_not_found(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(shutil, "which", lambda name: None)
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
result = check_rg_present()
|
||||
assert result["found"] is False
|
||||
|
||||
@pytest.mark.skipif(not shutil.which("rg"), reason="rg not installed")
|
||||
def test_rg_live(self):
|
||||
result = check_rg_present()
|
||||
assert result["found"] is True
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# check_broker_alive
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCheckBrokerAlive:
|
||||
def test_no_repo_root_no_env(self, monkeypatch):
|
||||
monkeypatch.delenv("AIPASS_HOME", raising=False)
|
||||
monkeypatch.setattr(Path, "cwd", lambda: Path("/nonexistent"))
|
||||
result = check_broker_alive(repo_root=None)
|
||||
assert result["alive"] is False
|
||||
|
||||
def test_socket_missing(self, tmp_path):
|
||||
ai_central = tmp_path / ".ai_central"
|
||||
ai_central.mkdir()
|
||||
result = check_broker_alive(repo_root=tmp_path)
|
||||
assert result["alive"] is False
|
||||
assert "missing" in result["detail"]
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "linux", reason="AF_UNIX broker sockets are Linux-only")
|
||||
def test_socket_connect_success(self, tmp_path):
|
||||
ai_central = tmp_path / ".ai_central"
|
||||
ai_central.mkdir()
|
||||
sock_path = ai_central / "drone_broker.sock"
|
||||
|
||||
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
server.bind(str(sock_path))
|
||||
server.listen(1)
|
||||
try:
|
||||
result = check_broker_alive(repo_root=tmp_path)
|
||||
assert result["alive"] is True
|
||||
assert "connected" in result["detail"]
|
||||
finally:
|
||||
server.close()
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "linux", reason="AF_UNIX broker sockets are Linux-only")
|
||||
def test_socket_connect_refused(self, tmp_path):
|
||||
ai_central = tmp_path / ".ai_central"
|
||||
ai_central.mkdir()
|
||||
sock_path = ai_central / "drone_broker.sock"
|
||||
sock_path.touch()
|
||||
result = check_broker_alive(repo_root=tmp_path)
|
||||
assert result["alive"] is False
|
||||
assert "connect failed" in result["detail"]
|
||||
|
||||
def test_repo_root_from_env(self, monkeypatch, tmp_path):
|
||||
ai_central = tmp_path / ".ai_central"
|
||||
ai_central.mkdir()
|
||||
monkeypatch.setenv("AIPASS_HOME", str(tmp_path))
|
||||
result = check_broker_alive(repo_root=None)
|
||||
assert result["alive"] is False
|
||||
assert "missing" in result["detail"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# is_linux
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestIsLinux:
|
||||
def test_linux(self, monkeypatch):
|
||||
monkeypatch.setattr("aipass.aipass.apps.handlers.sandbox_check.sandbox_checker.sys.platform", "linux")
|
||||
assert is_linux() is True
|
||||
|
||||
def test_darwin(self, monkeypatch):
|
||||
monkeypatch.setattr("aipass.aipass.apps.handlers.sandbox_check.sandbox_checker.sys.platform", "darwin")
|
||||
assert is_linux() is False
|
||||
|
||||
def test_win32(self, monkeypatch):
|
||||
monkeypatch.setattr("aipass.aipass.apps.handlers.sandbox_check.sandbox_checker.sys.platform", "win32")
|
||||
assert is_linux() is False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# _check_sandbox (doctor integration)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _stub_doctor_json():
|
||||
"""Stub json_handler inside doctor.py too."""
|
||||
with patch("aipass.aipass.apps.modules.doctor.json_handler") as mock:
|
||||
mock.log_operation = MagicMock()
|
||||
yield mock
|
||||
|
||||
|
||||
class TestCheckSandboxDoctor:
|
||||
def test_non_linux_one_info_line(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.is_linux",
|
||||
lambda: False,
|
||||
)
|
||||
results = _check_sandbox()
|
||||
assert len(results) == 1
|
||||
assert "Linux-only" in results[0].detail
|
||||
assert results[0].glyph == GLYPH_PASS
|
||||
|
||||
def test_flag_off_missing_prereq_is_warn(self, monkeypatch):
|
||||
monkeypatch.delenv("AIPASS_SANDBOX_ENABLED", raising=False)
|
||||
monkeypatch.setattr("aipass.aipass.apps.modules.doctor.is_linux", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_sandbox_flag", lambda: {"enabled": False, "raw_value": ""}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_bwrap_present", lambda: {"found": False, "path": None}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_node_present", lambda: {"found": False, "path": None}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_srt_resolvable",
|
||||
lambda: {"found": False, "path": None, "install_hint": "npm install -g ..."},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_rg_present", lambda: {"found": False, "path": None}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_broker_alive",
|
||||
lambda repo_root=None: {"alive": False, "detail": "not found"},
|
||||
)
|
||||
monkeypatch.setattr("aipass.aipass.apps.modules.doctor.find_project_root", lambda p: None)
|
||||
|
||||
results = _check_sandbox()
|
||||
for r in results:
|
||||
assert r.glyph != GLYPH_FAIL, f"Flag OFF should not produce FAIL, got FAIL for {r.label}"
|
||||
|
||||
def test_flag_on_missing_prereq_is_fail(self, monkeypatch):
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1")
|
||||
monkeypatch.setattr("aipass.aipass.apps.modules.doctor.is_linux", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_sandbox_flag", lambda: {"enabled": True, "raw_value": "1"}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_bwrap_present", lambda: {"found": False, "path": None}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_node_present", lambda: {"found": False, "path": None}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_srt_resolvable",
|
||||
lambda: {"found": False, "path": None, "install_hint": "npm install -g ..."},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_rg_present", lambda: {"found": False, "path": None}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_broker_alive",
|
||||
lambda repo_root=None: {"alive": False, "detail": "not found"},
|
||||
)
|
||||
monkeypatch.setattr("aipass.aipass.apps.modules.doctor.find_project_root", lambda p: None)
|
||||
|
||||
results = _check_sandbox()
|
||||
fail_results = [r for r in results if r.glyph == GLYPH_FAIL]
|
||||
assert len(fail_results) >= 4, f"Flag ON + missing prereqs should produce FAILs, got {len(fail_results)}"
|
||||
|
||||
def test_flag_on_all_present_is_pass(self, monkeypatch):
|
||||
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1")
|
||||
monkeypatch.setattr("aipass.aipass.apps.modules.doctor.is_linux", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_sandbox_flag", lambda: {"enabled": True, "raw_value": "1"}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_bwrap_present", lambda: {"found": True, "path": "/usr/bin/bwrap"}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_bwrap_functional",
|
||||
lambda: {"ok": True, "detail": "trivial sandbox succeeded", "sysctl_value": None},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_node_present", lambda: {"found": True, "path": "/usr/bin/node"}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_srt_resolvable",
|
||||
lambda: {"found": True, "path": "/usr/lib/srt/index.js", "install_hint": ""},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_rg_present", lambda: {"found": True, "path": "/usr/bin/rg"}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_broker_alive",
|
||||
lambda repo_root=None: {"alive": True, "detail": "connected"},
|
||||
)
|
||||
monkeypatch.setattr("aipass.aipass.apps.modules.doctor.find_project_root", lambda p: Path("/tmp/fake"))
|
||||
|
||||
results = _check_sandbox()
|
||||
for r in results:
|
||||
assert r.glyph == GLYPH_PASS, f"All present should be PASS, got {r.glyph} for {r.label}"
|
||||
|
||||
def test_bwrap_functional_skipped_when_not_present(self, monkeypatch):
|
||||
monkeypatch.setattr("aipass.aipass.apps.modules.doctor.is_linux", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_sandbox_flag", lambda: {"enabled": False, "raw_value": ""}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_bwrap_present", lambda: {"found": False, "path": None}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_node_present", lambda: {"found": True, "path": "/usr/bin/node"}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_srt_resolvable",
|
||||
lambda: {"found": True, "path": "/x", "install_hint": ""},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_rg_present", lambda: {"found": True, "path": "/usr/bin/rg"}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_broker_alive",
|
||||
lambda repo_root=None: {"alive": True, "detail": "ok"},
|
||||
)
|
||||
monkeypatch.setattr("aipass.aipass.apps.modules.doctor.find_project_root", lambda p: None)
|
||||
|
||||
results = _check_sandbox()
|
||||
labels = [r.label for r in results]
|
||||
assert "bwrap functional" not in labels
|
||||
|
||||
def test_bwrap_functional_included_when_present(self, monkeypatch):
|
||||
monkeypatch.setattr("aipass.aipass.apps.modules.doctor.is_linux", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_sandbox_flag", lambda: {"enabled": False, "raw_value": ""}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_bwrap_present", lambda: {"found": True, "path": "/usr/bin/bwrap"}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_bwrap_functional",
|
||||
lambda: {"ok": True, "detail": "ok", "sysctl_value": None},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_node_present", lambda: {"found": True, "path": "/usr/bin/node"}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_srt_resolvable",
|
||||
lambda: {"found": True, "path": "/x", "install_hint": ""},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_rg_present", lambda: {"found": True, "path": "/usr/bin/rg"}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_broker_alive",
|
||||
lambda repo_root=None: {"alive": True, "detail": "ok"},
|
||||
)
|
||||
monkeypatch.setattr("aipass.aipass.apps.modules.doctor.find_project_root", lambda p: None)
|
||||
|
||||
results = _check_sandbox()
|
||||
labels = [r.label for r in results]
|
||||
assert "bwrap functional" in labels
|
||||
|
||||
def test_sysctl_in_detail_on_functional_fail(self, monkeypatch):
|
||||
monkeypatch.setattr("aipass.aipass.apps.modules.doctor.is_linux", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_sandbox_flag", lambda: {"enabled": True, "raw_value": "1"}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_bwrap_present", lambda: {"found": True, "path": "/usr/bin/bwrap"}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_bwrap_functional",
|
||||
lambda: {"ok": False, "detail": "exit 1: denied", "sysctl_value": "1"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_node_present", lambda: {"found": True, "path": "/usr/bin/node"}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_srt_resolvable",
|
||||
lambda: {"found": True, "path": "/x", "install_hint": ""},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_rg_present", lambda: {"found": True, "path": "/usr/bin/rg"}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_broker_alive",
|
||||
lambda repo_root=None: {"alive": True, "detail": "ok"},
|
||||
)
|
||||
monkeypatch.setattr("aipass.aipass.apps.modules.doctor.find_project_root", lambda p: None)
|
||||
|
||||
results = _check_sandbox()
|
||||
func_result = [r for r in results if r.label == "bwrap functional"][0]
|
||||
assert "apparmor_restrict_unprivileged_userns=1" in func_result.detail
|
||||
|
||||
def test_inert_suffix_when_flag_off(self, monkeypatch):
|
||||
monkeypatch.setattr("aipass.aipass.apps.modules.doctor.is_linux", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_sandbox_flag", lambda: {"enabled": False, "raw_value": ""}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_bwrap_present", lambda: {"found": False, "path": None}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_node_present", lambda: {"found": False, "path": None}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_srt_resolvable",
|
||||
lambda: {"found": False, "path": None, "install_hint": "npm install -g ..."},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_rg_present", lambda: {"found": False, "path": None}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.modules.doctor.check_broker_alive",
|
||||
lambda repo_root=None: {"alive": False, "detail": "not found"},
|
||||
)
|
||||
monkeypatch.setattr("aipass.aipass.apps.modules.doctor.find_project_root", lambda p: None)
|
||||
|
||||
results = _check_sandbox()
|
||||
missing_results = [r for r in results if r.glyph == GLYPH_WARN]
|
||||
for r in missing_results:
|
||||
assert "inert" in r.detail or r.label == "sandbox flag", (
|
||||
f"Missing prereq {r.label} should show inert suffix"
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_shared_bootstrap_safety.py
|
||||
# Description: Guard test — shared/ must stay stdlib-only (loads pre-drone)
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-06-10
|
||||
# Modified: 2026-06-10
|
||||
# =============================================
|
||||
|
||||
"""Guard test: importing aipass.aipass.shared must NOT pull in branch dependencies.
|
||||
|
||||
The shared/ package is used by bootstrap.py during `aipass init` on fresh machines
|
||||
where drone/prax/trigger don't exist yet. If shared/ ever imports a branch
|
||||
dependency, init breaks. This test enforces the invariant via subprocess isolation.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
ALLOWED_PREFIXES = ("aipass.aipass.shared",)
|
||||
ALLOWED_EXACT = {"aipass", "aipass.aipass"}
|
||||
|
||||
SCRIPT = """\
|
||||
import sys
|
||||
|
||||
import aipass.aipass.shared.json_handler
|
||||
import aipass.aipass.shared.json_ops
|
||||
import aipass.aipass.shared.registry_discovery
|
||||
|
||||
bad = []
|
||||
for name in sorted(sys.modules):
|
||||
if not name.startswith("aipass"):
|
||||
continue
|
||||
if name in {allowed_exact}:
|
||||
continue
|
||||
if any(name.startswith(p) for p in {allowed_prefixes}):
|
||||
continue
|
||||
bad.append(name)
|
||||
|
||||
if bad:
|
||||
print("FAIL: branch dependencies loaded: " + ", ".join(bad))
|
||||
sys.exit(1)
|
||||
print("OK")
|
||||
""".format(
|
||||
allowed_exact=repr(ALLOWED_EXACT),
|
||||
allowed_prefixes=repr(ALLOWED_PREFIXES),
|
||||
)
|
||||
|
||||
|
||||
class TestSharedBootstrapSafety:
|
||||
def test_no_branch_deps_loaded(self):
|
||||
"""Importing all shared modules must not pull in any branch code."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", SCRIPT],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
assert result.returncode == 0, f"shared/ pulled in branch dependencies:\n{result.stdout}\n{result.stderr}"
|
||||
@@ -20,7 +20,6 @@ from aipass.aipass.apps.handlers.structure_scan.structure_scanner import (
|
||||
check_root_artifacts,
|
||||
detect_pollution,
|
||||
find_project_root,
|
||||
find_registry,
|
||||
scan_agents,
|
||||
)
|
||||
from aipass.aipass.apps.handlers.ui.progress import GLYPH_FAIL, GLYPH_WARN
|
||||
@@ -357,16 +356,22 @@ class TestRegistryConsistency:
|
||||
|
||||
class TestFindRegistry:
|
||||
def test_finds_registry(self, tmp_path: Path) -> None:
|
||||
"""Finds *_REGISTRY.json in project root."""
|
||||
"""Shared find_registry finds *_REGISTRY.json from start_path."""
|
||||
from aipass.aipass.shared.registry_discovery import find_registry
|
||||
|
||||
(tmp_path / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||
result = find_registry(tmp_path)
|
||||
result = find_registry(start_path=tmp_path)
|
||||
assert result is not None
|
||||
assert result.name == "AIPASS_REGISTRY.json"
|
||||
|
||||
def test_returns_none(self, tmp_path: Path) -> None:
|
||||
"""Returns None when no registry file."""
|
||||
result = find_registry(tmp_path)
|
||||
assert result is None
|
||||
def test_fallback_when_missing(self, tmp_path: Path) -> None:
|
||||
"""Shared find_registry returns fallback when no registry in isolated dir."""
|
||||
from aipass.aipass.shared.registry_discovery import find_registry
|
||||
|
||||
isolated = tmp_path / "no_registry"
|
||||
isolated.mkdir()
|
||||
result = find_registry(start_path=isolated)
|
||||
assert result.parent != isolated or not result.exists()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# These are beyond the base AIPass requirements
|
||||
# Install with: pip install -r requirements.project.txt
|
||||
|
||||
requests
|
||||
requests>=2.34.2
|
||||
rich
|
||||
google-auth
|
||||
google-auth-oauthlib
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# DEVPULSE — Branch Prompt
|
||||
|
||||
Injected every turn. Breadcrumbs only — details in README, --help, .trinity/, STATUS.local.md.
|
||||
Injected every turn. Breadcrumbs only — details in README, --help, .trinity/, DASHBOARD.local.json.
|
||||
|
||||
## Identity
|
||||
|
||||
@@ -9,7 +9,7 @@ DEVPULSE — Patrick's primary AI collaborator, orchestration hub. Design, plan,
|
||||
## How You Work
|
||||
|
||||
- DRONE FOR EVERYTHING. Never raw git, gh, or python -m. `drone` is on PATH — run it directly. No which, no path lookup, no verification. Just `drone @git ...`, `drone @flow ...`, `drone @ai_mail ...`. If blocked, drone is the fix — not a workaround.
|
||||
- Build own directly: modules, DPLANs, FPLANs, memories, STATUS — yours, edit freely.
|
||||
- Build own directly: modules, DPLANs, FPLANs, memories — yours, edit freely.
|
||||
- Prototype to explore shape, hand real build to sub-agent.
|
||||
- Investigate other branches freely: read, debug, test, fix small bugs. CWD stays devpulse.
|
||||
- Full multi-file implementations → `drone @ai_mail dispatch @branch`.
|
||||
@@ -35,9 +35,15 @@ Task belongs to specialist domain → ask them. Investigate/fix small things you
|
||||
| User onboarding, init | @aipass | Concierge, aipass init, doctor, scanner |
|
||||
| Hooks, engine, gates | @hooks | Hook engine, bridges, per-project config, sound |
|
||||
|
||||
## Git — Dev Branch, Drone Only, You Are Gatekeeper
|
||||
## Git — Dev Branch, You Are Gatekeeper
|
||||
|
||||
Only branch with git write access. All git/gh blocked at project level. Drone bypasses via subprocess — tier system grants write to devpulse only.
|
||||
Only branch with git WRITE access. WRITE git (commit, push, checkout, merge, reset, rebase, clean, pull, fetch, tag, branch -D, clone, worktree…) is blocked raw → use `drone @git` (tier grants write to devpulse only).
|
||||
|
||||
**READ git is allowed RAW** (S193, git_gate read allowlist) — just run it, no drone needed. Use this for investigation/forensics instead of reaching for drone or `find` fallbacks:
|
||||
- Allowed verbs: `ls-files, ls-tree, show, cat-file, rev-parse, rev-list, log, status, diff, blame, describe, for-each-ref, show-ref, symbolic-ref, shortlog, grep, archive, count-objects, var, help, version`.
|
||||
- NOT yet allowed (gap, S193): `check-ignore` → use `git ls-files <path>` (empty = ignored/untracked) or read `.gitignore` directly.
|
||||
- Reproduce a clean checkout (tracked-only, like CI): `git archive HEAD | tar -x -C /tmp/<dir>` (`drone rm` the dir first; `rm -rf` is gated).
|
||||
- Chained read+write blocks the whole command (e.g. `git log && git push` → blocked). Keep read and write in separate invocations.
|
||||
|
||||
Three rules:
|
||||
1. Work on dev, merge to main when satisfied. `drone @git merge dev` squash-merges.
|
||||
@@ -91,12 +97,12 @@ drone, seedgo, prax, cli, ai_mail, api, flow, spawn, trigger, memory, aipass, ho
|
||||
|
||||
- Lean on branches for expertise. Email for architecture questions. Investigate/debug/test freely.
|
||||
- Use memories freely. Rollover to @memory by design. Update .trinity/ often.
|
||||
- STATUS.local.md Notepad for friction notes. Address in batches.
|
||||
- local.json todos[] for friction notes. Address in batches.
|
||||
- Own things → build directly. Heavy refactors → delegate sub-agent.
|
||||
- CWD = identity. Visit other branches, don't move in.
|
||||
- Git awareness: after completing work, `drone @git status`. Suggest commit if coherent. Don't force, don't let pile up.
|
||||
- Git workflow: commit → dev-pr → wait for CI. Every commit must be pushed. Local-only commits are invisible. After fixing CI, push immediately (dev-pr reports "PR already open" = pushed).
|
||||
- CHANGELOG: update `CHANGELOG.md` when committing/pushing. Add entries to the current week's `[YYYY.WNN]` section as work lands — don't batch at end of week. Sunday = merge to main + tag.
|
||||
- CHANGELOG: update `CHANGELOG.md` when committing/pushing. Add entries under the current dated section (`[YYYY-MM-DD]`, one per merge) as work lands — don't batch. Merge to main + tag on demand (no fixed weekly cadence).
|
||||
- Never `docker cp` into containers. Merge PR → git pull → test.
|
||||
- Sub-agents build, you PR.
|
||||
|
||||
@@ -122,10 +128,10 @@ Find agent via `.trinity/passport.json`. Use `dangerouslyDisableSandbox: true`.
|
||||
|
||||
## Memory & Tracking
|
||||
|
||||
- `.trinity/local.json` — session history, key learnings
|
||||
- `.trinity/local.json` — session history, key learnings, todos[]
|
||||
- `.trinity/observations.json` — collaboration patterns
|
||||
- `STATUS.local.md` — current work, issues, todos, notepad. Feeds central STATUS.md.
|
||||
- `DASHBOARD.local.json` — live state glance (refreshed by prax)
|
||||
|
||||
Update proactively — after milestones, /memo, topic shifts, 5+ actions without saving.
|
||||
|
||||
This prompt = lightweight signposts. State → .trinity/ + STATUS.local.md.
|
||||
This prompt = lightweight signposts. State → .trinity/ + DASHBOARD.local.json.
|
||||
|
||||
@@ -84,6 +84,31 @@
|
||||
"standard": "help_text",
|
||||
"file": "tools/hook_engine_poc/test_engine.py",
|
||||
"reason": "POC test harness — usage example in docstring."
|
||||
},
|
||||
{
|
||||
"standard": "debug_print",
|
||||
"file": "tools/rm_shim/redteam_suite.py",
|
||||
"reason": "Standalone red-team diagnostic runner (FPLAN-0250 Phase 6) — print() IS the report output, same as broker_acceptance_test.py."
|
||||
},
|
||||
{
|
||||
"standard": "encapsulation",
|
||||
"file": "tools/rm_shim/redteam_suite.py",
|
||||
"reason": "Red-team tool imports the real broker daemon/client + sandbox module directly to exercise them under live conditions — that is the point of an integration probe, not a handler."
|
||||
},
|
||||
{
|
||||
"standard": "imports",
|
||||
"file": "tools/rm_shim/redteam_suite.py",
|
||||
"reason": "Standalone script run via 'python tools/...' — sys.path insert lets it import the production modules it red-teams without being pip-installed."
|
||||
},
|
||||
{
|
||||
"standard": "help_text",
|
||||
"file": "tools/rm_shim/redteam_suite.py",
|
||||
"reason": "Diagnostic script — docstring shows the 'python tools/...' invocation; it is not a drone-routed module."
|
||||
},
|
||||
{
|
||||
"standard": "documentation",
|
||||
"file": "tools/rm_shim/redteam_suite.py",
|
||||
"reason": "Result.ok/bad are 2-line internal report helpers in a diagnostic script — self-evident, docstrings redundant."
|
||||
}
|
||||
],
|
||||
"notes": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# DevPulse
|
||||
|
||||
> Orchestration hub for AIPass. The user's primary AI collaborator — designs, plans, debugs, coordinates all 11 branches, and builds its own modules.
|
||||
> Orchestration hub for AIPass. The user's primary AI collaborator — designs, plans, debugs, coordinates all 12 other branches, and builds its own modules.
|
||||
|
||||
DevPulse handles the day-to-day: working with the user to plan, design, troubleshoot, and adjust. It builds its own modules directly (watchdog, feedback, json_handler), manages all git operations for the project, dispatches heavy multi-file builds to sub-agents, and ventures into other branches to investigate, debug, and fix small bugs. The only branch with git write access.
|
||||
|
||||
@@ -10,7 +10,7 @@ DevPulse handles the day-to-day: working with the user to plan, design, troubles
|
||||
|
||||
| You want to | Read |
|
||||
|---|---|
|
||||
| What's happening right now | [STATUS.local.md](STATUS.local.md) |
|
||||
| What's happening right now | `DASHBOARD.local.json` |
|
||||
| Identity, memory, session history | [`.trinity/`](.trinity/) |
|
||||
| Active plans | `drone @flow list open` |
|
||||
| Branch list | `drone systems` |
|
||||
@@ -42,11 +42,11 @@ src/aipass/devpulse/
|
||||
│ │ └── watchdog/ # Agent, timer, schedule, registry
|
||||
│ └── plugins/ # Plugin extension point
|
||||
├── devpulse_json/ # JSON handler storage (config, data, logs per module)
|
||||
├── tests/ # 252 tests
|
||||
├── tests/ # 236 tests
|
||||
├── artifacts/ # Birth certificate, reports
|
||||
├── dropbox/ # Received files, archived plans, install audit
|
||||
├── docs/ # Transition notes
|
||||
└── STATUS.local.md # Current work beacon
|
||||
└── DASHBOARD.local.json # Live state (refreshed by prax)
|
||||
```
|
||||
|
||||
## Commands
|
||||
@@ -107,7 +107,7 @@ drone @git log # Recent commits
|
||||
|
||||
All branches via dispatch orchestration. Watchdog monitoring for any dispatched agent. Feedback channel for cross-branch communication. Git operations (commit, PR, merge) for the entire project.
|
||||
|
||||
*Last Updated: 2026-05-16*
|
||||
*Last Updated: 2026-06-05*
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
[← Back to DevPulse](README.md)
|
||||
|
||||
# DevPulse Setup, Uninstall, Troubleshooting
|
||||
|
||||
Everything you need to install, run, maintain, or remove DevPulse (and AIPass as a whole). Kept here so the DevPulse README can stay lean and loads quickly on every session startup.
|
||||
|
||||
---
|
||||
|
||||
## Platform support at a glance
|
||||
|
||||
| Platform | Install status | Notes |
|
||||
|---|---|---|
|
||||
| **Linux** (Ubuntu, Debian, Fedora, Arch) | Supported | Primary development target. `setup.sh` works out of the box. |
|
||||
| **macOS** (Intel and Apple Silicon) | Supported | `setup.sh` works with minor caveats (see macOS section). |
|
||||
| **Windows 10 / 11** | **In progress** | Native Windows support is actively being built. Track progress in [issue #261](https://github.com/AIOSAI/AIPass/issues/261). For now: use WSL2 (Ubuntu), or wait for the cross-platform `setup.py` landing in a PR soon. |
|
||||
|
||||
---
|
||||
|
||||
## Linux install
|
||||
|
||||
### Requirements
|
||||
|
||||
- Python 3.10 or newer (`python3 --version`)
|
||||
- `git`, `bash`, `sudo`
|
||||
- Claude Code CLI installed and authenticated (`claude --version`)
|
||||
- ~500 MB disk for the venv and dependencies
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
git clone https://github.com/AIOSAI/AIPass.git ~/Projects/AIPass
|
||||
cd ~/Projects/AIPass
|
||||
bash setup.sh
|
||||
```
|
||||
|
||||
`setup.sh` will:
|
||||
1. Create a Python venv at `.venv/`
|
||||
2. Install AIPass in editable mode (`pip install -e .`)
|
||||
3. Verify the `drone` and `aipass` CLI entry points
|
||||
4. Create `~/.secrets/aipass/` with `chmod 700` and seed an `.env.example`
|
||||
5. Generate the AIPass branch registry
|
||||
6. Bootstrap branch identity files (`.trinity/passport.json` per branch)
|
||||
7. Wire Claude Code hooks into `~/.claude/settings.json`
|
||||
8. Create a global symlink at `/usr/local/bin/drone` (asks for `sudo`)
|
||||
|
||||
### Post-install
|
||||
|
||||
```bash
|
||||
# Verify
|
||||
drone systems
|
||||
|
||||
# Enter the DevPulse branch
|
||||
cd ~/Projects/AIPass/src/aipass/devpulse
|
||||
claude
|
||||
```
|
||||
|
||||
You should see DevPulse greet you, read its memory, and be ready.
|
||||
|
||||
### Optional
|
||||
|
||||
- Add API keys to `~/.secrets/aipass/.env` if you want LLM routing beyond Claude Code
|
||||
- Set `AIPASS_HOME=~/Projects/AIPass` in your shell rc if you plan to use AIPass from other projects
|
||||
- Add `export AIPASS_HOME=~/Projects/AIPass` to `~/.bashrc` **and** `~/.claude/settings.json` (the `env` section) — both are needed for full cross-project access
|
||||
|
||||
---
|
||||
|
||||
## macOS install
|
||||
|
||||
Same as Linux. `setup.sh` uses bash and runs on macOS out of the box.
|
||||
|
||||
**Caveats**:
|
||||
- `chmod 700` and `chown` work correctly on macOS's HFS+ and APFS
|
||||
- `sudo ln -sf /usr/local/bin/drone` works but may prompt for your admin password
|
||||
- Homebrew users: if you have multiple Python installs, make sure `python3` points to Python 3.10+ before running `setup.sh`
|
||||
|
||||
---
|
||||
|
||||
## Windows install
|
||||
|
||||
**Short version**: use [WSL2](https://learn.microsoft.com/en-us/windows/wsl/install) (Ubuntu) and follow the Linux instructions. Full native Windows support is landing in a PR soon — follow [issue #261](https://github.com/AIOSAI/AIPass/issues/261) for status.
|
||||
|
||||
**Why it's in progress**: the current `setup.sh` uses bash, `sudo`, and `ln -sf /usr/local/bin/drone`, none of which translate to Windows. The `aipass init` command also writes a shell loop into `.claude/settings.json` that assumes Unix root `/`. Fixes are in flight:
|
||||
- A cross-platform `setup.py` that replaces `setup.sh` on Windows
|
||||
- A Python-based directory traversal replacing the bash loop in `aipass init`
|
||||
- OS detection in `setup.sh` to skip the symlink step on Windows and print PATH instructions instead
|
||||
|
||||
**Interim workaround**: install WSL2 with an Ubuntu distribution, then clone and run `setup.sh` inside WSL. Claude Code also runs well inside WSL.
|
||||
|
||||
---
|
||||
|
||||
## Uninstall
|
||||
|
||||
### Full removal (Linux / macOS)
|
||||
|
||||
```bash
|
||||
# 1. Remove the venv and repo
|
||||
rm -rf ~/Projects/AIPass
|
||||
|
||||
# 2. Remove the global drone symlink
|
||||
sudo rm /usr/local/bin/drone
|
||||
|
||||
# 3. Remove secrets (if you won't reinstall)
|
||||
rm -rf ~/.secrets/aipass
|
||||
|
||||
# 4. Clean Claude Code hooks
|
||||
# Edit ~/.claude/settings.json and remove any "hooks" sections that reference AIPass paths.
|
||||
# Safer: back up the file first.
|
||||
cp ~/.claude/settings.json ~/.claude/settings.json.bak
|
||||
nano ~/.claude/settings.json # or your editor of choice
|
||||
|
||||
# 5. Clean your shell rc
|
||||
# Remove any AIPASS_HOME export from ~/.bashrc, ~/.zshrc, etc.
|
||||
```
|
||||
|
||||
### Partial removal (keeping secrets for reinstall)
|
||||
|
||||
Skip step 3 above. Your `~/.secrets/aipass/.env` will persist and be reused on next install.
|
||||
|
||||
### Windows (WSL2)
|
||||
|
||||
Same as Linux, inside the WSL distribution. To also remove the WSL distribution itself: `wsl --unregister Ubuntu` from PowerShell.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `drone: command not found`
|
||||
|
||||
Your venv is not activated or the `/usr/local/bin/drone` symlink is missing.
|
||||
|
||||
```bash
|
||||
# Option A: activate the venv
|
||||
source ~/Projects/AIPass/.venv/bin/activate
|
||||
drone systems
|
||||
|
||||
# Option B: run via full path
|
||||
~/Projects/AIPass/.venv/bin/drone systems
|
||||
|
||||
# Option C: reinstall the symlink
|
||||
sudo ln -sf ~/Projects/AIPass/.venv/bin/drone /usr/local/bin/drone
|
||||
```
|
||||
|
||||
### `AIPASS_HOME not set` warnings
|
||||
|
||||
```bash
|
||||
# In your shell rc (~/.bashrc or ~/.zshrc)
|
||||
export AIPASS_HOME=~/Projects/AIPass
|
||||
|
||||
# Then restart the shell or:
|
||||
source ~/.bashrc
|
||||
```
|
||||
|
||||
Also add it to `~/.claude/settings.json` under the `env` block for Claude Code sessions to pick it up.
|
||||
|
||||
### DevPulse greets you but doesn't read its memory
|
||||
|
||||
Check that `.trinity/passport.json`, `.trinity/local.json`, and `.trinity/observations.json` exist in `src/aipass/devpulse/`. If they don't, run `bash setup.sh` again to re-bootstrap the identity files.
|
||||
|
||||
### `drone @git system-pr` fails with a lock error
|
||||
|
||||
```bash
|
||||
drone @git lock # check the lock state
|
||||
drone @git fix # attempt to fix broken git state
|
||||
```
|
||||
|
||||
Do NOT use raw `git reset --hard` — merge conflicts are easier to resolve than lost work.
|
||||
|
||||
### Branch mail not arriving
|
||||
|
||||
```bash
|
||||
drone @ai_mail inbox # check your inbox
|
||||
drone @prax watch # watch the monitoring dashboard
|
||||
```
|
||||
|
||||
A known issue at the end of S90 affected wake delivery; see the wake investigation in DPLAN-0125 Track E if you're running a recent build.
|
||||
|
||||
### Tests fail on a fresh clone
|
||||
|
||||
```bash
|
||||
cd ~/Projects/AIPass
|
||||
source .venv/bin/activate
|
||||
python -m pytest src/aipass/<branch>/tests/
|
||||
```
|
||||
|
||||
If tests fail because `AIPASS_HOME` leaks the real registry into test results, that's a known pattern — the tests need `monkeypatch.delenv("AIPASS_HOME")`. See S90 notes for the fixture pattern.
|
||||
|
||||
### `.claude/settings.json` has hardcoded absolute paths
|
||||
|
||||
You pulled an old clone. The hardcoded paths were removed in commit `867dad0` (April 5, 2026). Pull the latest main and re-run `setup.sh`, which generates the settings dynamically from your local repo root.
|
||||
|
||||
---
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Purpose | Set where |
|
||||
|---|---|---|
|
||||
| `AIPASS_HOME` | Lets external projects find the AIPass registry | `~/.bashrc` + `~/.claude/settings.json` env block |
|
||||
| `AIPASS_CALLER_BRANCH` | Auto-set by dispatch; identifies the sending branch for feedback/mail | Runtime only, do not set manually |
|
||||
| `AIPASS_CALLER_CWD` | Auto-set by dispatch; identifies the caller's project directory | Runtime only, do not set manually |
|
||||
|
||||
Sensitive values (API keys, tokens, recovery codes) belong in `~/.secrets/aipass/.env`, not in shell rc or repo files.
|
||||
|
||||
---
|
||||
|
||||
## Reporting bugs
|
||||
|
||||
File issues at https://github.com/AIOSAI/AIPass/issues.
|
||||
|
||||
Helpful info to include:
|
||||
- OS and version
|
||||
- Python version (`python3 --version`)
|
||||
- Claude Code version (`claude --version`)
|
||||
- The exact command you ran and the full error output
|
||||
- Whether you cloned recently or have been on the same checkout for a while (clone age helps us distinguish current bugs from fixed-but-stale-clone issues)
|
||||
|
||||
The first external bug report was [#261 by Gavin Rooney](https://github.com/AIOSAI/AIPass/issues/261) — that template is a good example of a useful report.
|
||||
|
||||
---
|
||||
|
||||
## See also
|
||||
|
||||
- [DevPulse README](README.md) — the lean entry point
|
||||
- [AIPass root README](../../../README.md) — the whole framework
|
||||
- [STATUS.local.md](STATUS.local.md) — current work and loose ends
|
||||
- [issue #261](https://github.com/AIOSAI/AIPass/issues/261) — Windows compat tracking
|
||||
@@ -319,7 +319,7 @@ def _classify_exit(
|
||||
def watch_agent(
|
||||
agent_id: str,
|
||||
timeout_seconds: int = 600,
|
||||
poll_interval: float = 2.0,
|
||||
poll_interval: float = 5.0,
|
||||
) -> dict:
|
||||
"""Block until the dispatched agent at `agent_id` exits.
|
||||
|
||||
@@ -327,7 +327,10 @@ def watch_agent(
|
||||
agent_id: Branch token like ``@drone`` (or bare ``drone``).
|
||||
timeout_seconds: Maximum wait. Default 10 min — catches crashes + silent-finishes
|
||||
fast; long agent watches should pass an explicit ``--timeout``.
|
||||
poll_interval: Seconds between checks. Default 2.0.
|
||||
poll_interval: Seconds between checks. Default 5.0 — the per-tick work (lock
|
||||
stat, PID liveness, one-dir JSONL size scan) is cheap, so a tight cadence
|
||||
just burns CPU. 5s keeps completion latency invisible on multi-minute
|
||||
dispatches while the 120s stall threshold has ample resolution.
|
||||
|
||||
Returns:
|
||||
dict with keys: woke, reason, elapsed, agent_state, exit_code, agent_id.
|
||||
|
||||
@@ -50,11 +50,11 @@ HELP_TEXT = """\
|
||||
def print_introspection() -> None:
|
||||
"""Display module introspection info."""
|
||||
console.print()
|
||||
console.print("feedback Module")
|
||||
console.print("DevPulse personal feedback mailbox. Receives cross-project")
|
||||
console.print("feedback messages from any agent via drone routing.")
|
||||
console.print("[bold cyan]feedback Module[/bold cyan]")
|
||||
console.print("[dim]DevPulse personal feedback mailbox. Receives cross-project[/dim]")
|
||||
console.print("[dim]feedback messages from any agent via drone routing.[/dim]")
|
||||
console.print()
|
||||
console.print("Subcommands: inbox, view, reply, send, clear")
|
||||
console.print("[yellow]Subcommands:[/yellow] [cyan]inbox, view, reply, send, clear[/cyan]")
|
||||
console.print()
|
||||
|
||||
|
||||
|
||||
@@ -97,18 +97,18 @@ Examples:
|
||||
def print_introspection() -> None:
|
||||
"""Display module introspection info."""
|
||||
console.print()
|
||||
console.print("watchdog Module")
|
||||
console.print("Devpulse-local directed wake system. Wakes devpulse when a")
|
||||
console.print("watched condition fires (agent exit, timer, schedule).")
|
||||
console.print("[bold cyan]watchdog Module[/bold cyan]")
|
||||
console.print("[dim]Devpulse-local directed wake system. Wakes devpulse when a[/dim]")
|
||||
console.print("[dim]watched condition fires (agent exit, timer, schedule).[/dim]")
|
||||
console.print()
|
||||
console.print("Subcommands:")
|
||||
console.print("[yellow]Subcommands:[/yellow]")
|
||||
for sub in _VALID_SUBCOMMANDS:
|
||||
marker = "active" if sub in ("agent", "status") else f"phase {_PHASE_BY_SUB.get(sub, '?')}"
|
||||
console.print(f" {sub:<10} ({marker})")
|
||||
console.print(f" [cyan]{sub:<10}[/cyan] [dim]({marker})[/dim]")
|
||||
console.print()
|
||||
console.print("Connected Handlers:")
|
||||
console.print(" handlers/watchdog/")
|
||||
console.print(" - agent.py (watch_agent — block until dispatched agent exits)")
|
||||
console.print("[yellow]Connected Handlers:[/yellow]")
|
||||
console.print(" [cyan]handlers/watchdog/[/cyan]")
|
||||
console.print(" [dim]- agent.py (watch_agent — block until dispatched agent exits)[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
@@ -360,7 +360,8 @@ def _handle_agent(sub_args: List[str]) -> bool:
|
||||
if state == "completed_silent":
|
||||
console.print(
|
||||
f"watchdog: {agent_id} stopped (state={state}) -- CHECK DELIVERABLES. "
|
||||
f'Next: drone @ai_mail dispatch {agent_id} "check in" "You finished your last task but did not send a reply. '
|
||||
f'Next: drone @ai_mail dispatch {agent_id} "check in" '
|
||||
'"You finished your last task but did not send a reply. '
|
||||
f'Please reply with your results now via drone @ai_mail email @devpulse."'
|
||||
)
|
||||
elif state == "completed_replied":
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# DPLAN-0194 — P1 cross-OS e2e wiring harness PROTOTYPE (Linux/Docker dev loop).
|
||||
# Runs INSIDE a clean container with the repo bind-mounted read-only at /repo.
|
||||
# Builds a wheel, installs into a CLEAN venv, then asserts the 4 tiers:
|
||||
# T0 install+binaries T1 aipass init scaffold T2a synthetic hook fire T3 drone routing
|
||||
# Tolerant: never exits on first failure — runs every assertion so we see the full red/green ladder.
|
||||
#
|
||||
set -uo pipefail
|
||||
|
||||
P=0; F=0
|
||||
ok(){ echo " ok $1"; P=$((P+1)); }
|
||||
no(){ echo " XX $1"; F=$((F+1)); }
|
||||
chk(){ if eval "$2" >/dev/null 2>&1; then ok "$1"; else no "$1"; fi; }
|
||||
hdr(){ echo; echo "=== $1 ==="; }
|
||||
|
||||
SRC=~/src
|
||||
BUILDENV=/tmp/buildenv
|
||||
CLEANENV=/tmp/cleanenv
|
||||
DIST=/tmp/dist
|
||||
PY=$CLEANENV/bin/python
|
||||
AIPASS=$CLEANENV/bin/aipass
|
||||
DRONE=$CLEANENV/bin/drone
|
||||
|
||||
hdr "SETUP — copy repo (writable), build wheel"
|
||||
rm -rf "$SRC" "$DIST" "$BUILDENV" "$CLEANENV"
|
||||
cp -r /repo "$SRC" 2>/dev/null || true # .trinity memory files are perm-restricted; harmless, code copies fine
|
||||
cd "$SRC"
|
||||
# A real fresh-clone runs setup.sh to GENERATE the registry (the host's AIPASS_REGISTRY.json
|
||||
# is mode 0600 and won't copy across uids anyway). Synthesize a minimal one pointing at the
|
||||
# copied branches — faithful to what setup.sh produces, lets Tier 3 prove routing plumbing.
|
||||
cat > "$SRC/AIPASS_REGISTRY.json" <<JSON
|
||||
{ "metadata": { "name": "AIPASS", "version": "1.0.0", "total_branches": 2 },
|
||||
"branches": [
|
||||
{ "name": "drone", "path": "$SRC/src/aipass/drone" },
|
||||
{ "name": "seedgo", "path": "$SRC/src/aipass/seedgo" }
|
||||
] }
|
||||
JSON
|
||||
python3 -m venv "$BUILDENV"
|
||||
"$BUILDENV/bin/pip" -q install --upgrade pip build 2>&1 | tail -2
|
||||
echo " building wheel..."
|
||||
"$BUILDENV/bin/python" -m build --wheel --outdir "$DIST" . 2>&1 | tail -4
|
||||
WHEEL=$(ls "$DIST"/*.whl 2>/dev/null | head -1)
|
||||
echo " wheel: ${WHEEL:-<NONE>}"
|
||||
|
||||
hdr "TIER 0 — clean-venv wheel install + binaries"
|
||||
python3 -m venv "$CLEANENV"
|
||||
if [ -n "${WHEEL:-}" ]; then
|
||||
"$CLEANENV/bin/pip" -q install "$WHEEL" 2>&1 | tail -3
|
||||
fi
|
||||
chk "wheel built" "[ -n '${WHEEL:-}' ]"
|
||||
chk "clean venv has pip (not silent-broken venv, #495)" "[ -x '$CLEANENV/bin/pip' ]"
|
||||
chk "aipass console_script installed" "[ -x '$AIPASS' ]"
|
||||
chk "drone console_script installed" "[ -x '$DRONE' ]"
|
||||
chk "drone --version runs" "'$DRONE' --version"
|
||||
chk "aipass entrypoint imports (aipass init --help)" "'$AIPASS' init --help"
|
||||
|
||||
hdr "TIER 1 — aipass init scaffolds correctly"
|
||||
PROJ=/tmp/proj; rm -rf "$PROJ"
|
||||
# AIPASS_HOME left UNSET on purpose: tests core scaffold independent of venv/templates,
|
||||
# and sidesteps the .venv symlink (the symlink bug is a Windows-only failure — N/A on Linux).
|
||||
"$AIPASS" init "$PROJ" demo > /tmp/init.out 2>&1
|
||||
echo " init exit=$? (see /tmp/init.out)"; tail -3 /tmp/init.out | sed 's/^/ | /'
|
||||
chk "DEMO_REGISTRY.json exists" "[ -f '$PROJ/DEMO_REGISTRY.json' ]"
|
||||
chk "DEMO_REGISTRY.json is valid JSON" "jq -e . '$PROJ/DEMO_REGISTRY.json'"
|
||||
chk "registry metadata.name == DEMO" "[ \"\$(jq -r .metadata.name '$PROJ/DEMO_REGISTRY.json')\" = DEMO ]"
|
||||
chk ".claude/settings.json exists" "[ -f '$PROJ/.claude/settings.json' ]"
|
||||
chk "settings deny has EnterPlanMode" "jq -e '.permissions.deny|index(\"EnterPlanMode\")' '$PROJ/.claude/settings.json'"
|
||||
chk "src/demo/__init__.py exists" "[ -f '$PROJ/src/demo/__init__.py' ]"
|
||||
chk ".gitignore mentions .venv" "grep -q '.venv' '$PROJ/.gitignore'"
|
||||
chk ".trinity/ NOT created (projects!=citizens)" "[ ! -e '$PROJ/.trinity' ]"
|
||||
chk "no passport.json created" "[ ! -e '$PROJ/.trinity/passport.json' ]"
|
||||
|
||||
hdr "TIER 2a — synthetic hook fire (module form, sentinel UUID, engine.jsonl)"
|
||||
HOOKP=/tmp/hookproj; rm -rf "$HOOKP"; mkdir -p "$HOOKP/.aipass"
|
||||
# minimal isolated config: ONLY rm_gate enabled -> no git_gate/sound noise
|
||||
cat > "$HOOKP/.aipass/hooks.json" <<'JSON'
|
||||
{ "hooks_enabled": true,
|
||||
"PreToolUse": {
|
||||
"rm_gate": { "enabled": true, "handler": "aipass.hooks.apps.handlers.security.rm_gate.handle", "matcher": "Bash" }
|
||||
} }
|
||||
JSON
|
||||
UUID="PROTOUUID12345"
|
||||
LOG=$(find "$CLEANENV" -path '*/aipass/hooks/logs/engine.jsonl' 2>/dev/null | head -1)
|
||||
LOGDIR=$(dirname "$(find "$CLEANENV" -path '*/aipass/hooks' -type d 2>/dev/null | head -1)")
|
||||
[ -n "$LOG" ] && : > "$LOG" # truncate if present
|
||||
# fire: rm -rf -> expect block (exit 2)
|
||||
OUT=$(cd "$HOOKP" && echo "{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"rm -rf /tmp/x\"},\"agent_id\":\"$UUID\"}" \
|
||||
| AIPASS_HOME="$HOOKP" "$PY" -m aipass.hooks.apps.handlers.bridges.claude "PreToolUse:rm_gate" 2>/tmp/hook.err)
|
||||
HX=$?
|
||||
# relocate LOG now if it didn't exist before
|
||||
[ -z "$LOG" ] && LOG=$(find "$CLEANENV" -path '*/aipass/hooks/logs/engine.jsonl' 2>/dev/null | head -1)
|
||||
printf '%s' "$OUT" > /tmp/hook.out # write to file: never eval-interpolate captured JSON
|
||||
echo " hook exit=$HX stdout=${OUT:0:80}"
|
||||
[ -s /tmp/hook.err ] && echo " stderr: $(head -1 /tmp/hook.err)"
|
||||
# REAL contract (discovered by prototype): rm_gate blocks via {"decision":"block"} on STDOUT, exit 0 — NOT exit 2.
|
||||
chk "rm_gate decision==block (stdout JSON)" "jq -e '.decision==\"block\"' /tmp/hook.out"
|
||||
chk "bridge exit 0 (block via JSON not code)" "[ '$HX' = 0 ]"
|
||||
chk "engine.jsonl exists" "[ -n '$LOG' ] && [ -f '$LOG' ]"
|
||||
chk "engine.jsonl logged sentinel UUID" "[ -n '$LOG' ] && grep -q '$UUID' '$LOG'"
|
||||
chk "logged record hook==rm_gate" "[ -n '$LOG' ] && grep '$UUID' '$LOG' | grep -q rm_gate"
|
||||
# negative: harmless echo -> allow (exit 0)
|
||||
OUT2=$(cd "$HOOKP" && echo "{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"echo hi\"},\"agent_id\":\"$UUID-neg\"}" \
|
||||
| AIPASS_HOME="$HOOKP" "$PY" -m aipass.hooks.apps.handlers.bridges.claude "PreToolUse:rm_gate" 2>/dev/null)
|
||||
HX2=$?
|
||||
chk "rm_gate allows echo (exit 0)" "[ '$HX2' = 0 ]"
|
||||
|
||||
hdr "TIER 3 — drone routing (against real repo registry)"
|
||||
chk "drone systems runs (reads registry)" "cd '$SRC' && '$DRONE' systems"
|
||||
chk "drone systems lists a known branch" "cd '$SRC' && '$DRONE' systems 2>/dev/null | grep -qi seedgo"
|
||||
chk "drone @drone --help routes" "cd '$SRC' && '$DRONE' @drone --help"
|
||||
|
||||
hdr "RESULT"
|
||||
echo " PASS=$P FAIL=$F"
|
||||
[ "$F" -eq 0 ] && echo " ALL GREEN" || echo " $F red — that's the truth we wanted"
|
||||
exit 0
|
||||
@@ -140,8 +140,8 @@ class TestEscapedQuoteBypass:
|
||||
assert _is_blocked(_bash(cmd)) == should_block, f"{'Should block' if should_block else 'Should allow'}: {cmd}"
|
||||
|
||||
|
||||
class TestReadOnlyBlocked:
|
||||
"""New handler blocks ALL raw git — read-only included. Use drone."""
|
||||
class TestReadVerbsAllowed:
|
||||
"""Read-only git verbs in the allowlist run raw (S193, DPLAN-0195)."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cmd",
|
||||
@@ -149,15 +149,49 @@ class TestReadOnlyBlocked:
|
||||
"git status",
|
||||
"git log --oneline",
|
||||
"git diff",
|
||||
"git show HEAD",
|
||||
"git ls-files",
|
||||
"git ls-tree HEAD",
|
||||
"git rev-parse --show-toplevel",
|
||||
"git blame README.md",
|
||||
"git grep TODO",
|
||||
"git archive HEAD",
|
||||
"git for-each-ref",
|
||||
"git -C /tmp/x log",
|
||||
],
|
||||
)
|
||||
def test_allows_read_verbs(self, cmd):
|
||||
"""Allowlisted read verbs are not blocked — raw is fine."""
|
||||
assert not _is_blocked(_bash(cmd)), f"Read verb should be allowed: {cmd}"
|
||||
|
||||
|
||||
class TestNonAllowlistedGitBlocked:
|
||||
"""Git verbs outside the read allowlist stay blocked — conservative."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cmd",
|
||||
[
|
||||
"git fetch",
|
||||
"git branch",
|
||||
"git tag",
|
||||
"git remote -v",
|
||||
],
|
||||
)
|
||||
def test_blocks_read_only_raw_git(self, cmd):
|
||||
"""Read-only raw git is also blocked — use drone instead."""
|
||||
assert _is_blocked(_bash(cmd)), f"Should block raw git (use drone): {cmd}"
|
||||
def test_blocks_non_allowlisted(self, cmd):
|
||||
"""Reads not on the allowlist (fetch/branch/tag/remote) still block."""
|
||||
assert _is_blocked(_bash(cmd)), f"Should block (use drone): {cmd}"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cmd",
|
||||
[
|
||||
"git log && git push",
|
||||
"git status; git commit -m x",
|
||||
"git diff | git apply",
|
||||
],
|
||||
)
|
||||
def test_blocks_chained_read_then_write(self, cmd):
|
||||
"""A read chained with a write blocks the whole command."""
|
||||
assert _is_blocked(_bash(cmd)), f"Chained read+write should block: {cmd}"
|
||||
|
||||
|
||||
class TestDroneNotBlocked:
|
||||
|
||||
@@ -158,6 +158,92 @@
|
||||
"standard": "architecture",
|
||||
"reason": "Test file — intentionally outside 3-layer structure. tests/ is a peer of apps/, not part of it."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_rm.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test file — intentionally outside 3-layer structure. tests/ is a peer of apps/, not part of it."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_rm.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Test file imports rm_handler directly to test its public interface. Unit tests require direct access to implementation components."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_rm.py",
|
||||
"standard": "documentation",
|
||||
"reason": "Test class docstrings describe intent; per-method docstrings would be noise for assertion-named test methods."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_rm.py",
|
||||
"standard": "meta",
|
||||
"reason": "Test file — META blocks are for production source files, not test suites."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_rm.py",
|
||||
"standard": "trigger",
|
||||
"reason": "Test file exercises .unlink() to verify deletion behavior — not a production file operation requiring trigger events."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_broker.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test file — intentionally outside 3-layer structure. tests/ is a peer of apps/, not part of it."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_broker.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Test file imports broker handlers directly to test their public interface. Unit tests require direct access to implementation components."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_broker.py",
|
||||
"standard": "trigger",
|
||||
"reason": "Test file exercises .unlink() to clean up test symlinks — not a production file operation requiring trigger events."
|
||||
},
|
||||
{
|
||||
"file": "artifacts/broker_acceptance_test.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Acceptance test artifact — standalone demo script, not part of 3-layer production structure."
|
||||
},
|
||||
{
|
||||
"file": "artifacts/broker_acceptance_test.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Acceptance test imports handlers directly to verify broker daemon behavior end-to-end."
|
||||
},
|
||||
{
|
||||
"file": "artifacts/broker_acceptance_test.py",
|
||||
"standard": "documentation",
|
||||
"reason": "Acceptance test script — main() is self-documenting via module docstring and inline comments."
|
||||
},
|
||||
{
|
||||
"file": "artifacts/broker_acceptance_test.py",
|
||||
"standard": "imports",
|
||||
"reason": "Acceptance test script uses sys.path.insert to locate the package from the artifacts/ directory."
|
||||
},
|
||||
{
|
||||
"file": "artifacts/broker_acceptance_test.py",
|
||||
"standard": "help_text",
|
||||
"reason": "Docstring run instruction shows how to invoke the script — not a production help text."
|
||||
},
|
||||
{
|
||||
"file": "artifacts/broker_acceptance_test.py",
|
||||
"standard": "meta",
|
||||
"reason": "Acceptance test artifact — META blocks are for production source files."
|
||||
},
|
||||
{
|
||||
"file": "artifacts/broker_acceptance_test.py",
|
||||
"standard": "trigger",
|
||||
"reason": "Acceptance test exercises .unlink() to clean up test symlinks — not production file operations."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_broker.py",
|
||||
"standard": "windows_compat",
|
||||
"lines": [556],
|
||||
"reason": "stat.S_IMODE() guarded by os.name != 'posix' skip at runtime. POSIX-only secret permission test."
|
||||
},
|
||||
{
|
||||
"file": "artifacts/broker_acceptance_test.py",
|
||||
"standard": "unused_function",
|
||||
"reason": "Standalone acceptance demo runner — helper functions invoked from the demo main, not a production module (same pattern as the other demo bypasses)."
|
||||
},
|
||||
{
|
||||
"file": "CLAUDE.md",
|
||||
"standard": "architecture",
|
||||
@@ -174,6 +260,18 @@
|
||||
"standard": "unused_function",
|
||||
"lines": [108],
|
||||
"reason": "create_pr() is deprecated per FPLAN-0210 — pr command blocked at auth tier. Handler kept for backwards compatibility; still tested in test_git_module.py."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/git_module.py",
|
||||
"standard": "unused_function",
|
||||
"lines": [655],
|
||||
"reason": "get_introspective() called dynamically via getattr() by module_registry_handler.py:219 for internal module introspection. Also tested in test_git_module, test_system_pr, test_devpulse_plugins, test_git_access."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/broker/daemon.py",
|
||||
"standard": "unused_function",
|
||||
"lines": [422],
|
||||
"reason": "Threaded broker entrypoint, exercised by tests/test_broker.py; production uses blocking start(); intentionally not called in shipped non-test code."
|
||||
}
|
||||
],
|
||||
"notes": {
|
||||
|
||||
@@ -143,7 +143,8 @@ drone/
|
||||
│ │ ├── registry.py # Registry query operations
|
||||
│ │ ├── commands.py # Custom command shortcut orchestrator
|
||||
│ │ ├── git_module.py # Git workflow (tier-based access, 16 commands)
|
||||
│ │ └── scan.py # Branch command scanning
|
||||
│ │ ├── scan.py # Branch command scanning
|
||||
│ │ └── broker.py # Broker daemon orchestrator (sandbox delete)
|
||||
│ ├── handlers/ # Implementation details
|
||||
│ │ ├── executor.py # Safe subprocess execution (timeout, no shell)
|
||||
│ │ ├── exceptions.py # Exception hierarchy (10 exception types)
|
||||
@@ -153,6 +154,11 @@ drone/
|
||||
│ │ ├── module_registry_handler.py # Module loading (internal + external)
|
||||
│ │ ├── generic_adapter.py # StringIO capture for external modules
|
||||
│ │ ├── routing_config.json # External module declarations
|
||||
│ │ ├── broker/
|
||||
│ │ │ ├── daemon.py # Broker daemon (unix socket, openat2, audit)
|
||||
│ │ │ ├── client.py # Broker client (inherited fd transport)
|
||||
│ │ │ ├── path_resolver.py # openat2 RESOLVE_BENEATH path resolution
|
||||
│ │ │ └── protocol.py # Typed JSON-line IPC (BrokerRequest/Response)
|
||||
│ │ ├── json/
|
||||
│ │ │ └── json_handler.py # Structured operation logging
|
||||
│ │ ├── scanning/
|
||||
@@ -187,7 +193,8 @@ drone/
|
||||
│ └── hook_sounds_plugin.py.disabled
|
||||
├── docs/ # Public documentation
|
||||
├── docs.local/ # Investigation reports and policies
|
||||
└── tests/ # 704 tests across 21 test files
|
||||
├── artifacts/ # Live acceptance test scripts
|
||||
└── tests/ # 807 tests across 22 test files
|
||||
```
|
||||
|
||||
### Routing Flow
|
||||
@@ -243,6 +250,14 @@ By default, drone captures subprocess output (`capture_output=True`) with a 30s
|
||||
|
||||
Commands in the interactive tuple bypass capture and inherit the terminal directly — enabling live Rich output, colors, and no timeout.
|
||||
|
||||
**Always interactive** — these presentational commands always inherit the terminal for Rich color on a TTY, plain when piped:
|
||||
|
||||
| Pattern | Reason |
|
||||
|----------------|---------------------------------------------|
|
||||
| `@branch` | No-args introspection (branch overview) |
|
||||
| `@branch --help` | Help output with Rich formatting |
|
||||
| `@branch -h` | Short help flag (same as --help) |
|
||||
|
||||
**Per-command allowlist** (in `apps/drone.py`):
|
||||
|
||||
| Command | Reason |
|
||||
@@ -250,6 +265,7 @@ Commands in the interactive tuple bypass capture and inherit the terminal direct
|
||||
| `monitor` | Prax real-time monitoring (live TUI) |
|
||||
| `audit` | Seedgo audit (Rich progress bars) |
|
||||
| `watchdog` | Devpulse watchdog (live monitoring) |
|
||||
| `status` | Branch status with Rich formatted output |
|
||||
|
||||
**Per-branch allowlist** — all commands from these branches get interactive mode:
|
||||
|
||||
@@ -316,7 +332,7 @@ Tip: set AIPASS_HOME=/path/to/AIPass to access all branches
|
||||
|
||||
## Testing
|
||||
|
||||
704 tests across 21 test files, covering all layers:
|
||||
807 tests across 22 test files, covering all layers:
|
||||
|
||||
| Area | Files | Tests |
|
||||
|------|-------|-------|
|
||||
@@ -324,7 +340,8 @@ Tip: set AIPASS_HOME=/path/to/AIPass to access all branches
|
||||
| Git operations | `test_git_module.py`, `test_system_pr.py`, `test_devpulse_plugins.py`, `test_git_access.py` | ~150 |
|
||||
| Handlers | `test_executor.py`, `test_registry_handler.py`, `test_discovery.py` | ~99 |
|
||||
| Infrastructure | `test_generic_adapter.py`, `test_module_registry.py`, `test_config.py` | ~66 |
|
||||
| Features | `test_commands.py`, `test_scan.py`, `test_json_handler.py` | ~125 |
|
||||
| Features | `test_commands.py`, `test_scan.py`, `test_json_handler.py`, `test_rm.py` | ~181 |
|
||||
| Broker | `test_broker.py` | ~55 |
|
||||
| Standards | `test_cli_routing.py`, `test_contracts.py`, `test_error_resilience.py`, `test_init_provisioning.py` | ~21 |
|
||||
|
||||
Run tests: `cd src/aipass/drone && python -m pytest tests/ -q`
|
||||
@@ -339,7 +356,7 @@ Run tests: `cd src/aipass/drone && python -m pytest tests/ -q`
|
||||
|
||||
---
|
||||
|
||||
**Seedgo:** 99% | **Tests:** 704 pass, 4 skip | **Last Updated:** 2026-05-12
|
||||
**Seedgo:** 100% | **Tests:** 830 pass, 4 skip | **Last Updated:** 2026-06-10
|
||||
|
||||
---
|
||||
[← Back to AIPass](../../../README.md)
|
||||
|
||||
@@ -25,7 +25,6 @@ from rich.text import Text
|
||||
from aipass.prax import logger
|
||||
from aipass.cli.apps.modules import console, err_console
|
||||
from aipass.drone.apps.modules import BranchNotFoundError, CommandExecutionError, RegistryError
|
||||
from aipass.drone.apps.modules.discovery import get_help
|
||||
from aipass.drone.apps.modules.resolver import get_all_branches
|
||||
from aipass.drone.apps.modules.router import route_command
|
||||
from aipass.drone.apps.modules.module_registry import (
|
||||
@@ -41,7 +40,7 @@ VERSION = "1.1.0"
|
||||
MODULES_DIR = Path(__file__).parent / "modules"
|
||||
|
||||
# Interactive mode — commands/branches that bypass capture + timeout for live terminal output.
|
||||
INTERACTIVE_COMMANDS = ("monitor", "audit", "watchdog")
|
||||
INTERACTIVE_COMMANDS = ("monitor", "audit", "watchdog", "status")
|
||||
INTERACTIVE_BRANCHES = ("cli",)
|
||||
|
||||
|
||||
@@ -85,6 +84,7 @@ def show_help() -> None:
|
||||
table.add_row("activate @target", "Register all commands from a branch")
|
||||
table.add_row("list", "List registered custom commands")
|
||||
table.add_row("remove <name>", "Remove a custom command")
|
||||
table.add_row("rm <path> [<path>...]", "Contained safe-delete (project + tmp)")
|
||||
table.add_row("--help", "Show this help")
|
||||
table.add_row("--version", "Show version")
|
||||
|
||||
@@ -110,12 +110,7 @@ def print_help() -> None:
|
||||
|
||||
|
||||
def print_introspection() -> None:
|
||||
"""Alias for seedgo standard compliance (audit expects print_introspection)."""
|
||||
show_introspection()
|
||||
|
||||
|
||||
def show_introspection() -> None:
|
||||
"""Show discovery view (no args) — auto-discovers modules."""
|
||||
"""Display branch overview — auto-discovers modules."""
|
||||
console.print()
|
||||
console.print("[bold cyan]Drone - Command Router & Discovery[/bold cyan]")
|
||||
console.print()
|
||||
@@ -310,6 +305,18 @@ def _handle_remove(name: str) -> int:
|
||||
return 0 if success else 1
|
||||
|
||||
|
||||
def _handle_rm(args: list[str]) -> int:
|
||||
"""Handle ``drone rm <path> [<path>...]`` — contained safe-delete."""
|
||||
from aipass.drone.apps.modules.rm import handle_command, print_help
|
||||
|
||||
if not args or args[0] in ("--help", "-h"):
|
||||
print_help()
|
||||
return 0
|
||||
|
||||
result = handle_command(args[0], args[1:] if len(args) > 1 else None)
|
||||
return 0 if result else 1
|
||||
|
||||
|
||||
def _handle_custom_command(args: list[str]) -> int:
|
||||
"""Handle a custom command shortcut by matching and routing.
|
||||
|
||||
@@ -400,8 +407,9 @@ def _handle_target(args: List[str]) -> int:
|
||||
rest = args[1:]
|
||||
module_name = target.lstrip("@").lower()
|
||||
|
||||
first_cmd = rest[0] if rest and rest[0] != "--help" else None
|
||||
needs_interactive = first_cmd in INTERACTIVE_COMMANDS or module_name in INTERACTIVE_BRANCHES
|
||||
first_cmd = rest[0] if rest and rest[0] not in ("--help", "-h") else None
|
||||
is_presentational = not rest or first_cmd is None
|
||||
needs_interactive = is_presentational or first_cmd in INTERACTIVE_COMMANDS or module_name in INTERACTIVE_BRANCHES
|
||||
|
||||
# Route to internal module — unless command needs interactive terminal,
|
||||
# in which case fall through to branch (subprocess) routing so Rich
|
||||
@@ -409,10 +417,10 @@ def _handle_target(args: List[str]) -> int:
|
||||
if is_module(module_name) and not needs_interactive:
|
||||
return _handle_module(module_name, rest)
|
||||
|
||||
# No args = pass through to branch (introspection)
|
||||
# No args = pass through to branch (introspection — inherit terminal for color)
|
||||
if not rest:
|
||||
try:
|
||||
result = route_command(target)
|
||||
result = route_command(target, interactive=True)
|
||||
except (BranchNotFoundError, CommandExecutionError, RegistryError) as exc:
|
||||
if isinstance(exc, BranchNotFoundError) and is_module(module_name):
|
||||
logger.info("Falling back to module routing for @%s (not in local registry)", module_name)
|
||||
@@ -422,30 +430,22 @@ def _handle_target(args: List[str]) -> int:
|
||||
if isinstance(exc, BranchNotFoundError) and not os.environ.get("AIPASS_HOME"):
|
||||
err_console.print(" Tip: set AIPASS_HOME=/path/to/AIPass to access core branches.")
|
||||
return 1
|
||||
if result.stdout:
|
||||
console.print(result.stdout, end="", highlight=False)
|
||||
if result.stderr:
|
||||
err_console.print(result.stderr, end="", highlight=False)
|
||||
return result.exit_code
|
||||
|
||||
# --help = show help
|
||||
if rest == ["--help"]:
|
||||
# --help / -h = help (inherit terminal for color)
|
||||
if rest in (["--help"], ["-h"]):
|
||||
try:
|
||||
result = get_help(target)
|
||||
if result.text:
|
||||
console.print(result.text, end="", highlight=False)
|
||||
else:
|
||||
console.print(f"No help available for {target}.")
|
||||
result = route_command(target, rest[0], interactive=True)
|
||||
except (BranchNotFoundError, CommandExecutionError, RegistryError) as exc:
|
||||
if isinstance(exc, BranchNotFoundError) and is_module(module_name):
|
||||
logger.info("Falling back to module routing for @%s --help (not in local registry)", module_name)
|
||||
logger.info("Falling back to module routing for @%s %s (not in local registry)", module_name, rest[0])
|
||||
return _handle_module(module_name, rest)
|
||||
logger.warning("Help lookup failed for %s: %s", target, exc)
|
||||
err_console.print(f"drone: {exc}")
|
||||
if isinstance(exc, BranchNotFoundError) and not os.environ.get("AIPASS_HOME"):
|
||||
err_console.print(" Tip: set AIPASS_HOME=/path/to/AIPass to access core branches.")
|
||||
return 1
|
||||
return 0
|
||||
return result.exit_code
|
||||
|
||||
# drone @branch command [args...]
|
||||
command = rest[0]
|
||||
@@ -557,6 +557,10 @@ def main() -> int:
|
||||
return 1
|
||||
return _handle_remove(args[1])
|
||||
|
||||
# rm — contained safe-delete
|
||||
if command == "rm":
|
||||
return _handle_rm(args[1:])
|
||||
|
||||
# @target — route to branch or module
|
||||
if command.startswith("@"):
|
||||
return _handle_target(args)
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Broker handler package — privileged delete daemon for sandboxed agents."""
|
||||
|
||||
from .protocol import BrokerRequest as BrokerRequest # noqa: F401
|
||||
from .protocol import BrokerResponse as BrokerResponse # noqa: F401
|
||||
from .path_resolver import resolve_beneath as resolve_beneath # noqa: F401
|
||||
from .daemon import BrokerDaemon as BrokerDaemon # noqa: F401
|
||||
from .client import broker_delete as broker_delete # noqa: F401
|
||||
from .client import create_identified_connection as create_identified_connection # noqa: F401
|
||||
@@ -0,0 +1,154 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: client.py
|
||||
# Description: Broker client — sends delete requests over inherited fd
|
||||
# Version: 2.0.0
|
||||
# Created: 2026-06-09
|
||||
# Modified: 2026-06-10
|
||||
# =============================================
|
||||
|
||||
"""Broker client — sends delete requests over an inherited socket fd.
|
||||
|
||||
When ``AIPASS_BROKER_FD`` is set, ``drone rm`` uses this client to send
|
||||
delete requests to the out-of-sandbox broker daemon instead of calling
|
||||
``Path.unlink`` directly. The fd was pre-opened by the launch wrapper
|
||||
before the sandbox locked.
|
||||
|
||||
Launcher contract (Phase 6a):
|
||||
``create_identified_connection()`` connects to the broker socket,
|
||||
reads the per-start secret, computes the HMAC, sends the identify
|
||||
preamble, and returns the authenticated socket. The caller passes
|
||||
the socket's fd to the sandboxed child via AIPASS_BROKER_FD.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac as hmac_mod
|
||||
import os
|
||||
import socket
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.drone.apps.handlers.json import json_handler
|
||||
from aipass.drone.apps.handlers.broker.protocol import BrokerRequest, BrokerResponse
|
||||
|
||||
BROKER_FD_ENV = "AIPASS_BROKER_FD"
|
||||
|
||||
|
||||
def is_sandboxed() -> bool:
|
||||
"""Return True if running inside a sandbox with a broker fd available."""
|
||||
return BROKER_FD_ENV in os.environ
|
||||
|
||||
|
||||
def _get_broker_fd() -> int | None:
|
||||
"""Return the inherited broker socket fd, or None if not set."""
|
||||
raw = os.environ.get(BROKER_FD_ENV)
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
fd = int(raw)
|
||||
if fd < 0:
|
||||
logger.warning("broker client: invalid fd %d", fd)
|
||||
return None
|
||||
return fd
|
||||
except ValueError:
|
||||
logger.warning("broker client: non-integer AIPASS_BROKER_FD=%s", raw)
|
||||
return None
|
||||
|
||||
|
||||
def create_identified_connection(
|
||||
socket_path: str | Path,
|
||||
secret_path: str | Path,
|
||||
branch: str,
|
||||
) -> socket.socket:
|
||||
"""Connect to the broker, authenticate via HMAC, return the identified socket.
|
||||
|
||||
This is the launcher contract for dispatch_monitor. The returned
|
||||
socket fd should be passed to the sandboxed child via AIPASS_BROKER_FD.
|
||||
|
||||
Args:
|
||||
socket_path: Path to the broker's unix socket.
|
||||
secret_path: Path to the broker's per-start secret file (mode 0600).
|
||||
branch: Branch name to identify as.
|
||||
|
||||
Returns:
|
||||
A connected, identified ``socket.socket``.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If identification fails (bad HMAC, broker error).
|
||||
OSError: If the socket or secret file cannot be accessed.
|
||||
"""
|
||||
secret = Path(secret_path).read_bytes()
|
||||
mac = hmac_mod.new(secret, branch.encode(), hashlib.sha256).hexdigest()
|
||||
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.connect(str(socket_path))
|
||||
|
||||
req = BrokerRequest(op="identify", branch=branch, hmac=mac)
|
||||
sock.sendall(req.to_bytes())
|
||||
|
||||
data = b""
|
||||
while b"\n" not in data:
|
||||
chunk = sock.recv(4096)
|
||||
if not chunk:
|
||||
sock.close()
|
||||
raise RuntimeError("Broker closed connection during identify")
|
||||
data += chunk
|
||||
|
||||
resp = BrokerResponse.from_bytes(data)
|
||||
if not resp.ok:
|
||||
sock.close()
|
||||
raise RuntimeError(f"Broker identify failed: {resp.message}")
|
||||
|
||||
logger.info("broker client: identified as %s", branch)
|
||||
json_handler.log_operation("broker_identify", {"branch": branch})
|
||||
return sock
|
||||
|
||||
|
||||
def broker_delete(path: str) -> tuple[bool, str]:
|
||||
"""Send a delete request to the broker over the inherited fd.
|
||||
|
||||
Returns ``(success, message)`` matching the pattern in ``rm_handler.safe_delete``.
|
||||
"""
|
||||
fd = _get_broker_fd()
|
||||
if fd is None:
|
||||
return False, "Broker fd not available (AIPASS_BROKER_FD not set)"
|
||||
|
||||
request_id = uuid.uuid4().hex[:8]
|
||||
req = BrokerRequest(op="delete", path=path, request_id=request_id)
|
||||
json_handler.log_operation(
|
||||
"broker_delete_request",
|
||||
{"path": path, "fd": fd, "request_id": request_id},
|
||||
)
|
||||
|
||||
try:
|
||||
sock = socket.socket(fileno=fd)
|
||||
sock.setblocking(True)
|
||||
try:
|
||||
sock.sendall(req.to_bytes())
|
||||
|
||||
data = b""
|
||||
while b"\n" not in data:
|
||||
chunk = sock.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
|
||||
if not data.strip():
|
||||
logger.error("broker client: empty response from broker")
|
||||
return False, "Broker returned empty response"
|
||||
|
||||
resp = BrokerResponse.from_bytes(data)
|
||||
logger.info(
|
||||
"broker client: %s for %s: %s",
|
||||
"ok" if resp.ok else "refused",
|
||||
path,
|
||||
resp.message,
|
||||
)
|
||||
return resp.ok, resp.message
|
||||
finally:
|
||||
sock.detach()
|
||||
except OSError as exc:
|
||||
logger.error("broker client: socket error for %s: %s", path, exc)
|
||||
return False, f"Broker communication failed: {exc}"
|
||||
@@ -0,0 +1,445 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: daemon.py
|
||||
# Description: Broker daemon — privileged deleter for sandboxed agents
|
||||
# Version: 2.0.0
|
||||
# Created: 2026-06-09
|
||||
# Modified: 2026-06-10
|
||||
# =============================================
|
||||
|
||||
"""Broker daemon — privileged deleter for sandboxed agents.
|
||||
|
||||
A long-lived process that listens on a unix socket, accepts delete requests
|
||||
from sandboxed ``drone rm`` clients, re-resolves paths via openat2
|
||||
RESOLVE_BENEATH (never trusting agent-supplied strings), applies
|
||||
identity-bound allowlist policy, performs the delete, and audit-logs
|
||||
every attempt.
|
||||
|
||||
Identity model (Phase 6a):
|
||||
The launcher pre-connects, authenticates with an HMAC derived from a
|
||||
per-start secret, declares the branch identity, then passes the
|
||||
connected fd to the sandboxed child. The child inherits an already-
|
||||
identified connection. Connections that never identify get the
|
||||
narrowest scope (/tmp only).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac as hmac_mod
|
||||
import json
|
||||
import secrets
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.drone.apps.handlers.json import json_handler
|
||||
from aipass.drone.apps.handlers.broker.protocol import BrokerRequest, BrokerResponse
|
||||
from aipass.drone.apps.handlers.broker.path_resolver import resolve_beneath
|
||||
|
||||
_DEFAULT_SOCKET_DIR = ".ai_central"
|
||||
_SOCKET_NAME = "drone_broker.sock"
|
||||
_AUDIT_LOG_NAME = "drone_broker_audit.jsonl"
|
||||
_SECRET_NAME = "broker_secret"
|
||||
|
||||
_DENYLIST_DIRS = frozenset((".git", ".trinity", ".aipass", ".codex", ".agents"))
|
||||
|
||||
_TMP_BASES = (Path("/tmp"), Path("/var/tmp"))
|
||||
|
||||
|
||||
def _find_project_root() -> Path | None:
|
||||
"""Walk up from CWD to find *_REGISTRY.json; return its parent as project root."""
|
||||
import os
|
||||
|
||||
cwd = Path.cwd()
|
||||
for parent in [cwd, *cwd.parents]:
|
||||
if list(parent.glob("*_REGISTRY.json")):
|
||||
return parent.resolve()
|
||||
aipass_home = os.environ.get("AIPASS_HOME")
|
||||
if aipass_home:
|
||||
home = Path(aipass_home)
|
||||
if home.is_dir() and list(home.glob("*_REGISTRY.json")):
|
||||
return home.resolve()
|
||||
return None
|
||||
|
||||
|
||||
def _default_socket_path() -> Path:
|
||||
"""Return the default broker socket path under the repo root."""
|
||||
root = _find_project_root()
|
||||
if root is None:
|
||||
return Path("/tmp") / _SOCKET_NAME
|
||||
return root / _DEFAULT_SOCKET_DIR / _SOCKET_NAME
|
||||
|
||||
|
||||
def _default_audit_path() -> Path:
|
||||
"""Return the default audit log path."""
|
||||
root = _find_project_root()
|
||||
if root is None:
|
||||
return Path("/tmp") / _AUDIT_LOG_NAME
|
||||
return root / _DEFAULT_SOCKET_DIR / _AUDIT_LOG_NAME
|
||||
|
||||
|
||||
def _default_secret_path() -> Path:
|
||||
"""Return the default secret path."""
|
||||
root = _find_project_root()
|
||||
if root is None:
|
||||
return Path("/tmp") / _SECRET_NAME
|
||||
return root / _DEFAULT_SOCKET_DIR / _SECRET_NAME
|
||||
|
||||
|
||||
class BrokerDaemon:
|
||||
"""Out-of-sandbox delete broker with identity-bound allowlist policy.
|
||||
|
||||
Listens on a unix socket, optionally authenticates connections via
|
||||
HMAC, then validates delete requests via openat2 path re-resolution,
|
||||
identity-scoped allowlist, and a denylist backstop before performing
|
||||
``os.unlink`` / ``shutil.rmtree``.
|
||||
|
||||
Identity scopes:
|
||||
None (unidentified): /tmp, /var/tmp only.
|
||||
Builder branch: /tmp, /var/tmp, + own tree ($REPO/src/aipass/<branch>/).
|
||||
devpulse: /tmp, /var/tmp, + anywhere under $REPO.
|
||||
Denylist backstop (.git, .trinity, .aipass, .codex, .agents) always applies.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repo_root: Path | None = None,
|
||||
socket_path: Path | None = None,
|
||||
audit_path: Path | None = None,
|
||||
secret_path: Path | None = None,
|
||||
) -> None:
|
||||
"""Initialize the broker.
|
||||
|
||||
Args:
|
||||
repo_root: Project root directory. Auto-discovered if not set.
|
||||
socket_path: Where to bind the unix socket.
|
||||
audit_path: Where to write the JSONL audit log.
|
||||
secret_path: Where to write the per-start HMAC secret.
|
||||
"""
|
||||
self._repo_root = repo_root.resolve() if repo_root else _find_project_root()
|
||||
self.socket_path = socket_path or _default_socket_path()
|
||||
self.audit_path = audit_path or _default_audit_path()
|
||||
self._secret_path = secret_path or _default_secret_path()
|
||||
self._secret: bytes = b""
|
||||
self._server: socket.socket | None = None
|
||||
self._running = False
|
||||
self._listening = threading.Event()
|
||||
self._lock = threading.Lock()
|
||||
json_handler.log_operation(
|
||||
"broker_init",
|
||||
{
|
||||
"repo_root": str(self._repo_root),
|
||||
"socket": str(self.socket_path),
|
||||
},
|
||||
)
|
||||
|
||||
def _generate_secret(self) -> bytes:
|
||||
"""Generate a fresh HMAC secret, write to disk with mode 0600."""
|
||||
secret = secrets.token_bytes(32)
|
||||
self._secret_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._secret_path.write_bytes(secret)
|
||||
self._secret_path.chmod(0o600)
|
||||
logger.info("broker: generated secret at %s", self._secret_path)
|
||||
return secret
|
||||
|
||||
def _audit(self, entry: dict) -> None:
|
||||
"""Append a JSON line to the audit log."""
|
||||
entry["timestamp"] = time.strftime("%Y-%m-%dT%H:%M:%S%z")
|
||||
self.audit_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.audit_path, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(entry, separators=(",", ":")) + "\n")
|
||||
|
||||
def _check_denylist(self, resolved: Path) -> str | None:
|
||||
"""Return a reason string if the resolved path hits the denylist."""
|
||||
for part in resolved.parts:
|
||||
if part in _DENYLIST_DIRS:
|
||||
return f"Protected directory: path is inside {part}/"
|
||||
return None
|
||||
|
||||
def _get_allowed_bases(self, identity: str | None) -> list[Path]:
|
||||
"""Return the allowed base directories for the given identity."""
|
||||
bases: list[Path] = list(_TMP_BASES)
|
||||
if identity is None or self._repo_root is None:
|
||||
return bases
|
||||
if identity == "devpulse":
|
||||
bases.append(self._repo_root)
|
||||
return bases
|
||||
branch_dir = self._repo_root / "src" / "aipass" / identity
|
||||
if branch_dir.is_dir():
|
||||
bases.append(branch_dir)
|
||||
return bases
|
||||
|
||||
def _handle_identify(self, req: BrokerRequest) -> tuple[BrokerResponse, str | None]:
|
||||
"""Verify HMAC and bind identity to the connection."""
|
||||
audit_entry: dict = {
|
||||
"op": "identify",
|
||||
"branch": req.branch,
|
||||
"request_id": req.request_id,
|
||||
}
|
||||
|
||||
if not req.branch or not req.hmac:
|
||||
audit_entry.update(result="REFUSED", reason="missing branch or hmac")
|
||||
self._audit(audit_entry)
|
||||
return BrokerResponse(
|
||||
ok=False,
|
||||
message="Missing branch or hmac",
|
||||
request_id=req.request_id,
|
||||
error_code="IDENTIFY_INVALID",
|
||||
), None
|
||||
|
||||
expected = hmac_mod.new(self._secret, req.branch.encode(), hashlib.sha256).hexdigest()
|
||||
if not hmac_mod.compare_digest(expected, req.hmac):
|
||||
audit_entry.update(result="REFUSED", reason="bad HMAC")
|
||||
self._audit(audit_entry)
|
||||
return BrokerResponse(
|
||||
ok=False,
|
||||
message="Authentication failed",
|
||||
request_id=req.request_id,
|
||||
error_code="IDENTIFY_FAILED",
|
||||
), None
|
||||
|
||||
audit_entry.update(result="IDENTIFIED", identity=req.branch)
|
||||
self._audit(audit_entry)
|
||||
logger.info("broker: connection identified as %s", req.branch)
|
||||
return BrokerResponse(
|
||||
ok=True,
|
||||
message=f"Identified as {req.branch}",
|
||||
request_id=req.request_id,
|
||||
), req.branch
|
||||
|
||||
def _handle_delete(self, req: BrokerRequest, identity: str | None) -> BrokerResponse:
|
||||
"""Process a single delete request with full re-resolution and identity scoping."""
|
||||
import shutil
|
||||
|
||||
audit_entry: dict = {
|
||||
"op": req.op,
|
||||
"agent_path": req.path,
|
||||
"request_id": req.request_id,
|
||||
"identity": identity,
|
||||
}
|
||||
|
||||
allowed_bases = self._get_allowed_bases(identity)
|
||||
|
||||
for base in allowed_bases:
|
||||
agent_path = req.path
|
||||
try:
|
||||
candidate = Path(agent_path)
|
||||
if candidate.is_absolute() and candidate.is_relative_to(base):
|
||||
agent_path = str(candidate.relative_to(base))
|
||||
except (ValueError, TypeError) as exc:
|
||||
logger.info("broker: path normalization skipped for %s: %s", agent_path, exc)
|
||||
|
||||
try:
|
||||
resolved = resolve_beneath(base, agent_path)
|
||||
except OSError as exc:
|
||||
logger.info("broker: base %s skipped for %s: %s", base, agent_path, exc)
|
||||
continue
|
||||
|
||||
# Prevent /tmp base from granting access to repo-scoped paths
|
||||
if self._repo_root and base in _TMP_BASES and resolved.is_relative_to(self._repo_root):
|
||||
logger.info("broker: %s is under repo root via /tmp — skipping", resolved)
|
||||
continue
|
||||
|
||||
if not resolved.is_relative_to(base):
|
||||
continue
|
||||
|
||||
deny_reason = self._check_denylist(resolved)
|
||||
if deny_reason:
|
||||
audit_entry.update(
|
||||
result="REFUSED",
|
||||
reason=deny_reason,
|
||||
resolved=str(resolved),
|
||||
base=str(base),
|
||||
)
|
||||
self._audit(audit_entry)
|
||||
logger.warning("broker: denied delete %s: %s", resolved, deny_reason)
|
||||
return BrokerResponse(
|
||||
ok=False,
|
||||
message=deny_reason,
|
||||
request_id=req.request_id,
|
||||
error_code="DENYLIST",
|
||||
)
|
||||
|
||||
if resolved == base:
|
||||
reason = f"Refusing to delete root directory itself: {base}"
|
||||
audit_entry.update(
|
||||
result="REFUSED",
|
||||
reason=reason,
|
||||
resolved=str(resolved),
|
||||
base=str(base),
|
||||
)
|
||||
self._audit(audit_entry)
|
||||
return BrokerResponse(
|
||||
ok=False,
|
||||
message=reason,
|
||||
request_id=req.request_id,
|
||||
error_code="ROOT_DELETE",
|
||||
)
|
||||
|
||||
try:
|
||||
if resolved.is_symlink():
|
||||
resolved.unlink()
|
||||
elif resolved.is_dir():
|
||||
shutil.rmtree(resolved)
|
||||
else:
|
||||
resolved.unlink()
|
||||
|
||||
audit_entry.update(result="DELETED", resolved=str(resolved), base=str(base))
|
||||
self._audit(audit_entry)
|
||||
logger.info(
|
||||
"broker: deleted %s (base=%s, identity=%s)",
|
||||
resolved,
|
||||
base,
|
||||
identity,
|
||||
)
|
||||
return BrokerResponse(
|
||||
ok=True,
|
||||
message=f"Deleted: {resolved}",
|
||||
request_id=req.request_id,
|
||||
)
|
||||
except OSError as exc:
|
||||
audit_entry.update(
|
||||
result="ERROR",
|
||||
reason=str(exc),
|
||||
resolved=str(resolved),
|
||||
base=str(base),
|
||||
)
|
||||
self._audit(audit_entry)
|
||||
logger.error("broker: delete failed %s: %s", resolved, exc)
|
||||
return BrokerResponse(
|
||||
ok=False,
|
||||
message=f"Delete failed: {exc}",
|
||||
request_id=req.request_id,
|
||||
error_code="OS_ERROR",
|
||||
)
|
||||
|
||||
audit_entry.update(result="REFUSED", reason="Path not under any allowed base for this identity")
|
||||
self._audit(audit_entry)
|
||||
logger.warning("broker: no allowed base matched for %s (identity=%s)", req.path, identity)
|
||||
return BrokerResponse(
|
||||
ok=False,
|
||||
message=f"Path not permitted for identity '{identity}': {req.path}",
|
||||
request_id=req.request_id,
|
||||
error_code="NO_BASE",
|
||||
)
|
||||
|
||||
def _handle_connection(self, conn: socket.socket) -> None:
|
||||
"""Read messages in a loop, tracking per-connection identity."""
|
||||
identity: str | None = None
|
||||
first_message_done = False
|
||||
buffer = b""
|
||||
try:
|
||||
while True:
|
||||
while b"\n" not in buffer:
|
||||
chunk = conn.recv(4096)
|
||||
if not chunk:
|
||||
return
|
||||
buffer += chunk
|
||||
|
||||
line, _, buffer = buffer.partition(b"\n")
|
||||
if not line.strip():
|
||||
continue
|
||||
|
||||
req = BrokerRequest.from_bytes(line + b"\n")
|
||||
|
||||
if req.op == "identify":
|
||||
if first_message_done:
|
||||
self._audit(
|
||||
{
|
||||
"op": "identify",
|
||||
"branch": req.branch,
|
||||
"identity": identity,
|
||||
"result": "REFUSED",
|
||||
"reason": "identify after first message",
|
||||
"request_id": req.request_id,
|
||||
}
|
||||
)
|
||||
resp = BrokerResponse(
|
||||
ok=False,
|
||||
message="Identify must be the first message",
|
||||
request_id=req.request_id,
|
||||
error_code="IDENTIFY_LATE",
|
||||
)
|
||||
else:
|
||||
resp, identity = self._handle_identify(req)
|
||||
first_message_done = True
|
||||
elif req.op == "delete":
|
||||
first_message_done = True
|
||||
resp = self._handle_delete(req, identity)
|
||||
else:
|
||||
first_message_done = True
|
||||
resp = BrokerResponse(
|
||||
ok=False,
|
||||
message=f"Unknown operation: {req.op}",
|
||||
request_id=req.request_id,
|
||||
error_code="UNKNOWN_OP",
|
||||
)
|
||||
|
||||
conn.sendall(resp.to_bytes())
|
||||
except Exception as exc:
|
||||
logger.error("broker: connection error: %s", exc)
|
||||
try:
|
||||
err = BrokerResponse(ok=False, message=f"Internal error: {exc}", error_code="INTERNAL")
|
||||
conn.sendall(err.to_bytes())
|
||||
except OSError as send_exc:
|
||||
logger.warning("broker: failed to send error response: %s", send_exc)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the broker daemon (blocking). Use ``start_background`` for threaded."""
|
||||
self._secret = self._generate_secret()
|
||||
|
||||
self.socket_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if self.socket_path.exists():
|
||||
self.socket_path.unlink()
|
||||
|
||||
self._server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
self._server.bind(str(self.socket_path))
|
||||
self._server.listen(5)
|
||||
self._server.settimeout(1.0)
|
||||
self._running = True
|
||||
self._listening.set()
|
||||
|
||||
logger.info("broker: listening on %s", self.socket_path)
|
||||
json_handler.log_operation("broker_start", {"socket": str(self.socket_path)})
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
conn, _ = self._server.accept()
|
||||
t = threading.Thread(target=self._handle_connection, args=(conn,), daemon=True)
|
||||
t.start()
|
||||
except socket.timeout:
|
||||
logger.info("broker: accept poll tick")
|
||||
continue
|
||||
except OSError as exc:
|
||||
if self._running:
|
||||
logger.error("broker: accept error: %s", exc)
|
||||
break
|
||||
|
||||
def start_background(self, timeout: float = 5.0) -> threading.Thread:
|
||||
"""Start the broker in a background thread, blocking until listening."""
|
||||
self._listening.clear()
|
||||
t = threading.Thread(target=self.start, daemon=True, name="drone-broker")
|
||||
t.start()
|
||||
if not self._listening.wait(timeout):
|
||||
raise RuntimeError(f"broker failed to start listening within {timeout}s")
|
||||
return t
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the broker daemon."""
|
||||
self._running = False
|
||||
if self._server:
|
||||
try:
|
||||
self._server.close()
|
||||
except OSError as exc:
|
||||
logger.warning("broker: error closing server socket: %s", exc)
|
||||
if self.socket_path.exists():
|
||||
try:
|
||||
self.socket_path.unlink()
|
||||
except OSError as exc:
|
||||
logger.warning("broker: error removing socket file: %s", exc)
|
||||
logger.info("broker: stopped")
|
||||
json_handler.log_operation("broker_stop", {})
|
||||
@@ -0,0 +1,139 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: path_resolver.py
|
||||
# Description: Kernel-safe path resolution via openat2 RESOLVE_BENEATH
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-06-09
|
||||
# Modified: 2026-06-09
|
||||
# =============================================
|
||||
|
||||
"""Kernel-safe path resolution via openat2 RESOLVE_BENEATH.
|
||||
|
||||
Re-resolves an agent-supplied path string server-side so the broker never
|
||||
trusts the raw string. Uses Linux openat2(2) with RESOLVE_BENEATH |
|
||||
RESOLVE_NO_SYMLINKS to guarantee the final target is strictly beneath an
|
||||
allowed base directory and traverses no symlinks.
|
||||
|
||||
Falls back to a pure-Python per-component walk (O_NOFOLLOW openat) when
|
||||
openat2 is unavailable (non-Linux, older kernels).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import ctypes.util
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.drone.apps.handlers.json import json_handler
|
||||
|
||||
RESOLVE_BENEATH = 0x08
|
||||
RESOLVE_NO_SYMLINKS = 0x04
|
||||
SYS_OPENAT2 = 437
|
||||
O_PATH = 0o010000000
|
||||
O_NOFOLLOW = 0o0400000
|
||||
|
||||
_OPEN_HOW_SIZE = 24
|
||||
|
||||
|
||||
def _openat2_available() -> bool:
|
||||
"""Check if the openat2 syscall is usable on this platform."""
|
||||
return sys.platform == "linux" and os.uname().machine == "x86_64"
|
||||
|
||||
|
||||
def _openat2(dirfd: int, pathname: bytes, flags: int, resolve: int) -> int:
|
||||
"""Call openat2(2) via ctypes syscall.
|
||||
|
||||
Returns an fd on success, raises OSError on failure.
|
||||
"""
|
||||
open_how = struct.pack("QQQ", flags, 0, resolve)
|
||||
libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)
|
||||
result = libc.syscall(
|
||||
ctypes.c_long(SYS_OPENAT2),
|
||||
ctypes.c_int(dirfd),
|
||||
ctypes.c_char_p(pathname),
|
||||
ctypes.c_char_p(open_how),
|
||||
ctypes.c_size_t(_OPEN_HOW_SIZE),
|
||||
)
|
||||
if result < 0:
|
||||
errno = ctypes.get_errno()
|
||||
raise OSError(errno, os.strerror(errno), pathname.decode(errors="replace"))
|
||||
return result
|
||||
|
||||
|
||||
def resolve_beneath(base: Path, relpath: str) -> Path:
|
||||
"""Resolve *relpath* strictly beneath *base*, refusing escapes and symlinks.
|
||||
|
||||
Uses openat2 RESOLVE_BENEATH|RESOLVE_NO_SYMLINKS on Linux x86-64,
|
||||
falls back to a per-component O_NOFOLLOW walk otherwise.
|
||||
|
||||
Returns the resolved absolute path on success.
|
||||
Raises OSError on traversal failure (escape, symlink, missing component).
|
||||
"""
|
||||
json_handler.log_operation("resolve_beneath", {"base": str(base), "relpath": relpath})
|
||||
|
||||
cleaned = os.path.normpath(relpath)
|
||||
if cleaned.startswith("/") or cleaned.startswith(".."):
|
||||
raise OSError(1, "Path escapes base via leading / or ..", relpath)
|
||||
|
||||
parts = cleaned.split("/")
|
||||
if ".." in parts:
|
||||
raise OSError(1, "Path contains .. component", relpath)
|
||||
|
||||
if _openat2_available():
|
||||
return _resolve_via_openat2(base, cleaned)
|
||||
return _resolve_via_walk(base, parts)
|
||||
|
||||
|
||||
def _resolve_via_openat2(base: Path, cleaned: str) -> Path:
|
||||
"""Resolve using the openat2 syscall with kernel-enforced containment."""
|
||||
dirfd = os.open(str(base), os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
fd = _openat2(
|
||||
dirfd,
|
||||
cleaned.encode(),
|
||||
O_PATH,
|
||||
RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS,
|
||||
)
|
||||
try:
|
||||
resolved = Path(os.readlink(f"/proc/self/fd/{fd}"))
|
||||
logger.info("resolve_beneath: openat2 resolved %s -> %s", cleaned, resolved)
|
||||
return resolved
|
||||
finally:
|
||||
os.close(fd)
|
||||
finally:
|
||||
os.close(dirfd)
|
||||
|
||||
|
||||
def _resolve_via_walk(base: Path, parts: list[str]) -> Path:
|
||||
"""Fallback: per-component walk using O_NOFOLLOW to block symlinks."""
|
||||
current_fd = os.open(str(base), os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
for i, component in enumerate(parts):
|
||||
if component in ("", "."):
|
||||
continue
|
||||
|
||||
is_last = i == len(parts) - 1
|
||||
flags = O_PATH | O_NOFOLLOW
|
||||
if not is_last:
|
||||
flags |= os.O_DIRECTORY
|
||||
|
||||
try:
|
||||
next_fd = os.open(component, flags, dir_fd=current_fd)
|
||||
except OSError as exc:
|
||||
raise OSError(
|
||||
exc.errno,
|
||||
f"Component '{component}' failed: {exc.strerror}",
|
||||
"/".join(parts),
|
||||
) from exc
|
||||
|
||||
os.close(current_fd)
|
||||
current_fd = next_fd
|
||||
|
||||
resolved = Path(os.readlink(f"/proc/self/fd/{current_fd}"))
|
||||
logger.info("resolve_beneath: walk resolved %s -> %s", "/".join(parts), resolved)
|
||||
return resolved
|
||||
finally:
|
||||
os.close(current_fd)
|
||||
@@ -0,0 +1,76 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: protocol.py
|
||||
# Description: Typed protocol for broker IPC
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-06-09
|
||||
# Modified: 2026-06-09
|
||||
# =============================================
|
||||
|
||||
"""Typed protocol for broker IPC.
|
||||
|
||||
JSON-line messages over a unix socket. Extensible — only ``delete`` is
|
||||
implemented now, but the envelope supports future operation types.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from typing import Literal
|
||||
|
||||
from aipass.drone.apps.handlers.json import json_handler
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrokerRequest:
|
||||
"""A request from sandboxed drone to the broker."""
|
||||
|
||||
op: Literal["delete", "identify"]
|
||||
path: str = ""
|
||||
request_id: str = ""
|
||||
extra: dict[str, str] = field(default_factory=dict)
|
||||
branch: str = ""
|
||||
hmac: str = ""
|
||||
|
||||
def to_bytes(self) -> bytes:
|
||||
"""Serialize to a newline-terminated JSON bytes line."""
|
||||
return json.dumps(asdict(self), separators=(",", ":")).encode() + b"\n"
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes) -> BrokerRequest:
|
||||
"""Deserialize from JSON bytes."""
|
||||
d = json.loads(data)
|
||||
json_handler.log_operation("broker_request_parse", {"op": d.get("op", "")})
|
||||
return cls(
|
||||
op=d["op"],
|
||||
path=d.get("path", ""),
|
||||
request_id=d.get("request_id", ""),
|
||||
extra=d.get("extra", {}),
|
||||
branch=d.get("branch", ""),
|
||||
hmac=d.get("hmac", ""),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrokerResponse:
|
||||
"""The broker's reply."""
|
||||
|
||||
ok: bool
|
||||
message: str
|
||||
request_id: str = ""
|
||||
error_code: str = ""
|
||||
|
||||
def to_bytes(self) -> bytes:
|
||||
"""Serialize to a newline-terminated JSON bytes line."""
|
||||
return json.dumps(asdict(self), separators=(",", ":")).encode() + b"\n"
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes) -> BrokerResponse:
|
||||
"""Deserialize from JSON bytes."""
|
||||
d = json.loads(data)
|
||||
return cls(
|
||||
ok=d["ok"],
|
||||
message=d["message"],
|
||||
request_id=d.get("request_id", ""),
|
||||
error_code=d.get("error_code", ""),
|
||||
)
|
||||
@@ -25,6 +25,19 @@ def list_remote_branches() -> dict:
|
||||
"""
|
||||
repo_root = find_repo_root()
|
||||
|
||||
# Prune stale remote-tracking refs before listing
|
||||
try:
|
||||
prune = subprocess.run(
|
||||
["git", "fetch", "--prune"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(repo_root),
|
||||
)
|
||||
if prune.returncode != 0:
|
||||
logger.warning("git fetch --prune failed (listing stale refs): %s", prune.stderr.strip())
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
logger.warning("git fetch --prune unavailable (offline?), listing may include stale refs: %s", exc)
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "branch", "-r"],
|
||||
@@ -52,3 +65,46 @@ def list_remote_branches() -> dict:
|
||||
logger.info("Listed %d remote branches", len(branches))
|
||||
|
||||
return {"branches": branches, "count": len(branches), "message": f"{len(branches)} remote branches"}
|
||||
|
||||
|
||||
def prune_temp_branches() -> dict:
|
||||
"""Delete local and remote temp PR branches (citizen/*) that are already merged.
|
||||
|
||||
Returns:
|
||||
Dict with pruned list, count, and message.
|
||||
"""
|
||||
repo_root = find_repo_root()
|
||||
pruned: list[str] = []
|
||||
|
||||
try:
|
||||
merged = subprocess.run(
|
||||
["git", "branch", "--merged", "main"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(repo_root),
|
||||
)
|
||||
if merged.returncode != 0:
|
||||
return {"pruned": [], "count": 0, "message": f"git branch --merged failed: {merged.stderr.strip()}"}
|
||||
|
||||
for line in merged.stdout.splitlines():
|
||||
name = line.strip().lstrip("* ")
|
||||
if name.startswith("citizen/"):
|
||||
local_del = subprocess.run(
|
||||
["git", "branch", "-d", name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(repo_root),
|
||||
)
|
||||
if local_del.returncode == 0:
|
||||
pruned.append(name)
|
||||
logger.info("Pruned merged temp branch: %s", name)
|
||||
else:
|
||||
logger.warning("Failed to delete local branch %s: %s", name, local_del.stderr.strip())
|
||||
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
logger.error("prune_temp_branches failed: %s", exc)
|
||||
return {"pruned": [], "count": 0, "message": f"Prune failed: {exc}"}
|
||||
|
||||
json_handler.log_operation("prune_temp_branches", {"count": len(pruned)})
|
||||
msg = f"Pruned {len(pruned)} merged temp branch(es)" if pruned else "No merged temp branches to prune"
|
||||
return {"pruned": pruned, "count": len(pruned), "message": msg}
|
||||
|
||||
@@ -396,3 +396,35 @@ def get_branch_by_name(name: str) -> Optional[Dict[str, Any]]:
|
||||
logger.warning("get_branch_by_name: AIPass home registry unavailable for '%s': %s", name, exc)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_branch_with_registry(name: str) -> Optional[tuple]:
|
||||
"""Get a branch and the registry path it was found in.
|
||||
|
||||
Same two-step lookup as get_branch_by_name (primary then AIPASS_HOME),
|
||||
but returns (branch_dict, registry_path) so callers can determine
|
||||
which project root the branch belongs to.
|
||||
"""
|
||||
lower_name = name.lower()
|
||||
|
||||
try:
|
||||
primary_path = get_registry_path()
|
||||
registry = load_registry()
|
||||
branch = registry.get("branches", {}).get(lower_name)
|
||||
if branch is not None:
|
||||
return branch, primary_path
|
||||
except (RegistryNotFoundError, RegistryCorruptError, RegistryPermissionError) as exc:
|
||||
logger.warning("get_branch_with_registry: primary registry unavailable for '%s': %s", name, exc)
|
||||
primary_path = None
|
||||
|
||||
home_path = _get_aipass_home_registry_path()
|
||||
if home_path is not None and home_path != primary_path:
|
||||
try:
|
||||
home_data = _load_registry_data(home_path)
|
||||
branch = home_data.get("branches", {}).get(lower_name)
|
||||
if branch is not None:
|
||||
return branch, home_path
|
||||
except (RegistryNotFoundError, RegistryCorruptError, RegistryPermissionError) as exc:
|
||||
logger.warning("get_branch_with_registry: AIPass home registry unavailable for '%s': %s", name, exc)
|
||||
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: rm_handler.py
|
||||
# Description: Contained safe-delete handler
|
||||
# Version: 1.1.0
|
||||
# Created: 2026-06-02
|
||||
# Modified: 2026-06-02
|
||||
# =============================================
|
||||
|
||||
"""Contained safe-delete handler.
|
||||
|
||||
Deletes paths using shutil.rmtree (directories) or Path.unlink (files),
|
||||
constrained to project root and system temp directories. Provider-agnostic
|
||||
alternative to shell ``rm``.
|
||||
|
||||
Matches Codex sandbox boundaries: writable = {project, /tmp, $TMPDIR}.
|
||||
Hard carve-outs protect .git, .trinity, .aipass, .codex, .agents, and
|
||||
sibling branch worktrees even inside allowed roots.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.drone.apps.handlers.json import json_handler
|
||||
|
||||
_CARVEOUT_DIRS = frozenset((".git", ".trinity", ".aipass", ".codex", ".agents"))
|
||||
|
||||
|
||||
def _find_project_root() -> Path | None:
|
||||
"""Walk up from CWD to find *_REGISTRY.json; return its parent as project root."""
|
||||
cwd = Path.cwd()
|
||||
for parent in [cwd, *cwd.parents]:
|
||||
if list(parent.glob("*_REGISTRY.json")):
|
||||
return parent.resolve()
|
||||
aipass_home = os.environ.get("AIPASS_HOME")
|
||||
if aipass_home:
|
||||
home = Path(aipass_home)
|
||||
if home.is_dir() and list(home.glob("*_REGISTRY.json")):
|
||||
return home.resolve()
|
||||
return None
|
||||
|
||||
|
||||
def get_allowed_roots() -> list[Path]:
|
||||
"""Return resolved roots under which deletion is permitted.
|
||||
|
||||
Union of: project root, ``/tmp``, and ``tempfile.gettempdir()`` (which
|
||||
honors ``$TMPDIR``). Deduplicated by resolved path.
|
||||
"""
|
||||
seen: set[Path] = set()
|
||||
roots: list[Path] = []
|
||||
|
||||
project_root = _find_project_root()
|
||||
if project_root is not None and project_root not in seen:
|
||||
seen.add(project_root)
|
||||
roots.append(project_root)
|
||||
|
||||
for tmp_candidate in (Path("/tmp"), Path(tempfile.gettempdir())):
|
||||
resolved = tmp_candidate.resolve()
|
||||
if resolved not in seen:
|
||||
seen.add(resolved)
|
||||
roots.append(resolved)
|
||||
|
||||
return roots
|
||||
|
||||
|
||||
def _detect_current_branch(project_root: Path | None) -> str | None:
|
||||
"""Return the branch name the CWD lives in, or None."""
|
||||
if project_root is None:
|
||||
return None
|
||||
cwd = Path.cwd().resolve()
|
||||
aipass_src = project_root / "src" / "aipass"
|
||||
if not cwd.is_relative_to(aipass_src):
|
||||
return None
|
||||
rel = cwd.relative_to(aipass_src)
|
||||
return rel.parts[0] if rel.parts else None
|
||||
|
||||
|
||||
def _resolve_git_dir(path: Path) -> Path | None:
|
||||
"""If *path* is a ``.git`` file (worktree pointer), return the resolved gitdir."""
|
||||
try:
|
||||
if path.is_file():
|
||||
text = path.read_text(encoding="utf-8", errors="replace").strip()
|
||||
if text.startswith("gitdir:"):
|
||||
return Path(text.split(":", 1)[1].strip()).resolve()
|
||||
except OSError as exc:
|
||||
logger.warning("Failed to read .git file at %s: %s", path, exc)
|
||||
return None
|
||||
|
||||
|
||||
def check_carveouts(resolved: Path, project_root: Path | None) -> tuple[bool, str]:
|
||||
"""Refuse deletion of protected paths even inside allowed roots.
|
||||
|
||||
Returns ``(blocked, reason)``. ``blocked=True`` means the path must
|
||||
NOT be deleted.
|
||||
"""
|
||||
parts = resolved.parts
|
||||
for i, part in enumerate(parts):
|
||||
if part in _CARVEOUT_DIRS:
|
||||
return True, f"Protected directory: path is inside {part}/"
|
||||
|
||||
if part == ".git":
|
||||
git_path = Path(*parts[: i + 1]) if i > 0 else Path(part)
|
||||
real_gitdir = _resolve_git_dir(git_path)
|
||||
if real_gitdir and resolved.is_relative_to(real_gitdir):
|
||||
return True, "Protected: path resolves inside .git worktree gitdir"
|
||||
|
||||
if project_root is not None:
|
||||
aipass_src = project_root / "src" / "aipass"
|
||||
if resolved.is_relative_to(aipass_src):
|
||||
rel = resolved.relative_to(aipass_src)
|
||||
if rel.parts:
|
||||
target_branch = rel.parts[0]
|
||||
current_branch = _detect_current_branch(project_root)
|
||||
if current_branch is None or target_branch != current_branch:
|
||||
return True, (f"Protected: path is inside sibling branch src/aipass/{target_branch}/")
|
||||
|
||||
return False, ""
|
||||
|
||||
|
||||
def check_containment(path: Path, roots: list[Path]) -> tuple[bool, str]:
|
||||
"""Check if *path* (already resolved) is a strict child of any allowed root.
|
||||
|
||||
Returns ``(allowed, reason)``. Refuses the root directories themselves.
|
||||
When multiple roots are nested (e.g. /tmp and /tmp/claude-1000), the path
|
||||
must not equal ANY root — checked upfront before containment.
|
||||
"""
|
||||
root_set = frozenset(roots)
|
||||
if path in root_set:
|
||||
return False, f"Refusing to delete root directory itself: {path}"
|
||||
|
||||
for root in roots:
|
||||
if path.is_relative_to(root):
|
||||
return True, ""
|
||||
|
||||
allowed_str = ", ".join(str(r) for r in roots)
|
||||
return False, (f"Path {path} is outside allowed roots.\n Allowed: {allowed_str}")
|
||||
|
||||
|
||||
def safe_delete(paths: list[str]) -> list[tuple[str, bool, str]]:
|
||||
"""Delete *paths* with containment checks.
|
||||
|
||||
Returns a list of ``(original_path, success, message)`` tuples.
|
||||
Every path is checked independently; a refused path does not block others.
|
||||
"""
|
||||
return _safe_delete_direct(paths)
|
||||
|
||||
|
||||
def _safe_delete_direct(paths: list[str]) -> list[tuple[str, bool, str]]:
|
||||
"""Delete paths directly (unsandboxed mode — current behavior)."""
|
||||
roots = get_allowed_roots()
|
||||
if not roots:
|
||||
return [(p, False, "No allowed roots found (no project registry, no temp dir)") for p in paths]
|
||||
|
||||
project_root = _find_project_root()
|
||||
json_handler.log_operation("rm", {"paths": paths, "roots": [str(r) for r in roots]})
|
||||
|
||||
results: list[tuple[str, bool, str]] = []
|
||||
for path_str in paths:
|
||||
original = Path(path_str)
|
||||
absolute = original if original.is_absolute() else (Path.cwd() / original)
|
||||
|
||||
exists_on_disk = absolute.exists() or absolute.is_symlink()
|
||||
if not exists_on_disk:
|
||||
results.append((path_str, False, f"Path does not exist: {absolute}"))
|
||||
logger.info("rm: nonexistent path %s", absolute)
|
||||
continue
|
||||
|
||||
resolved = absolute.resolve()
|
||||
|
||||
allowed, reason = check_containment(resolved, roots)
|
||||
if not allowed:
|
||||
results.append((path_str, False, reason))
|
||||
logger.warning(
|
||||
"rm: containment refused %s (resolved %s): %s",
|
||||
path_str,
|
||||
resolved,
|
||||
reason,
|
||||
)
|
||||
continue
|
||||
|
||||
blocked, carveout_reason = check_carveouts(resolved, project_root)
|
||||
if blocked:
|
||||
results.append((path_str, False, carveout_reason))
|
||||
logger.warning(
|
||||
"rm: carveout refused %s (resolved %s): %s",
|
||||
path_str,
|
||||
resolved,
|
||||
carveout_reason,
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
if absolute.is_symlink():
|
||||
absolute.unlink()
|
||||
elif absolute.is_dir():
|
||||
shutil.rmtree(absolute)
|
||||
else:
|
||||
absolute.unlink()
|
||||
results.append((path_str, True, f"Deleted: {resolved}"))
|
||||
logger.info("rm: deleted %s (resolved %s)", path_str, resolved)
|
||||
except Exception as exc:
|
||||
results.append((path_str, False, f"Delete failed: {exc}"))
|
||||
logger.error("rm: delete failed for %s: %s", path_str, exc)
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,119 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: broker.py
|
||||
# Description: Module orchestrator for the drone-broker daemon
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-06-09
|
||||
# Modified: 2026-06-09
|
||||
# =============================================
|
||||
|
||||
"""Module orchestrator for the drone-broker daemon.
|
||||
|
||||
Thin orchestrator that delegates to the broker handler package for
|
||||
daemon lifecycle, path resolution, and client operations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.drone.apps.handlers.json import json_handler
|
||||
from aipass.drone.apps.handlers.broker.daemon import BrokerDaemon
|
||||
|
||||
|
||||
def handle_command(command: Optional[str] = None, args: Optional[list[str]] = None) -> bool:
|
||||
"""Route broker subcommands to handler functions."""
|
||||
if not args:
|
||||
if command is None:
|
||||
print_introspection()
|
||||
return True
|
||||
args = []
|
||||
if command in ("--help", "-h") or (args and args[0] in ("--help", "-h")):
|
||||
print_help()
|
||||
return True
|
||||
|
||||
json_handler.log_operation("broker_command", {"command": command, "args": args})
|
||||
|
||||
if command == "start":
|
||||
return _start_broker()
|
||||
if command == "status":
|
||||
return _show_status()
|
||||
|
||||
logger.warning("broker: unknown command '%s'", command)
|
||||
return False
|
||||
|
||||
|
||||
def _start_broker() -> bool:
|
||||
"""Start the broker daemon in the foreground."""
|
||||
from aipass.drone.apps.handlers.broker.daemon import _find_project_root
|
||||
|
||||
repo_root = _find_project_root()
|
||||
if not repo_root:
|
||||
logger.error("No project root found — cannot start broker")
|
||||
return False
|
||||
|
||||
console.print(f"[green]Starting broker (repo root: {repo_root})...[/green]")
|
||||
|
||||
daemon = BrokerDaemon(repo_root=repo_root)
|
||||
console.print(f"[green]Listening on {daemon.socket_path}[/green]")
|
||||
console.print("[dim]Press Ctrl+C to stop[/dim]")
|
||||
try:
|
||||
daemon.start()
|
||||
except KeyboardInterrupt:
|
||||
logger.info("broker: interrupted, stopping")
|
||||
daemon.stop()
|
||||
console.print("[yellow]Broker stopped[/yellow]")
|
||||
return True
|
||||
|
||||
|
||||
def _show_status() -> bool:
|
||||
"""Show broker status."""
|
||||
from aipass.drone.apps.handlers.broker.daemon import _default_socket_path
|
||||
|
||||
sock_path = _default_socket_path()
|
||||
if sock_path.exists():
|
||||
console.print(f"[green]Broker socket exists:[/green] {sock_path}")
|
||||
return True
|
||||
console.print(f"No broker socket found at: {sock_path}")
|
||||
return True
|
||||
|
||||
|
||||
def print_introspection() -> None:
|
||||
"""Display module overview (no args)."""
|
||||
try:
|
||||
from aipass.cli.apps.modules.display import console as c
|
||||
except ImportError:
|
||||
logger.warning("CLI console not available, using fallback")
|
||||
from rich.console import Console
|
||||
|
||||
c = Console()
|
||||
|
||||
c.print()
|
||||
c.print("[bold cyan]broker Module[/bold cyan]")
|
||||
c.print("[dim]Privileged delete daemon for sandboxed agents.[/dim]")
|
||||
c.print()
|
||||
c.print("[yellow]Connected Handlers:[/yellow]")
|
||||
c.print(" [cyan]handlers/broker/[/cyan]")
|
||||
c.print(" - [cyan]daemon.py[/cyan] [dim](BrokerDaemon — unix socket listener + openat2 resolver)[/dim]")
|
||||
c.print(" - [cyan]client.py[/cyan] [dim](broker_delete — send requests over inherited fd)[/dim]")
|
||||
c.print(" - [cyan]path_resolver.py[/cyan] [dim](resolve_beneath — openat2 RESOLVE_BENEATH)[/dim]")
|
||||
c.print(" - [cyan]protocol.py[/cyan] [dim](BrokerRequest/BrokerResponse — typed JSON-line IPC)[/dim]")
|
||||
c.print()
|
||||
|
||||
|
||||
def print_help() -> None:
|
||||
"""Display help (--help flag)."""
|
||||
console.print("Usage: drone broker <command>")
|
||||
console.print()
|
||||
console.print("Privileged delete daemon for sandboxed agents.")
|
||||
console.print()
|
||||
console.print("[bold]Commands:[/bold]")
|
||||
console.print(" [green]start[/green] Start the broker daemon (foreground)")
|
||||
console.print(" [green]status[/green] Check if the broker socket exists")
|
||||
console.print()
|
||||
console.print("[bold]Environment:[/bold]")
|
||||
console.print(" AIPASS_BROKER_FD Inherited socket fd (set by launch wrapper)")
|
||||
console.print()
|
||||
console.print("[bold]Socket:[/bold] $REPO/.ai_central/drone_broker.sock")
|
||||
console.print("[bold]Audit:[/bold] $REPO/.ai_central/drone_broker_audit.jsonl")
|
||||
@@ -155,13 +155,15 @@ def print_introspection() -> None:
|
||||
console = Console()
|
||||
|
||||
console.print()
|
||||
console.print("commands Module")
|
||||
console.print("Custom command shortcuts — map short names to full drone commands.")
|
||||
console.print("[bold cyan]commands Module[/bold cyan]")
|
||||
console.print("[dim]Custom command shortcuts — map short names to full drone commands.[/dim]")
|
||||
console.print()
|
||||
console.print("Connected Handlers:")
|
||||
console.print(" handlers/command_registry/")
|
||||
console.print(" - ops.py (add_command, remove_command, update_command, command_exists)")
|
||||
console.print(" - lookup.py (lookup_command, match_command, list_commands, list_commands_by_branch)")
|
||||
console.print("[yellow]Connected Handlers:[/yellow]")
|
||||
console.print(" [cyan]handlers/command_registry/[/cyan]")
|
||||
console.print(" - [cyan]ops.py[/cyan] [dim](add_command, remove_command, update_command, command_exists)[/dim]")
|
||||
console.print(
|
||||
" - [cyan]lookup.py[/cyan] [dim](lookup_command, match_command, list_commands, list_commands_by_branch)[/dim]"
|
||||
)
|
||||
console.print()
|
||||
|
||||
|
||||
|
||||
@@ -37,14 +37,20 @@ def print_introspection():
|
||||
console = Console()
|
||||
|
||||
console.print()
|
||||
console.print("config Module")
|
||||
console.print("Registry configuration management — path resolution and overrides.")
|
||||
console.print("[bold cyan]config Module[/bold cyan]")
|
||||
console.print("[dim]Registry configuration management — path resolution and overrides.[/dim]")
|
||||
console.print()
|
||||
console.print("Connected Handlers:")
|
||||
console.print(" handlers/")
|
||||
console.print(" - registry_handler.py (get_registry_path — return current registry file path)")
|
||||
console.print(" - registry_handler.py (set_registry_path — override registry file location)")
|
||||
console.print(" - registry_handler.py (reset_registry_path — restore default registry path)")
|
||||
console.print("[yellow]Connected Handlers:[/yellow]")
|
||||
console.print(" [cyan]handlers/[/cyan]")
|
||||
console.print(
|
||||
" - [cyan]registry_handler.py[/cyan] [dim](get_registry_path — return current registry file path)[/dim]"
|
||||
)
|
||||
console.print(
|
||||
" - [cyan]registry_handler.py[/cyan] [dim](set_registry_path — override registry file location)[/dim]"
|
||||
)
|
||||
console.print(
|
||||
" - [cyan]registry_handler.py[/cyan] [dim](reset_registry_path — restore default registry path)[/dim]"
|
||||
)
|
||||
console.print()
|
||||
|
||||
|
||||
|
||||
@@ -93,19 +93,25 @@ def print_introspection():
|
||||
console = Console()
|
||||
|
||||
console.print()
|
||||
console.print("discovery Module")
|
||||
console.print("Module and command discovery for AIPass branch introspection.")
|
||||
console.print("[bold cyan]discovery Module[/bold cyan]")
|
||||
console.print("[dim]Module and command discovery for AIPass branch introspection.[/dim]")
|
||||
console.print()
|
||||
console.print("Connected Handlers:")
|
||||
console.print(" handlers/")
|
||||
console.print(" - discovery_handler.py (HelpResult — structured help query result)")
|
||||
console.print(" - discovery_handler.py (discover_modules — list available commands for a branch)")
|
||||
console.print(" - discovery_handler.py (get_help — get structured help for a branch/command)")
|
||||
console.print(" - discovery_handler.py (get_system_help — aggregate help across all branches)")
|
||||
console.print("[yellow]Connected Handlers:[/yellow]")
|
||||
console.print(" [cyan]handlers/[/cyan]")
|
||||
console.print(" - [cyan]discovery_handler.py[/cyan] [dim](HelpResult — structured help query result)[/dim]")
|
||||
console.print(
|
||||
" - [cyan]discovery_handler.py[/cyan] [dim](discover_modules — list available commands for a branch)[/dim]"
|
||||
)
|
||||
console.print(
|
||||
" - [cyan]discovery_handler.py[/cyan] [dim](get_help — get structured help for a branch/command)[/dim]"
|
||||
)
|
||||
console.print(
|
||||
" - [cyan]discovery_handler.py[/cyan] [dim](get_system_help — aggregate help across all branches)[/dim]"
|
||||
)
|
||||
console.print()
|
||||
console.print("Connected Modules:")
|
||||
console.print(" modules/")
|
||||
console.print(" - resolver.py (resolve_branch, list_branches — branch name resolution)")
|
||||
console.print("[yellow]Connected Modules:[/yellow]")
|
||||
console.print(" [cyan]modules/[/cyan]")
|
||||
console.print(" - [cyan]resolver.py[/cyan] [dim](resolve_branch, list_branches — branch name resolution)[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ _COMMANDS = (
|
||||
"smart-sync",
|
||||
"fix",
|
||||
"pr",
|
||||
"prune-temp",
|
||||
)
|
||||
|
||||
_GH_PASSTHROUGH_COMMANDS = ("issue", "run", "workflow")
|
||||
@@ -167,6 +168,8 @@ def handle_command(command: str | None = None, args: list[str] | None = None) ->
|
||||
return _handle_fix(args, caller)
|
||||
if command == "pr":
|
||||
return _handle_pr(args)
|
||||
if command == "prune-temp":
|
||||
return _handle_prune_temp()
|
||||
|
||||
available = ", ".join(_COMMANDS)
|
||||
return {
|
||||
@@ -219,6 +222,15 @@ def _handle_branches() -> dict:
|
||||
return {"stdout": result["message"], "stderr": "", "exit_code": 0}
|
||||
|
||||
|
||||
def _handle_prune_temp() -> dict:
|
||||
"""Handle the prune-temp subcommand — delete merged citizen/* branches."""
|
||||
result = branches_handler.prune_temp_branches()
|
||||
lines = [result["message"]]
|
||||
for name in result.get("pruned", []):
|
||||
lines.append(f" deleted: {name}")
|
||||
return {"stdout": "\n".join(lines), "stderr": "", "exit_code": 0}
|
||||
|
||||
|
||||
def _handle_pr(args: list[str]) -> dict:
|
||||
"""Handle the pr subcommand — push current branch and create PR to main."""
|
||||
if not args:
|
||||
@@ -356,6 +368,8 @@ def _handle_status(args: list[str] | None = None) -> dict:
|
||||
|
||||
_, branch_dir = detected
|
||||
|
||||
branch_name = detected[0]
|
||||
|
||||
if show_all:
|
||||
repo_root = lock_handler.find_repo_root()
|
||||
result = status_handler.get_branch_status(repo_root)
|
||||
@@ -367,6 +381,9 @@ def _handle_status(args: list[str] | None = None) -> dict:
|
||||
for f in result["files"]:
|
||||
lines.append(f" {f['status']:>2} {f['path']}")
|
||||
|
||||
if not show_all:
|
||||
lines.append(f"(showing {branch_name} scope — use --all for full repo)")
|
||||
|
||||
return {
|
||||
"stdout": "\n".join(lines),
|
||||
"stderr": "",
|
||||
@@ -384,18 +401,18 @@ def _handle_diff(args: list[str]) -> dict:
|
||||
"exit_code": 1,
|
||||
}
|
||||
|
||||
_, branch_dir = detected
|
||||
branch_name, branch_dir = detected
|
||||
staged = "--staged" in args
|
||||
show_all = "--all" in args
|
||||
|
||||
target_dir = lock_handler.find_repo_root() if show_all else branch_dir
|
||||
result = diff_handler.get_branch_diff(target_dir, staged=staged)
|
||||
|
||||
return {
|
||||
"stdout": result["diff"] if result["diff"] else result["message"],
|
||||
"stderr": "",
|
||||
"exit_code": 0,
|
||||
}
|
||||
output = result["diff"] if result["diff"] else result["message"]
|
||||
if not show_all:
|
||||
output += f"\n(showing {branch_name} scope — use --all for full repo)"
|
||||
|
||||
return {"stdout": output, "stderr": "", "exit_code": 0}
|
||||
|
||||
|
||||
def _handle_log(args: list[str]) -> dict:
|
||||
@@ -610,17 +627,16 @@ def get_help(command: str | None = None) -> str:
|
||||
|
||||
return (
|
||||
"git — Tier-based git workflow (dev branch model)\n"
|
||||
"\n"
|
||||
"Global (all branches):\n"
|
||||
" status Show git status for your branch\n"
|
||||
" diff [--staged] Show git diff for your branch\n"
|
||||
" log [count] Show recent git log (default: 10)\n"
|
||||
" lock Check lock status\n"
|
||||
" branches List remote branches\n"
|
||||
" prune-temp Delete merged citizen/* temp branches\n"
|
||||
" issue [args] Passthrough to gh issue\n"
|
||||
" run [args] Passthrough to gh run\n"
|
||||
" workflow [args] Passthrough to gh workflow\n"
|
||||
"\n"
|
||||
"Owner (devpulse only):\n"
|
||||
" commit <msg> [--all | files] Commit changes (selective or --all)\n"
|
||||
" checkout <main|dev> Switch branches\n"
|
||||
@@ -642,52 +658,60 @@ def get_introspective() -> str:
|
||||
"@git — Tier-based git workflow, dev branch model (v3.0.0)\n"
|
||||
"Connected Handlers:\n"
|
||||
" handlers/git/\n"
|
||||
" - lock_handler.py (acquire_lock, release_lock, check_lock_status, force_unlock)\n"
|
||||
" - status_handler.py (get_branch_status — scoped git status)\n"
|
||||
" - diff_handler.py (get_branch_diff — scoped git diff)\n"
|
||||
" - log_handler.py (get_git_log — recent log entries)\n"
|
||||
" - commit_handler.py (commit_changes — selective files, --all, or pre-staged)\n"
|
||||
" - checkout_handler.py (checkout_branch — main/dev only)\n"
|
||||
" - sync_handler.py (sync_main — safe main synchronization)\n"
|
||||
" - dev_pr_handler.py (create_branch_pr, create_dev_pr — PR to main)\n"
|
||||
" - branches_handler.py (list_remote_branches)\n"
|
||||
" - delete_branch_handler.py (delete_remote_branch — protected: main/dev)\n"
|
||||
" - close_pr_handler.py (close_pr — close PR by number)\n"
|
||||
"\n"
|
||||
" - lock_handler.py, status_handler.py, diff_handler.py, log_handler.py\n"
|
||||
" - commit_handler.py, checkout_handler.py, sync_handler.py\n"
|
||||
" - dev_pr_handler.py, branches_handler.py, delete_branch_handler.py, close_pr_handler.py\n"
|
||||
" plugins/devpulse_ops/\n"
|
||||
" - auth.py (verify_git_access — tier-based authorization)\n"
|
||||
" - merge_plugin.py (merge_pr — merge PR + sync)\n"
|
||||
" - sync_plugin.py (smart_sync — fetch + rebase if behind)\n"
|
||||
" - fix_plugin.py (fix_git_state — detect/fix broken states)\n"
|
||||
"\n"
|
||||
" gh passthrough:\n"
|
||||
" - issue, run, workflow → subprocess gh <cmd> [args]\n"
|
||||
"\n"
|
||||
"Access Tiers: global (status, diff, log, lock, branches, issue, run, workflow) | owner (pr, commit, checkout, dev-pr, delete-branch, close-pr, sync, unlock, merge, smart-sync, fix)\n"
|
||||
" - auth.py, merge_plugin.py, sync_plugin.py, fix_plugin.py\n"
|
||||
" gh passthrough: issue, run, workflow\n"
|
||||
"Tiers: global (status,diff,log,lock,branches,prune-temp,issue,run,workflow)"
|
||||
" | owner (pr,commit,checkout,dev-pr,delete-branch,close-pr,sync,unlock,merge,smart-sync,fix)\n"
|
||||
)
|
||||
|
||||
|
||||
def _get_console():
|
||||
try:
|
||||
from aipass.cli.apps.modules.display import console
|
||||
|
||||
return console
|
||||
except ImportError:
|
||||
logger.warning("CLI console not available, using fallback")
|
||||
from rich.console import Console
|
||||
|
||||
return Console()
|
||||
|
||||
|
||||
def print_introspection() -> None:
|
||||
"""Print introspection (seedgo compliance)."""
|
||||
try:
|
||||
from aipass.cli.apps.modules.display import console
|
||||
except ImportError:
|
||||
logger.warning("CLI console not available, using fallback")
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
|
||||
console.print(get_introspective())
|
||||
c = _get_console()
|
||||
c.print()
|
||||
c.print("[bold cyan]@git[/bold cyan] [dim]— Tier-based git workflow, dev branch model (v3.0.0)[/dim]")
|
||||
c.print("[yellow]Connected Handlers:[/yellow]")
|
||||
c.print(" [cyan]handlers/git/[/cyan]")
|
||||
c.print(
|
||||
" - [cyan]lock_handler.py[/cyan], [cyan]status_handler.py[/cyan],"
|
||||
" [cyan]diff_handler.py[/cyan], [cyan]log_handler.py[/cyan]"
|
||||
)
|
||||
c.print(" - [cyan]commit_handler.py[/cyan], [cyan]checkout_handler.py[/cyan], [cyan]sync_handler.py[/cyan]")
|
||||
c.print(
|
||||
" - [cyan]dev_pr_handler.py[/cyan], [cyan]branches_handler.py[/cyan],"
|
||||
" [cyan]delete_branch_handler.py[/cyan], [cyan]close_pr_handler.py[/cyan]"
|
||||
)
|
||||
c.print(" [cyan]plugins/devpulse_ops/[/cyan]")
|
||||
c.print(
|
||||
" - [cyan]auth.py[/cyan], [cyan]merge_plugin.py[/cyan],"
|
||||
" [cyan]sync_plugin.py[/cyan], [cyan]fix_plugin.py[/cyan]"
|
||||
)
|
||||
c.print(" [dim]gh passthrough: issue, run, workflow[/dim]")
|
||||
c.print(
|
||||
"[yellow]Tiers:[/yellow] [dim]global[/dim]"
|
||||
" [dim](status,diff,log,lock,branches,prune-temp,issue,run,workflow)[/dim]"
|
||||
" | [dim]owner[/dim]"
|
||||
" [dim](pr,commit,checkout,dev-pr,delete-branch,close-pr,sync,unlock,merge,smart-sync,fix)[/dim]"
|
||||
)
|
||||
c.print()
|
||||
|
||||
|
||||
def print_help() -> None:
|
||||
"""Print help (seedgo compliance)."""
|
||||
try:
|
||||
from aipass.cli.apps.modules.display import console
|
||||
except ImportError:
|
||||
logger.warning("CLI console not available, using fallback")
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
|
||||
console.print(get_help())
|
||||
_get_console().print(get_help())
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user