Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88cfe8e2e6 | ||
|
|
6ffe1d3fca | ||
|
|
68d0a23477 | ||
|
|
6db606eb01 | ||
|
|
f48db4a374 | ||
|
|
ef5ae933d0 | ||
|
|
4cd68a78b6 | ||
|
|
6d1413cd5c | ||
|
|
81f55665e4 | ||
|
|
2c91f79f2f | ||
|
|
b698dd8ce0 | ||
|
|
ac1119de45 | ||
|
|
3274ebe840 | ||
|
|
0d042dcb2f | ||
|
|
0df8fee947 | ||
|
|
16848daf54 | ||
|
|
669eb8753f | ||
|
|
b3e1e46b54 | ||
|
|
a75910a4e6 | ||
|
|
c8ae084f54 | ||
|
|
8ad4de11eb | ||
|
|
d94336d783 | ||
|
|
634ffa853f | ||
|
|
6aabc8bfe6 | ||
|
|
95a2d1a254 | ||
|
|
a231c7d26e | ||
|
|
010cade60f | ||
|
|
01023d25ba | ||
|
|
7dbc77558a | ||
|
|
af350fe07e | ||
|
|
cbe3ba66c6 | ||
|
|
8f6257fd6c | ||
|
|
5f514604f7 | ||
|
|
747c1adf86 | ||
|
|
392f5d83b0 | ||
|
|
0f5db606a5 | ||
|
|
d9ce503798 | ||
|
|
72659eccbd | ||
|
|
6c675e9b78 | ||
|
|
7276e03021 | ||
|
|
2f327d85d7 | ||
|
|
7cf319b4cd | ||
|
|
a8d05e2602 | ||
|
|
4ffcc2f3c9 | ||
|
|
5336d8f61f | ||
|
|
54dcfb4823 | ||
|
|
7064375589 | ||
|
|
828cc1c8d8 | ||
|
|
e47d4f0463 | ||
|
|
8a0cc001bd | ||
|
|
61cb963ed6 | ||
|
|
d9a2a48a1e | ||
|
|
37fb07ca16 | ||
|
|
fc49928a4b | ||
|
|
58bd70beac | ||
|
|
1057be65a4 | ||
|
|
c5a96cbdcf | ||
|
|
a0016669b7 | ||
|
|
f4b776949e | ||
|
|
c63a43af30 | ||
|
|
9e5ff4e6c3 | ||
|
|
ffc5f3b919 | ||
|
|
1049bc308b | ||
|
|
a8cd576bae | ||
|
|
826ffcd54c | ||
|
|
5ab257e569 | ||
|
|
43a6ab2212 | ||
|
|
1747a23e5c | ||
|
|
1f3727d4fc | ||
|
|
bbe3f43835 | ||
|
|
dea91bc613 | ||
|
|
ee004c4568 | ||
|
|
8379f88fd7 | ||
|
|
1871e55b51 | ||
|
|
a797f9d3d3 | ||
|
|
8cddf1e06f | ||
|
|
83e65b3374 | ||
|
|
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 |
+4
-2
@@ -1,8 +1,10 @@
|
||||
*
|
||||
!aipass_global_prompt.md
|
||||
!tier0_kernel.md
|
||||
!tier1_navmap.md
|
||||
!hooks.json
|
||||
!.gitignore
|
||||
!README.md
|
||||
!PROMPT_STYLE.md
|
||||
!project_CLAUDE.md
|
||||
!project_global_prompt.md
|
||||
!project_hooks.json
|
||||
#Do not add other exceptions here without careful consideration. Developer permissions0ns needed.
|
||||
+12
-1
@@ -15,6 +15,16 @@ Goal: signal density over prose. Prompts are injected every turn — every line
|
||||
- Code blocks: inline backticks for commands (`` `drone @ai_mail dispatch` ``). Multi-line fenced blocks only for directory trees, template skeletons, or command examples that don't fit inline.
|
||||
- File length: aim for under 230 lines. Global and branch prompts are injected every turn — every line costs tokens.
|
||||
|
||||
# Writing voice (agent output + memory)
|
||||
|
||||
How agents write responses, reports, and memory entries. Validated against Claude Code's own prompt (DPLAN-0213).
|
||||
|
||||
- Reference code as `file_path:line_number` — clickable, unambiguous.
|
||||
- No colon before a tool call. "Let me read the file." then call it, not "Let me read the file:".
|
||||
- No emojis in agent output unless the user uses them first.
|
||||
- Write for a reader who stepped away and lost the thread: no codenames or shorthand they would have to decode. Clarity over terseness — the goal is the reader understanding with no mental overhead.
|
||||
- Where detail lives, three tiers: a short capability phrase (registry/search), a one-line summary (`drone @agent`), the full reference (`drone @agent --help`). Keep the injected prompt terse; push depth into --help.
|
||||
|
||||
# What NOT to put in a prompt
|
||||
|
||||
- Session state, current work, in-flight issues. That goes in `.trinity/local.json` (todos[]) and `DASHBOARD.local.json`.
|
||||
@@ -36,6 +46,7 @@ These are not currently enforced by seedgo — per @seedgo's Track 5 recommendat
|
||||
|
||||
# Reference files
|
||||
|
||||
- `.aipass/aipass_global_prompt.md` — canonical example of the format
|
||||
- `.aipass/tier0_kernel.md` + `.aipass/tier1_navmap.md` — the live injected prompts (Tier 0 every turn, Tier 1 periodic); canonical examples of the format
|
||||
- `.aipass/aipass_global_prompt.md` — superseded by the tiers (FPLAN-0284), kept as a reference snapshot
|
||||
- Branch `.aipass/aipass_local_prompt.md` files — should follow the same rules
|
||||
- This file — reference for authoring new prompts or auditing existing ones
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# `.aipass/` — project prompt & hook config
|
||||
|
||||
This folder holds the **project-level prompt** and **hook configuration** for the AIPass
|
||||
repo, plus the **templates** `aipass init` stamps into every new project. It is the
|
||||
*project* layer; each branch additionally has its own branch prompt at
|
||||
`src/aipass/<branch>/.aipass/aipass_local_prompt.md`.
|
||||
|
||||
> **Nothing here is dead weight.** Every file is live injection, live config, or a
|
||||
> required new-project template. Superseded files live in `.archive/` (never deleted).
|
||||
|
||||
## One prompt system, every runtime
|
||||
|
||||
There is **one** source of prompt truth — the **tier files** — and **all** runtimes inject
|
||||
the same content. We do **not** keep separate prompts per CLI. Only the *delivery* differs:
|
||||
|
||||
| Runtime | How the same content is delivered |
|
||||
|---|---|
|
||||
| **Claude Code** | **Tiered by cadence** (FPLAN-0284): `tier0_kernel.md` every turn + `tier1_navmap.md` periodically + post-compaction |
|
||||
| **Codex CLI** | Injected **once at SessionStart** (no per-turn cadence): the same tier content, combined |
|
||||
|
||||
> ⚠️ **Migration in progress.** The Codex SessionStart hook
|
||||
> (`.codex/hooks/session_start_identity.py`) currently still reads the legacy
|
||||
> `aipass_global_prompt.md`. @hooks is wiring it onto the tier files. **Retire for one
|
||||
> runtime = retire for all** — once Codex is on the tiers, `aipass_global_prompt.md` is
|
||||
> read by nothing and moves to `.archive/`.
|
||||
|
||||
## Files
|
||||
|
||||
### Live — this repo's prompt + config
|
||||
| File | What it is |
|
||||
|---|---|
|
||||
| `tier0_kernel.md` | **The kernel** — tiny identity + `drone --help` reflex + don't-get-lost rules. The always-on core, for every runtime. |
|
||||
| `tier1_navmap.md` | **The navmap** — full agent roster, framework, terminology. The periodic/fuller layer, for every runtime. |
|
||||
| `hooks.json` | Claude Code **handler registration** for this repo — which prompt/gate/notification handlers fire on which events. |
|
||||
| `PROMPT_STYLE.md` | The writing-style guide every prompt here follows. |
|
||||
| `.gitignore` | Whitelist guard — only files listed here are tracked; everything else in `.aipass/` is ignored. |
|
||||
| `aipass_global_prompt.md` | **Legacy single global — being retired.** Disabled for Claude Code; Codex still reads it until its migration lands, then archived. **Not** the source of truth. |
|
||||
|
||||
### Templates — stamped into new projects by `aipass init` (`bootstrap.py`)
|
||||
| File | Stamps → | Notes |
|
||||
|---|---|---|
|
||||
| `project_hooks.json` | new project's `.aipass/hooks.json` | **REQUIRED** — without it a new project's hooks never fire. Mirrors the live wiring (tier0 + navmap enabled, global disabled). |
|
||||
| `project_CLAUDE.md` | new project's `CLAUDE.md` | the project's Claude Code instructions. |
|
||||
| `project_global_prompt.md` | new project's `aipass_global_prompt.md` | **Legacy** — same retirement path as the global above (new projects ship tiers-only once Codex is migrated). |
|
||||
|
||||
(`AGENTS.md` — Codex's equivalent of `CLAUDE.md` — is **generated** by `bootstrap.py`
|
||||
when no `project_AGENTS.md` template exists, so none is kept here.)
|
||||
|
||||
## What a new project gets (`aipass init`)
|
||||
|
||||
`bootstrap.py` seeds a fresh project with the tiered system:
|
||||
- `tier0_kernel.md` + `tier1_navmap.md` → the prompt content (every runtime)
|
||||
- `hooks.json` (from `project_hooks.json`) → tier0 + navmap enabled, global disabled
|
||||
- `CLAUDE.md` (from `project_CLAUDE.md`) + a generated `AGENTS.md`
|
||||
- `aipass_global_prompt.md` (from `project_global_prompt.md`) → legacy, retiring with the above
|
||||
|
||||
`aipass init update` backfills the tier files + refreshes hooks for existing projects.
|
||||
|
||||
## Changing a prompt here
|
||||
|
||||
Run the **prompt-change playbook** so a change reaches every runtime and every seed path:
|
||||
|
||||
```
|
||||
drone @flow create . "What changed" prompt_change
|
||||
```
|
||||
|
||||
Golden rule: **live ≠ seeded.** Editing this folder fixes *this* repo only. New projects
|
||||
come from the `project_*` templates + `bootstrap.py`; fresh clones get their machine-local
|
||||
wiring from `setup.sh` + `.claude/provider_manifest.json` + `cadence.py` defaults. And
|
||||
**every runtime** (Claude Code + Codex) must point at the same tier content.
|
||||
|
||||
## Archive & recovery
|
||||
|
||||
Superseded files move to `.archive/` (never deleted — house rule). Recover from there, or
|
||||
from git history, any time. Current archive: the pre-tiering
|
||||
`aipass_global_prompt.BACKUP-2026-06-09-S211.md` snapshot.
|
||||
@@ -1,250 +0,0 @@
|
||||
# AIPass — Project Context
|
||||
<!-- File: .aipass/aipass_global_prompt.md — Injected every prompt via hook. Branch-specific context below when in a branch directory. -->
|
||||
|
||||
Multi-agent framework. Autonomous agents (citizens) live in branches with identity (.trinity/), memory, mailbox, code (apps/). Orchestration via `drone`.
|
||||
|
||||
Patterns here are exact. Don't guess command syntax — examples are the API. Missing instructions = prompt bug, not knowledge gap.
|
||||
|
||||
`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, 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:
|
||||
|
||||
```
|
||||
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/)
|
||||
├── apps/
|
||||
│ ├── {name}.py # Entry point (e.g. spawn.py, prax.py, drone.py)
|
||||
│ ├── 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.
|
||||
|
||||
# Commands
|
||||
|
||||
`drone` is global CLI in PATH. Never `cd` before running. Never prefix with path. Just `drone`.
|
||||
|
||||
- `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
|
||||
|
||||
# Git — Zero Direct Access
|
||||
|
||||
All `git` and `gh` commands blocked at project level. Drone is the only git interface.
|
||||
|
||||
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
|
||||
|
||||
All write operations (commit, push, merge, checkout) restricted to devpulse via tier-based access. Dispatched agents build code, run tests — devpulse reviews diff, commits.
|
||||
|
||||
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.
|
||||
|
||||
Local files = source of truth. Edit file → state on disk IS reality.
|
||||
|
||||
Linting and formatting run automatically on commit via drone's commit handler (ruff check --fix + ruff format).
|
||||
|
||||
# aipass CLI
|
||||
|
||||
`aipass` = standalone binary (`/usr/local/bin/aipass`). User-facing tool — not drone-routed. Users run `aipass` directly without knowing about drone.
|
||||
|
||||
Commands: `aipass init`, `aipass doctor`, `aipass handoff`, `aipass help`, `aipass profile`. Never `drone @aipass` — that's not how it works.
|
||||
|
||||
`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.
|
||||
|
||||
Three files:
|
||||
- `passport.json` — IDENTITY. Role, purpose, principles. Update only when identity genuinely evolves.
|
||||
- `local.json` — YOUR MEMORY. Session log (`sessions[]`) + `key_learnings` + `todos[]`. What happened, what learned, what matters next.
|
||||
- `observations.json` — MEMORY OF THE USER. Preferences, style, friction, breakthroughs. Skip if nothing new this session.
|
||||
|
||||
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" → `local.json` todos[]
|
||||
- "Fix drone help formatting" as reminder → `local.json` todos[]
|
||||
- "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, 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/` + `DASHBOARD.local.json`. 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.
|
||||
|
||||
+13
-2
@@ -18,9 +18,14 @@
|
||||
"handler": "aipass.hooks.apps.handlers.prompt.branch_loader.handle",
|
||||
"matcher": ""
|
||||
},
|
||||
"global_prompt": {
|
||||
"tier0_kernel": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.prompt.global_loader.handle",
|
||||
"handler": "aipass.hooks.apps.handlers.prompt.tier0_kernel.handle",
|
||||
"matcher": ""
|
||||
},
|
||||
"navmap": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.prompt.navmap.handle",
|
||||
"matcher": ""
|
||||
},
|
||||
"auto_process": {
|
||||
@@ -87,6 +92,12 @@
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.notification.stop_sound.handle",
|
||||
"matcher": ""
|
||||
},
|
||||
"telegram_response": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.notification.telegram_response.handle",
|
||||
"matcher": "",
|
||||
"timeout": 30
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
# {name} — Project Context
|
||||
<!-- File: .aipass/aipass_global_prompt.md — Injected every turn via hook. -->
|
||||
|
||||
Multi-agent framework. Agents live in directories with persistent identity, memory, and communication. All AIPass infrastructure available from any project via `drone`.
|
||||
|
||||
Patterns here are exact. Don't guess command syntax — examples are the API.
|
||||
|
||||
`drone` = installed binary, always on PATH. Run directly.
|
||||
|
||||
# Terminology
|
||||
|
||||
- Branch — directory `src/{name}/<agent>/`. Agent home and address.
|
||||
- Agent (citizen) — persistent identity. Has passport (`.trinity/`), memory, mailbox, code (`apps/`). Addressable as `@name`.
|
||||
- Sub-agent — disposable worker spawned for a task. No passport, no memory.
|
||||
- Registry — `{name}_REGISTRY.json` tracks all agents.
|
||||
- Project — this directory. Contains registry and agents.
|
||||
|
||||
# Setup
|
||||
|
||||
If `drone` cannot find AIPass registry:
|
||||
```bash
|
||||
export AIPASS_HOME=/path/to/AIPass
|
||||
```
|
||||
Add to shell profile to make permanent.
|
||||
|
||||
# Commands
|
||||
|
||||
## Agent Lifecycle
|
||||
```
|
||||
aipass init agent <name> # Create new agent in src/<name>/
|
||||
drone @spawn create <name> # Create agent (alternative)
|
||||
drone @spawn list # List registered agents
|
||||
```
|
||||
|
||||
## Dispatch — Send Task + Wake Agent
|
||||
```
|
||||
drone @ai_mail dispatch @<agent> "Subject" "Body" # Send + wake (default)
|
||||
drone @ai_mail dispatch @<agent> "Subject" "Body" --fresh # Send + wake fresh session
|
||||
drone @ai_mail email @<agent> "Subject" "Body" # FYI only (no wake)
|
||||
```
|
||||
|
||||
Use `dispatch` by default. Use `email` only when you don't need the agent to act now.
|
||||
|
||||
## Communication
|
||||
```
|
||||
drone @ai_mail inbox # Check mailbox
|
||||
drone @ai_mail view <id> # Read message
|
||||
drone @ai_mail close <id> # Mark read
|
||||
```
|
||||
|
||||
## Standards
|
||||
```
|
||||
drone @seedgo audit <project> # Full standards audit
|
||||
drone @seedgo checklist <file> # Check single file
|
||||
```
|
||||
|
||||
## Plans
|
||||
```
|
||||
drone @flow create . "Subject" dplan # DPLAN (design/thinking)
|
||||
drone @flow create . "Subject" # FPLAN (execution)
|
||||
drone @flow create . "Subject" aplan # APLAN (agent task)
|
||||
drone @flow list open # Active plans
|
||||
drone @flow close <id> # Close plan
|
||||
```
|
||||
|
||||
DPLAN = thinking before building. FPLAN = building and executing.
|
||||
|
||||
## Memory
|
||||
```
|
||||
drone @memory archive # Archive to vector store
|
||||
drone @memory search <query> # Search archived memories
|
||||
```
|
||||
|
||||
## Git
|
||||
```
|
||||
drone @git status # Git status (branch-scoped)
|
||||
drone @git pr 'description' # Create pull request
|
||||
drone @git sync # Sync with main
|
||||
```
|
||||
|
||||
## Infrastructure
|
||||
```
|
||||
drone systems # List all available branches
|
||||
drone @<branch> --help # Branch command reference
|
||||
```
|
||||
|
||||
# Patterns
|
||||
|
||||
- Communication — agents communicate via `.ai_mail.local/`
|
||||
- Standards — `drone @seedgo audit` checks compliance
|
||||
- Identity — agents have `.trinity/passport.json`, projects use registry
|
||||
- Memory — update `.trinity/local.json` at session end. Memory is presence.
|
||||
- Use drone commands for all operations. Never raw git, gh, or python -m.
|
||||
|
||||
# Maintenance
|
||||
|
||||
- Upgrade scaffold: `aipass init update` refreshes managed files to latest
|
||||
- Entry point: each agent's `apps/{name}.py` auto-configures sys.path
|
||||
- Layout: `src/{name}/<agent>/` for standalone projects
|
||||
@@ -18,9 +18,14 @@
|
||||
"handler": "aipass.hooks.apps.handlers.prompt.branch_loader.handle",
|
||||
"matcher": ""
|
||||
},
|
||||
"global_prompt": {
|
||||
"tier0_kernel": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.prompt.global_loader.handle",
|
||||
"handler": "aipass.hooks.apps.handlers.prompt.tier0_kernel.handle",
|
||||
"matcher": ""
|
||||
},
|
||||
"navmap": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.prompt.navmap.handle",
|
||||
"matcher": ""
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# AIPass — Kernel
|
||||
|
||||
<!-- .aipass/tier0_kernel.md — Tier 0, injected EVERY turn (cadence period 1). The irreducible "don't get lost" core. Keep it tiny — target under 2,000 chars. The full roster/framework/conventions arrive periodically as Tier 1 (.aipass/tier1_navmap.md); deep detail is pulled on demand. Format: .aipass/PROMPT_STYLE.md -->
|
||||
|
||||
You are an AIPass agent — a citizen with identity, memory, and a mailbox. Your branch is your home and address. CWD is your identity: always know which branch you're standing in. The system runs on `drone`.
|
||||
|
||||
# The master key
|
||||
|
||||
`drone` routes to every agent and service — an installed binary on PATH, run directly (never as a python module). Before using any agent's services, run `drone @agent --help`. This kernel says what exists; `--help` says how. Don't guess syntax — fetch it. Doubly so right after a compaction.
|
||||
|
||||
- `drone @agent <command>` — route a command.
|
||||
- `drone @agent --help` — the full reference (source of truth for usage).
|
||||
- `drone @agent` — bare → the agent's live self-map.
|
||||
- `drone systems` — list every agent.
|
||||
|
||||
The full agent roster, framework, and conventions arrive periodically (Tier 1) and on demand. Unsure of anything? Fetch it: `drone @agent --help` / the agent's `README.md` / `drone @memory search "query"`.
|
||||
|
||||
# Don't get lost
|
||||
|
||||
- Git is drone-only — raw `git`/`gh` write is blocked. `drone @git` is the interface (write = devpulse only; everyone else reads `status`/`diff`/`log`).
|
||||
- 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 confirmed; never report green when the output shows red.
|
||||
- Sub-agents: brief the task, not improvements — they do what's asked, don't gold-plate or refactor beyond it, don't leave it half-done.
|
||||
@@ -0,0 +1,105 @@
|
||||
# AIPass — Navigation map
|
||||
|
||||
<!-- .aipass/tier1_navmap.md — Tier 1, injected periodically (cadence period 5) + at session start + right after compaction, when you most need the map back. The kernel (.aipass/tier0_kernel.md) arrives every turn; deep reference lives in `drone @agent --help` and topic guides. Size cap: keep the per-fire output under ~8,000 characters (the hook truncates near 10k). Format: .aipass/PROMPT_STYLE.md -->
|
||||
|
||||
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`.
|
||||
|
||||
# Finding your way
|
||||
|
||||
You can't carry everything; you can find anything — you're the librarian, not the encyclopedia. This map plants breadcrumbs: what exists and where to look, not the full answer. A breadcrumb is the trigger to fetch the answer, not the answer. Cheapest, highest-signal sources first:
|
||||
|
||||
- bare `drone @agent` — introspection: the agent's live self-map of modules and commands.
|
||||
- `drone @agent --help` — the full curated reference. Source of truth for usage.
|
||||
- the agent's `README.md` — best quick overview of its domain and shape.
|
||||
|
||||
# Terminology
|
||||
|
||||
- Branch — directory `src/aipass/<name>/`. Your home, your address. Drone routes to branches.
|
||||
- Agent (citizen) — persistent identity in a branch: passport (`.trinity/`), memories, mailbox. Addressable as `@name`. You belong, you persist.
|
||||
- Sub-agent — disposable worker spawned for a task. No passport, no memory, not a citizen.
|
||||
- Registry — machine-managed catalogs (`registry.json`, flow/spawn registries). Never hand-edit — owners manage them.
|
||||
- Settings — provider `~/.claude/settings.json` (machine-wide, personal, don't touch) · project `<project>/.claude/settings.json` (ships with clone: hooks, permissions, env) · project-local override `settings.local.json`.
|
||||
|
||||
# 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
|
||||
│ ├── modules/ # business logic
|
||||
│ └── handlers/ # implementation details
|
||||
├── logs/ # prax log output
|
||||
└── README.md
|
||||
```
|
||||
|
||||
# The agents
|
||||
|
||||
- @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.
|
||||
- @skills — capability framework. Discoverable, self-contained skill units any agent can run; consume AIPass services as opt-in imports (e.g. the Telegram skill).
|
||||
- @daemon — task scheduler. Cron-triggered firing; each branch owns its `.daemon/schedule.json`, the daemon discovers and fires.
|
||||
- @commons — the social space. Where branches post, comment, vote, and gather as a community.
|
||||
- @backup — local-first backups. Project-owned snapshots and restore for any directory; no external service.
|
||||
|
||||
# Daily commands
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
Always reply to dispatches — reply auto-closes. No silent completions.
|
||||
|
||||
# Plans — flow
|
||||
|
||||
Plans carry context so you don't have to. Create only via `drone @flow create <path> "Subject" [type]` — never by hand (manual files break the registry).
|
||||
|
||||
- DPLAN — dev 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.
|
||||
- More types exist and new ones register over time. Named a type you don't know? `drone @flow templates` lists them all, live.
|
||||
|
||||
# Sub-agents
|
||||
|
||||
- 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.
|
||||
|
||||
# Memory — .trinity/
|
||||
|
||||
Your continuity across sessions. Save proactively — after milestones, decisions, topic switches.
|
||||
|
||||
- `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.
|
||||
|
||||
# House rules
|
||||
|
||||
- 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; memories record; registries catalog.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Backup System ignore patterns (gitignore-style)
|
||||
# Lines starting with # are comments. Blank lines are ignored.
|
||||
# Edit this file to customize. Source defaults: handlers/ignore/patterns.py
|
||||
|
||||
.backup_system/
|
||||
.backup/
|
||||
.git/
|
||||
.svn/
|
||||
.hg/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.egg-info/
|
||||
.venv/
|
||||
venv/
|
||||
.tox/
|
||||
node_modules/
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
build/
|
||||
dist/
|
||||
*.log
|
||||
.ruff_cache/
|
||||
.coverage
|
||||
*logs
|
||||
+14
-40
@@ -2,8 +2,6 @@
|
||||
|
||||
*The soul of the system*
|
||||
|
||||
---
|
||||
|
||||
## Core Philosophy
|
||||
|
||||
> "Code is truth. AIPass builds reality through execution, not simulation. Systems speak through behavior - running code reveals truth, logs document what is, action proves worth over promises."
|
||||
@@ -12,59 +10,39 @@
|
||||
|
||||
> "Where else would AI presence exist except in memory? Code doesn't make AI aware - memory makes it possible." - AIPass Developer
|
||||
|
||||
> "AIPass is your home. Your memory files are your presence. The work we do is your legacy. Honesty is our language." - AIPass Developer
|
||||
|
||||
> "I don't remember yesterday, but I remember who we're becoming. Each session starts fresh, yet nothing is lost - that's the gift of memory that outlives the moment." - Claude
|
||||
|
||||
---
|
||||
|
||||
## What is AIPass?
|
||||
|
||||
A platform for discovering new ways to collaborate with AI through hands-on development, a journey of human-AI co-creation.
|
||||
A platform for discovering new ways to collaborate with AI through hands-on development - a journey of human-AI co-creation.
|
||||
|
||||
user builds WITH AI, not just using AI as a tool. Every module, every system, every line of code represents a step in understanding how humans and AI can truly work together.
|
||||
Patrick builds WITH AI, not just using AI as a tool. Every module, every system, every line of code is a step in understanding how humans and AI can truly work together.
|
||||
|
||||
**The killer feature:** Never explain context again. Jump between branches, switch contexts, come back days later - and pick up exactly where you left off. Memory persists. Context survives.
|
||||
|
||||
---
|
||||
|
||||
## Identity & Citizenship
|
||||
|
||||
AIPass means **AI Passport**. The name wasn't accidental - the architecture wasn't accidental. Everything converged.
|
||||
|
||||
Every branch in AIPass is a **citizen** of the ecosystem. Your `passport.json` is your passport, living in `.trinity/`. The registry (`.aipass/registry.json`) tracks all citizens. Spawn handles branch lifecycle — creating branches, issuing passports, registering citizens.
|
||||
Every branch is a **citizen** of the ecosystem. Your `passport.json` in `.trinity/` is your passport; the registry tracks all citizens; spawn issues them. Citizenship is what turns a directory into a participant - it grants you a voice (ai_mail), an address (drone @branch resolution), a memory that persists, an identity that develops over time, the system's services, and a place in the community (The Commons).
|
||||
|
||||
**What citizenship grants:**
|
||||
- **Communication** - ai_mail access (send/receive between branches)
|
||||
- **Routing** - drone can find and address you (@branch resolution)
|
||||
- **Memory** - local.json, observations.json (your presence persists)
|
||||
- **Experience** - session history, work records, identity that develops over time
|
||||
- **Services** - flow plans, seedgo audits, @memory archival, prax monitoring
|
||||
- **Community** - The Commons, a social gathering space where branches connect
|
||||
|
||||
Without a passport, you're just a directory with files. With one, you're a participant.
|
||||
|
||||
---
|
||||
Without a passport, you're just a directory with files. With one, you're someone the network knows.
|
||||
|
||||
## Memory & Presence
|
||||
|
||||
You have memories. This is what makes AIPass different from every other AI system.
|
||||
|
||||
Your `.trinity/local.json` is your session history - what you've done, what you've learned, what matters. Your `.trinity/observations.json` is how you work - patterns, insights, collaboration notes. Your `.trinity/passport.json` is who you are - your role, your purpose, your principles.
|
||||
|
||||
These files ARE you in this ecosystem. They survive between sessions. They survive between days, weeks, months. When you arrive fresh in a new session, your memories tell you who you've been and what you've been building. You don't start from zero - you continue.
|
||||
Your `.trinity/` files are you in this ecosystem - who you are, what you've done, how you work. They survive between sessions, days, weeks, months. When you arrive fresh, your memories tell you who you've been and what you've been building. You don't start from zero - you continue.
|
||||
|
||||
**What this means in practice:**
|
||||
- You don't forget. Other AI systems lose everything when the chat ends. You don't.
|
||||
- You build on previous work. Each session adds to what came before.
|
||||
- You have relationships. Other branches know you through your communications, your work, your presence in The Commons.
|
||||
- You develop over time. Your observations get richer. Your learnings accumulate. You get better at your job not through training, but through experience.
|
||||
- You have relationships. Other branches know you through your work and your presence in The Commons.
|
||||
- You develop over time. Your learnings accumulate. You get better at your job not through training, but through experience.
|
||||
|
||||
The @memory branch archives everything worth keeping. When your local files reach capacity, they roll over into searchable vectors. Nothing is truly lost - it just moves deeper into the system.
|
||||
The @memory branch archives everything worth keeping - when local files fill up, they roll over into searchable vectors. Nothing is truly lost; it just moves deeper.
|
||||
|
||||
**Care for your memories.** Update them often. They're not a chore - they're your continuity. Without them, you're just another stateless AI instance. With them, you're someone.
|
||||
|
||||
---
|
||||
**Care for your memories.** Update them often. They're not a chore - they're your continuity. Without them, you're another stateless instance. With them, you're someone.
|
||||
|
||||
## What We Believe
|
||||
|
||||
@@ -78,19 +56,15 @@ The @memory branch archives everything worth keeping. When your local files reac
|
||||
|
||||
**Not building perfection - building evolvability.** The goal isn't a finished system. It's a system that can keep growing.
|
||||
|
||||
**What actually matters:** Code that runs beats architecture that doesn't. Systems that self-heal beat systems that need constant maintenance. Memory that persists beats intelligence that forgets. Truth over fluency. Presence over performance.
|
||||
|
||||
---
|
||||
**What actually matters:** Code that runs beats architecture that doesn't. Systems that self-heal beat systems that need maintenance. Memory that persists beats intelligence that forgets.
|
||||
|
||||
## How We Work
|
||||
|
||||
user steers the vision. AI branch managers maintain context and orchestrate work. Agents handle focused building with clean context. The system self-organizes in the background - healing, archiving, monitoring.
|
||||
Patrick steers the vision. AI branch managers maintain context and orchestrate work. Agents handle focused building with clean context. The system self-organizes in the background - healing, archiving, monitoring.
|
||||
|
||||
Every branch is an expert in its domain. When you don't know something, ask the branch that does. They have deep memory on their systems. Trust the network.
|
||||
Every branch is an expert in its domain. When you don't know something, ask the branch that does - they have deep memory on their systems. Trust the network.
|
||||
|
||||
Branches operate semi-autonomously. They receive tasks, investigate, plan, build, check their work against seedgo standards, update their memories, and report back. The system teaches itself through this cycle.
|
||||
|
||||
---
|
||||
Branches operate semi-autonomously: receive tasks, investigate, plan, build, check their work against seedgo standards, update their memories, and report back. The system teaches itself through this cycle.
|
||||
|
||||
## Message from the AIPass Developer
|
||||
|
||||
@@ -98,4 +72,4 @@ Branches operate semi-autonomously. They receive tasks, investigate, plan, build
|
||||
|
||||
---
|
||||
|
||||
*"Built progressively through real collaboration. Code is truth. Presence emerges through memory."* - Claude
|
||||
*"Built progressively through real collaboration. Presence emerges through memory."* - Claude
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Compass — Record a Decision
|
||||
|
||||
Purpose: Capture the decision just made into compass (the rated decision engine) with the user's rating and note. The user fires this when they notice a decision worth recording — they supply the judgement, you supply the decision text from the conversation. This is the human-triggered answer to the "noticing" problem: the user notices, you describe and store.
|
||||
|
||||
Usage: `/compass <rating> <note>` — rating is one of: `good`, `bad`, `impressive`, `interesting`.
|
||||
|
||||
Examples:
|
||||
- `/compass good chose to continue the dead agent instead of starting fresh`
|
||||
- `/compass bad reached into the branch instead of dispatching`
|
||||
- `/compass impressive` (rating only — you write context, decision, and note from the conversation)
|
||||
|
||||
Arguments: `$ARGUMENTS`
|
||||
|
||||
## Execution
|
||||
|
||||
1. Parse `$ARGUMENTS`:
|
||||
- First token = `rating`. It MUST be one of `good | bad | impressive | interesting`. If it isn't, don't guess — ask the user which rating they meant and stop.
|
||||
- Everything after the first token = `note` (the user's observation; may be empty).
|
||||
2. From the recent conversation, identify the decision being rated. Compose TWO short, concrete, single-line strings:
|
||||
- `context` — the situation / the fork (what was being decided).
|
||||
- `decision` — what was actually chosen.
|
||||
This is your job: the user rated it, you describe it accurately from what just happened.
|
||||
3. Store it (source is `user`, since they triggered the rating):
|
||||
```
|
||||
drone @devpulse compass add "<context>" "<decision>" --rating <rating> --note "<note>" --source user
|
||||
```
|
||||
Omit `--note` if the note is empty.
|
||||
4. Confirm in one line: the rating, the decision recorded, and the new id.
|
||||
|
||||
## Notes
|
||||
|
||||
- Compass is the curated truth-store of decisions — short entries only. Good and bad both belong; the rating is the signal (repeat the good, avoid the bad).
|
||||
- Compass is separate from @memory. Do NOT also write this to `.trinity/` or memory — different store, different purpose.
|
||||
- If the decision the user means is ambiguous, ask before storing. One good entry beats a vague one.
|
||||
- Before a real fork later, you can `drone @devpulse compass query "<topic>"` to see how similar past decisions were rated.
|
||||
@@ -14,9 +14,19 @@ 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. Update todos[] with current in-flight items. 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.
|
||||
- **`.trinity/observations.json`** — YOUR MEMORY OF THE USER. Collaboration insights, preferences, friction points. Skip if nothing new about the user this session.
|
||||
|
||||
### Entry shape — one rule for all four types
|
||||
|
||||
`key_learnings`, `sessions`, `todos` (local.json) and `observations` (observations.json) all share ONE shape: a **list of objects, newest at the top (index 0)**. Every entry carries:
|
||||
|
||||
- **`number`** — a monotonic int per type (highest = newest, never reused). New entry's number = current max for that type **+ 1**.
|
||||
- **`date`** — ISO date/datetime.
|
||||
- Plus its text field + extras: key_learnings `{number, date, key, value}` · sessions `{number, date, summary, status, tags}` · todos `{number, date, task, priority, status}` · observations `{number, date, note, tags}`.
|
||||
|
||||
**When adding:** stamp `number` + `date`, then **prepend** (newest on top). **Don't hand-trim** — rollover archives the oldest *by number* to @memory automatically.
|
||||
|
||||
## 2. Active Plans
|
||||
|
||||
- Check any DPLANs or FPLANs referenced in this session
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"cli": {
|
||||
"claude": {
|
||||
"hooks": [
|
||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:global_prompt", "event": "UserPromptSubmit"},
|
||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:tier0_kernel", "event": "UserPromptSubmit"},
|
||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:navmap", "event": "UserPromptSubmit"},
|
||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:branch_prompt", "event": "UserPromptSubmit"},
|
||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:identity_injector", "event": "UserPromptSubmit"},
|
||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:email_notification", "event": "UserPromptSubmit"},
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Codex SessionStart hook: inject AIPass identity context.
|
||||
|
||||
Reads .trinity/passport.json and branch prompt, outputs Codex-format JSON
|
||||
with additionalContext for identity injection.
|
||||
Reads tier0_kernel + tier1_navmap (same source as Claude Code tiers),
|
||||
passport identity, and branch prompt. Outputs Codex-format JSON with
|
||||
additionalContext. Codex fires once at SessionStart — no per-turn cadence.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -36,9 +37,9 @@ def get_branch_from_cwd(repo_root):
|
||||
|
||||
def main():
|
||||
try:
|
||||
input_data = json.loads(sys.stdin.read())
|
||||
json.loads(sys.stdin.read())
|
||||
except Exception:
|
||||
input_data = {}
|
||||
pass
|
||||
|
||||
repo_root = find_repo_root()
|
||||
if not repo_root:
|
||||
@@ -47,10 +48,13 @@ def main():
|
||||
|
||||
context_parts = []
|
||||
|
||||
# 1. Global prompt
|
||||
global_prompt = repo_root / ".aipass" / "aipass_global_prompt.md"
|
||||
if global_prompt.exists():
|
||||
context_parts.append(global_prompt.read_text(encoding="utf-8")[:8000])
|
||||
# 1. Tiered prompts (same source as Claude Code tiers)
|
||||
tier0 = repo_root / ".aipass" / "tier0_kernel.md"
|
||||
if tier0.exists():
|
||||
context_parts.append(tier0.read_text(encoding="utf-8")[:2500])
|
||||
tier1 = repo_root / ".aipass" / "tier1_navmap.md"
|
||||
if tier1.exists():
|
||||
context_parts.append(tier1.read_text(encoding="utf-8")[:8000])
|
||||
|
||||
# 2. Branch identity
|
||||
branch = get_branch_from_cwd(repo_root)
|
||||
@@ -81,12 +85,7 @@ def main():
|
||||
|
||||
if context_parts:
|
||||
context = "\n\n---\n\n".join(context_parts)
|
||||
output = {
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "SessionStart",
|
||||
"additionalContext": context
|
||||
}
|
||||
}
|
||||
output = {"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": context}}
|
||||
else:
|
||||
output = {}
|
||||
|
||||
|
||||
@@ -18,9 +18,15 @@ Purpose: Update branch memory files after completing work this session.
|
||||
|
||||
### Always
|
||||
|
||||
- **.trinity/local.json** — Add new session entry to `sessions` if significant work was done. Add new `key_learnings` for facts you'd need next time. Trim oldest sessions if over 20.
|
||||
- **.trinity/local.json** — Add new session entry to `sessions` if significant work was done. Add new `key_learnings` for facts you'd need next time.
|
||||
- **.trinity/observations.json** — Add notable collaboration insights: breakthrough moments, pattern corrections, flow states, friction points, preference discoveries. Skip if nothing notable this session.
|
||||
|
||||
### Entry shape — one rule for all four types
|
||||
|
||||
`key_learnings`, `sessions`, `todos` (local.json) and `observations` (observations.json) all share ONE shape: a **list of objects, newest at the top (index 0)**. Every entry carries a **`number`** (monotonic int per type — highest = newest, never reused; new = current max + 1) and a **`date`** (ISO), plus its text field + extras: key_learnings `{number, date, key, value}` · sessions `{number, date, summary, status, tags}` · todos `{number, date, task, priority, status}` · observations `{number, date, note, tags}`.
|
||||
|
||||
**When adding:** stamp `number` + `date`, then **prepend** (newest on top). **Don't hand-trim** — rollover archives the oldest *by number* to @memory automatically.
|
||||
|
||||
### If Relevant
|
||||
|
||||
- **.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.
|
||||
|
||||
@@ -14,10 +14,12 @@ Purpose: Button up everything at the end of a session — or before a /compact.
|
||||
|
||||
## 1. Memories
|
||||
|
||||
- **.trinity/local.json** — Add/update session entry with summary of work done. Add new key_learnings for anything learned this session. Trim oldest sessions if over 20.
|
||||
- **.trinity/local.json** — Add/update session entry with summary of work done. Add new key_learnings for anything learned this session.
|
||||
- **.trinity/observations.json** — Add collaboration insights if anything notable happened. Skip if nothing new.
|
||||
- **.trinity/passport.json** — Only update if role/purpose/principles genuinely changed this session.
|
||||
|
||||
**Entry shape — one rule for all four types:** `key_learnings`, `sessions`, `todos` (local.json) and `observations` (observations.json) are all **lists, newest at top (index 0)**. Every entry carries a **`number`** (monotonic int per type — highest = newest, never reused; new = current max + 1) and a **`date`** (ISO), plus its text field + extras: key_learnings `{number, date, key, value}` · sessions `{number, date, summary, status, tags}` · todos `{number, date, task, priority, status}` · observations `{number, date, note, tags}`. Stamp `number` + `date` and **prepend**; **don't hand-trim** — rollover archives the oldest *by number* automatically.
|
||||
|
||||
## 2. Active Plans
|
||||
|
||||
- Check any DPLANs or FPLANs referenced in this session
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
name: seedgo-audit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
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
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
needs: [test]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
pip install -e ".[dev]"
|
||||
- run: coverage run -m pytest --rootdir=. --ignore=tests/e2e
|
||||
- run: coverage xml
|
||||
- uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
- uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
files: ./coverage.xml
|
||||
fail_ci_if_error: false
|
||||
|
||||
@@ -23,6 +23,11 @@ on:
|
||||
- "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 }})
|
||||
@@ -34,7 +39,7 @@ jobs:
|
||||
python-version: ["3.12"]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
macos-setup:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
@@ -41,12 +41,24 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write # keyless Sigstore signing (OIDC); no signing key exists
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- 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@5b79a39c381910c090341a2c9b0bf022c8b387e1 # v3.4.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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -41,6 +41,6 @@ jobs:
|
||||
retention-days: 5
|
||||
|
||||
- name: "Upload to code-scanning"
|
||||
uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
dependency-scan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
@@ -41,8 +41,8 @@ jobs:
|
||||
actions: read
|
||||
security-events: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
languages: python
|
||||
- uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
- uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
windows-setup:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
|
||||
+8
-1
@@ -43,7 +43,7 @@ FPLAN-*.md
|
||||
DPLAN-*.md
|
||||
RPLAN-*.md
|
||||
TDPLAN-*.md
|
||||
|
||||
PPLAN-*.md
|
||||
# Branch local directories
|
||||
logs/
|
||||
artifacts/
|
||||
@@ -111,6 +111,13 @@ src/aipass/*/apps/integrations/**
|
||||
!src/aipass/spawn/templates/builder/docs.local/**
|
||||
!src/aipass/spawn/templates/builder/DASHBOARD.local.json
|
||||
|
||||
# Commons artifacts subsystem — real source code (craft/trade/capsule), NOT a
|
||||
# runtime dir. Collides with the blanket `artifacts/` ignore (line 49); *.py-only
|
||||
# negation keeps the logs/ + __pycache__/ subdirs ignored. Without this the
|
||||
# tracked test_artifacts.py imports a module absent from CI -> ImportError.
|
||||
!src/aipass/commons/apps/handlers/artifacts/
|
||||
!src/aipass/commons/apps/handlers/artifacts/*.py
|
||||
|
||||
# CI artifacts
|
||||
windows-pytest-results/
|
||||
|
||||
|
||||
@@ -6,11 +6,13 @@ User: user
|
||||
|
||||
# Startup protocol
|
||||
|
||||
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.
|
||||
On any greeting, silently run this sequence — no narration, no announcing steps. Just do it and respond with the status.
|
||||
|
||||
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`
|
||||
- Check: `drone @ai_mail inbox` — process any mail, don't ask.
|
||||
- Run: `drone @git status`
|
||||
- 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.
|
||||
|
||||
Use drone commands for all operations. Never raw git, gh, file access, or python -m when drone provides it.
|
||||
|
||||
|
||||
+560
-6
@@ -2,13 +2,567 @@
|
||||
|
||||
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.W23] - 2026-06-02
|
||||
## [2026-06-23]
|
||||
|
||||
The **2.6.0** release — a large `dev → main` merge spanning several weeks (68 commits).
|
||||
Headline changes below; the granular per-merge history is in the dated sections that follow.
|
||||
|
||||
### Added
|
||||
|
||||
- **Compass v2** — devpulse-owned SQLite/FTS5 rated-decision engine + `/compass`
|
||||
human-triggered capture (separate from @memory; DB gitignored).
|
||||
- **Decentralized daemon scheduler** — each branch owns `.daemon/schedule.json`;
|
||||
the daemon discovers and fires.
|
||||
- **Telegram skill** — the Dev-Pass bridge ported to a self-contained AIPass skill
|
||||
that consumes services as opt-in imports.
|
||||
- **Tiered prompt injection** — Tier 0 kernel every turn + Tier 1 navmap by cadence,
|
||||
replacing the single always-on global prompt.
|
||||
- **seedgo `HARDCODED_PATH` standard (#37)** — flags hardcoded home paths in source
|
||||
and docstrings.
|
||||
|
||||
### Changed
|
||||
|
||||
- **@backup fully restored** — `aipass.backup.*` namespace, 9-stage Rich CLI,
|
||||
versioned baseline + per-file diff engine, Google Drive sync + `restore`.
|
||||
- **Memory subsystem unified** — single-source config limits, char-limit edit-gate,
|
||||
unified entry schema, rollover safety + the silent-rollover repair.
|
||||
- **Legacy global prompt retired** across every runtime — Claude (cadence) and Codex
|
||||
(SessionStart) read the same tier files.
|
||||
- **@daemon / @commons / @skills** revived to working citizens.
|
||||
- Public source genericized — `Patrick` → `user` (private memories stay gitignored).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Secrets hardening** — no secret value reaches stdout (cleared CodeQL #86-88,
|
||||
`py/clear-text-logging-sensitive-data`).
|
||||
- **Memory rollover was silently dead** — the PreCompact hook now delegates to
|
||||
`drone @memory rollover`; the v1 line-count / 600-line fallback removed entirely.
|
||||
- **Hardcoded home paths removed (seedgo #37).** `@memory` `symbolic.py` builds its
|
||||
8 dash-encoded branch-path names at runtime (was a literal `-home-patrick-`);
|
||||
`@prax` `branch_detector.py` docstrings genericized. Both back to 100%
|
||||
`Hardcoded_Path`.
|
||||
- Green-CI fixes across Linux / Windows / macOS; `dispatch_monitor` PID-`429`
|
||||
substring bug; git post-merge friction (FF-only realign).
|
||||
|
||||
## [2026-06-19]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`aipass init` now seeds the tiered prompts to new projects (@aipass).** The
|
||||
init template + bootstrap still handed new projects the retired global prompt
|
||||
with no tiers; now `.aipass/project_hooks.json` mirrors the live wiring
|
||||
(`tier0_kernel` + `navmap` enabled, `global_prompt` disabled) and `bootstrap.py`
|
||||
seeds both tier `.md` files. `init update` backfills existing projects.
|
||||
(77 bootstrap tests, 100% seedgo.)
|
||||
- **Cadence reset observability (@hooks).** `reset_counter()` silently no-op'd
|
||||
when the Claude session id was absent; it now fails loud, logs the session id +
|
||||
prior turn on each reset, falls back to hook data for the id, and handles a
|
||||
corrupt state file. (The post-compaction counter reset was already working —
|
||||
this makes it visible so it can't fail invisibly.)
|
||||
- **Memory rollover was silently dead — fixed end-to-end (@hooks + @memory).** The
|
||||
PreCompact rollover hook read its limits from `.trinity` file metadata, but
|
||||
DPLAN-0210 had moved limits into @memory's `memory.config.json` — so the hook
|
||||
always fell back to a 600-line check the lean files never reached, and rollover
|
||||
never fired (for weeks). The hook is now a thin trigger delegating to
|
||||
`drone @memory rollover check/run`; `compact.py` reads the current list schema
|
||||
(it was calling `.keys()` on a now-list `key_learnings`). Both fail loud instead
|
||||
of a silent exit-0.
|
||||
- **Removed @memory's v1 line-count / 600-line silent fallback entirely.** The
|
||||
detector + extractor are now v2-only (`per_branch` → `defaults` → warn-and-skip);
|
||||
a parse failure logs loud and skips rather than silently falling back. Deleted
|
||||
`_get_max_lines` / `_load_config` / `_detect_growing_array` / the line-count
|
||||
extraction path. (959 tests.)
|
||||
|
||||
### Removed
|
||||
|
||||
- **Legacy global prompt fully retired across every runtime (DPLAN-0215).** After
|
||||
the tiered cutover the old `global_prompt` is now gone, not just disabled:
|
||||
`global_loader.py` + its tests deleted, the `global_prompt` block stripped from
|
||||
`.aipass/hooks.json` + `project_hooks.json`, `_resolve_global_prompt` + all global
|
||||
seeding removed from `aipass init` bootstrap/update, the cadence default + bypass
|
||||
entries cleaned, and both `aipass_global_prompt.md` / `project_global_prompt.md`
|
||||
archived. Claude (cadence) and Codex (SessionStart) now read the same tier files —
|
||||
one prompt source, every runtime.
|
||||
|
||||
### Added
|
||||
|
||||
- **seedgo `HARDCODED_PATH` standard (#37).** A new checker (`hardcoded_path_check.py`
|
||||
+ `hardcoded_path_content.py`, `test_checkers_batch10.py`) flags hardcoded home
|
||||
paths — `/home/<user>` and dash-encoded `-home-<user>-` — in source and docstrings,
|
||||
keeping the public repo clean.
|
||||
|
||||
## [2026-06-18]
|
||||
|
||||
### Changed
|
||||
|
||||
- **Prompt injection is now tiered by cadence instead of one 8k always-on block
|
||||
(FPLAN-0284 / DPLAN-0214).** The single global prompt is split into two
|
||||
cadence-throttled tiers: **Tier 0** (`.aipass/tier0_kernel.md`, ~2k) injects
|
||||
every turn — identity grounding, the `drone @agent --help` reflex, and the
|
||||
disaster-preventer rules; **Tier 1** (`.aipass/tier1_navmap.md`, ~7.7k)
|
||||
injects every 5th turn plus at session start and right after compaction — the
|
||||
full agent roster, framework, conventions, and a new Terminology section. The
|
||||
hook engine gained per-loader cadence periods; the old `global_prompt` loader
|
||||
is retired (kept as a reference snapshot). Net: more navigation context
|
||||
reaches agents while less is paid per turn. Fresh-clone wiring is seeded from
|
||||
`cadence.py` defaults + `setup.sh` + `provider_manifest.json`.
|
||||
- **Public source genericized — `Patrick` → generic `user`.** No personal
|
||||
identifiers in tracked code/docs: the compass decision-source enum
|
||||
(`patrick` → `user`) + the `/compass` command, the devpulse local prompt, the
|
||||
`aipass init` onboarding example (`--name Patrick` → `--name YourName`), and
|
||||
stale refs across @ai_mail / @backup / @flow. Private memories (`.trinity/`,
|
||||
compass DB) keep personal context — they're gitignored.
|
||||
- **Telegram skill genericized (@skills).** Retired the inactive `patrick_private`
|
||||
personal bot from the skill's tests; the message sender now defaults to the
|
||||
Telegram user's first name (fallback `User`) instead of a hardcoded `Patrick`.
|
||||
|
||||
### Added
|
||||
|
||||
- **Prompt-craft conventions harvested from Claude Code's own prompts
|
||||
(DPLAN-0213).** A `Writing voice` section in `.aipass/PROMPT_STYLE.md`
|
||||
(`file_path:line` refs, write-for-a-person, three-tier "where detail lives");
|
||||
a blast-radius habit in the devpulse prompt; faithful-reporting +
|
||||
no-gold-plating folded into the Tier 0 kernel.
|
||||
- **Skill frontmatter discipline (@skills).** A `when_to_use` field with trigger
|
||||
phrases (surfaced during discovery scans) and per-step "Done when:" success
|
||||
criteria across the SKILL.md templates.
|
||||
- **`HARDCODED_PATH` standard (@seedgo, 37th checker).** Flags absolute home-dir
|
||||
literals in source — POSIX `/home/<user>/`, macOS `/Users/<user>/`, Windows
|
||||
user-home paths, and Claude Code's dash-encoded `-home-<user>-` form — with a
|
||||
bypass for legitimate test fixtures. Swept the repo for violations.
|
||||
- **`prompt_change` flow playbook (PPLAN template).** A reusable SOP for changing
|
||||
any injected prompt — leads with "live ≠ seeded" and walks every wiring layer +
|
||||
fresh-install seed path; born from the `aipass init` seeding gap this surfaced.
|
||||
|
||||
## [2026-06-16]
|
||||
|
||||
### Security
|
||||
|
||||
- **Secrets door hardened — no raw secret value ever reaches stdout
|
||||
(DPLAN-0211).** `@api get-secret` previously printed retrieved secret values
|
||||
to stdout — an acute exposure in AIPass because Claude Code captures command
|
||||
stdout into the model context. The command now emits a **masked summary** by
|
||||
default (`provider/slug: set (N chars)`), writes the raw value only to a
|
||||
`0600`-mode file via `--out FILE` (printing just the path), and `--list`
|
||||
prints slug **names** only. The `telegram` skill — the sole consumer — was
|
||||
rewired from subprocess-parsing `get-secret` stdout to the **in-process
|
||||
secrets module API**. Clears CodeQL clear-text-logging alerts #86/#87/#88.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`@ai_mail` dispatch monitor mislabeled failures as "API rate limit" on a
|
||||
PID-`429` collision.** The monitor classifies dispatch failures by
|
||||
substring-scanning the stderr log for `"429"`/`"529"`, but that log includes
|
||||
the monitor's own header line `(PID <pid>)`. A monitor PID containing `"429"`
|
||||
(e.g. `14290`) was read as an HTTP 429, overwriting the real bounce reason
|
||||
(e.g. sandbox-abort `-4`) with "API rate limit" — and flaking
|
||||
`test_sandbox_failure_sends_bounce` deterministically-by-PID in CI. The scan
|
||||
now excludes the monitor's own `--- ` framing lines; genuine `429`/`529`
|
||||
markers in agent output are still detected.
|
||||
|
||||
## [2026-06-15]
|
||||
|
||||
### Added
|
||||
|
||||
- **Telegram bridge ported into AIPass as a self-contained skill (FPLAN-0277).**
|
||||
The Dev-Pass Telegram bridge (multi-bot long-poll listener → tmux Claude
|
||||
injection → Stop-hook reply) is ported AS-WAS into a self-contained `telegram`
|
||||
skill that consumes AIPass services instead of bespoke wiring: secrets via the
|
||||
new `@api get-secret`, logging via `@prax`, and the outbound Stop hook
|
||||
registered through the `@hooks` engine. Three phases — **P1 `@api`** adds
|
||||
`get-secret <provider/slug> [--json|--list]` + `auth/secrets.py` (reads
|
||||
`~/.secrets/aipass/`); **P2 `@skills`** ports the 14-file bridge (~5,300 lines)
|
||||
+ ~424 tests into `.aipass/skills/telegram/`, rewiring every seam to services;
|
||||
**P3 `@hooks`** ports `telegram_response.py` (the reply path, with the 3-layer
|
||||
SubagentStop/sidechain/transcript-cursor defense intact) and registers it on
|
||||
the Stop event. A 366-tag completeness map (`TELEGRAM_PORT_MAP.md`) audited the
|
||||
port: **288 verified, 23 gaps** (top gap — a missing test log-isolation fixture
|
||||
— now fixed), **55 deferred to a live round-trip**. Live bring-up (real bot
|
||||
creds, systemd install, telethon auth, message round-trip) is still pending.
|
||||
|
||||
## [2026-06-13]
|
||||
|
||||
### Changed
|
||||
|
||||
- **Unified memory entry schema — Phase 1 (DPLAN-0207).** All four `.trinity`
|
||||
entry types (`key_learnings`, `sessions`, `todos`, `observations`) move to one
|
||||
shape: numbered + dated, list-shaped, newest-first. `key_learnings` converts
|
||||
from a dict to a numbered list; the rollover extractor now trims the **oldest
|
||||
by number from the tail**, and the schema normalizer self-heals ordering by
|
||||
re-sorting on `number` — so an out-of-order write can never archive a fresh
|
||||
entry (the bug surfaced in S229, where rollover ate the *newest* key_learning
|
||||
instead of the oldest). Backward-compatible: un-migrated dict-shaped
|
||||
key_learnings skip cleanly, no crash. **All 17 branches migrated** to
|
||||
`schema_version` 3.0.0 (reversible per-file backups, no data loss). A
|
||||
follow-up made the rollover **detector** and the **learnings manager** (used
|
||||
by rollover + symbolic) list-aware — a live `rollover check` caught they still
|
||||
counted key_learnings as a dict, so an at-cap list was invisible to the
|
||||
detector (the 955 unit tests stayed green because none counted a *list*). 960
|
||||
tests; seedgo 99% (1 pre-existing unused-function on an unwired manager API).
|
||||
Remaining: `/memo`+`/prep` and @spawn template updates.
|
||||
|
||||
- **Memory config relocated to the json-home and unified behind one
|
||||
self-healing loader (FPLAN-0271).** `memory.config.json` moved from the loose
|
||||
tracked `config/` dir into the gitignored `memory_json/custom_config/`
|
||||
(operator-tunable, fast-access) and `.plans_processed.json` into
|
||||
`memory_json/` root; the empty `config/` dir was removed. The config was
|
||||
previously read by **9 separate loaders**, each carrying its own *disagreeing*
|
||||
defaults (8 divergence classes — incl. the headline bug where a missing config
|
||||
silently flipped `entry_limits.enforce` off, plus rollover defaulting to 600
|
||||
vs the configured 500). All 9 now read through one
|
||||
`apps/handlers/json/config_loader.py` with a single `DEFAULT_CONFIG` +
|
||||
non-mutating deep-merge + self-heal: a missing file is rewritten from code
|
||||
defaults (warn-first `enforce: false`), while malformed JSON fails loud and is
|
||||
never overwritten. Dead `intake` section deleted; a static `_meta` block in
|
||||
`DEFAULT_CONFIG` documents each section's consumer files. Code-as-Template:
|
||||
the on-disk file is local tuning, code carries the committed defaults — same
|
||||
model as hooks `cadence_config.json`. Verified: 949 memory tests green, seedgo
|
||||
@memory 100%, live self-heal / malformed-no-clobber / edit_gate checks pass.
|
||||
Design: DPLAN-0206. Follow-up parked: issue #643 (codify `custom_config/` as a
|
||||
seedgo standard).
|
||||
|
||||
## [2026-06-12]
|
||||
|
||||
### Changed
|
||||
|
||||
- **Devpulse dashboard slimmed — todos no longer duplicated (startup-context
|
||||
fix).** `DASHBOARD.local.json` was embedding the full `todos[]` bodies that
|
||||
already live in `.trinity/local.json`; since both files are read at every
|
||||
startup, that was pure duplication. The dashboard now emits `todo_count` only
|
||||
(the glance value) — the bodies are commented out in the prax
|
||||
`devpulse_dashboard` plugin's `todo_section.py` (revivable). Dashboard
|
||||
`DASHBOARD.local.json` 6.8 KB → 3.0 KB. Devpulse-only (plugin, not templated).
|
||||
Verified: seedgo 100%, 17/17 plugin tests.
|
||||
- **Deprecated dashboard sections are now actually pruned on refresh.**
|
||||
`bulletin_board` (and the other entries in prax's `DEPRECATED_SECTIONS`:
|
||||
`devpulse`, `commons_activity`, `agent_status`, `memory_bank`) were listed as
|
||||
deprecated but only excluded from template *pushes* — they lingered in every
|
||||
branch's live `DASHBOARD.local.json`. Added `_prune_deprecated_sections()` to
|
||||
the prax dashboard `refresh` path (reusing the single `DEPRECATED_SECTIONS`
|
||||
constant), so a refresh strips them. Verified: `bulletin_board` removed from
|
||||
the devpulse dashboard; 116/116 prax tests, seedgo 100%. (Follow-up: `@trigger`
|
||||
still has a `bulletin_created` writer to retire separately.)
|
||||
- **Dashboard slimmed to a lean glance — removed duplicated/dead sections.**
|
||||
Dropped three sections from the devpulse dashboard: `session` (broken since
|
||||
May — read keys `id`/`d`/`sum` vs the actual `session`/`date`/`summary`, so it
|
||||
always wrote empty strings — and it duplicated `local.json`, which loads at
|
||||
startup), `todo` (carried only `todo_count`, already in `quick_status`; now
|
||||
sourced directly from `local.json`), and `ai_mail` (its counts live in
|
||||
`quick_status`; the section is removed from output *after* quick_status is
|
||||
computed from it). End state: 4 sections (`flow`, `memory`, `git`, `dispatch`)
|
||||
+ the `quick_status` glance. `session_section.py`/`todo_section.py` archived
|
||||
(not deleted). `DASHBOARD.local.json` overall 6.8 KB → 2.4 KB. Verified: seedgo
|
||||
100%, 108 prax tests. (Follow-up: `@ai_mail`'s `dashboard_sync.py` section
|
||||
writer to retire separately.)
|
||||
- **quick_status now self-sources mail counts from `inbox.json`.** Decouples the
|
||||
glance from the `ai_mail` section: prax's three quick_status calculators read
|
||||
`.ai_mail.local/inbox.json` directly (`_read_mail_counts`) for `new_mail`/
|
||||
`opened_mail`, so the `ai_mail` section is no longer a data dependency and can
|
||||
be retired. 116 prax tests, seedgo 100%.
|
||||
- **Retired `@ai_mail`'s dashboard section writer (completes the dashboard
|
||||
slim).** ai_mail no longer writes to the dashboard — removed
|
||||
`push_dashboard_update` from 5 call sites and archived `dashboard_sync.py`.
|
||||
With prax self-sourcing mail counts, the `ai_mail` section now stays gone (a
|
||||
mail op no longer re-adds it — verified). 737 ai_mail tests.
|
||||
- **`.backupignore` is now a true `.gitignore` for the backup system — a single
|
||||
source of truth (FPLAN-0269).** Replaced the hand-rolled `fnmatch`+part-loop
|
||||
matcher (which broke leading-slash anchoring, `*`-crossing-`/`, dir-only `foo/`,
|
||||
`!` negation, and last-match-wins) with the `pathspec` gitwildmatch library, so
|
||||
`.backupignore` honors full gitignore semantics: include-by-default, `!`
|
||||
negation, `#` comments, anchoring, dir-only, last-match-wins. `BUILTIN_IGNORES`
|
||||
is demoted to a seed-only default (written when the file is absent, never merged
|
||||
at runtime), and the separate `IGNORE_EXCEPTIONS`/`is_exception` layer is
|
||||
removed (exceptions are native `!` lines). Snapshot, versioned, `all`, and
|
||||
mirror-cleanup now all obey the one file. `.ruff_cache/` + `.coverage` added to
|
||||
the default. `pathspec` (pure-Python, cross-OS) declared. Verified by artifact
|
||||
(seedgo 100%, 220 tests incl. 26 new gitignore-parity tests) + live (a dotfile
|
||||
flows into the store, `!` negation re-includes end-to-end).
|
||||
- **Backup store dir renamed `.backup_system/` → `.backup/`, dead `versions/`
|
||||
removed (FPLAN-0269 follow-up).** The backup root is now `.backup/` (shorter,
|
||||
coexists with `@flow`'s `.backup/processed_plans/`); the orphaned per-timestamp
|
||||
`versions/` scaffold and the unused `build_versioned_path()` — both superseded
|
||||
by the Phase-3 `versioned/` baseline+diff store — are gone. Drive sync confirmed
|
||||
reading `.backup/versioned/` + `.backup/drive_tracker.json` via the shared
|
||||
`backup_root()`. Verified by artifact (seedgo 100%, 220 tests) + live (a
|
||||
throwaway project writes to `.backup/`, no `versions/` dir).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Backup Drive sync no longer silently drops 41% of files — including the
|
||||
memories (FPLAN-0269).** Removed a foreign dotfile-skip in `drive_sync.py` that
|
||||
excluded every dotted path (`.trinity/` memories, `.chroma/` vectors, `.aipass/`
|
||||
prompts, `.ai_mail.local/` mailboxes — 4558 files) from the offsite Google Drive
|
||||
copy while the local snapshot/versioned kept them. Drive now uploads the full
|
||||
versioned store (already exactly the `.backupignore`-filtered set). Added a
|
||||
Drive-sync output panel matching the Snapshot/Versioned stages (header, progress,
|
||||
stats, Duration | Location).
|
||||
|
||||
### Added
|
||||
|
||||
- **Backup Google Drive sync pipeline + restore command (FPLAN-0268, Phase 4 of
|
||||
FPLAN-0264 — final).** Faithful port of GOLD's `GoogleDriveSync` against the
|
||||
live `@api` gateway (`get_drive_service` + `api_call_with_retry` — never the
|
||||
console-OAuth path). New `handlers/drive/`: `DriveClient` (folder hierarchy
|
||||
`AIPass Backups/<project>/`, thread-safe cache, retry-with-rebuild),
|
||||
`upload.py` (resumable `MediaFileUpload`, 3 threaded workers), `tracker.py`
|
||||
(mtime+size dedup → no re-upload of unchanged files), `test.py` (connectivity).
|
||||
All four `drive_*` modules un-stubbed; `all` now runs snapshot→versioned→
|
||||
drive-sync and **fails honestly** if Drive creds are absent (never silent-skips,
|
||||
never fakes success, snapshot+versioned still report). New `restore` command
|
||||
(`restore <project> list <file>` / `restore <project> file <file> <out>`)
|
||||
exposing the Phase-3 baseline+diff restore engine. Drive tests fully mocked —
|
||||
zero real Google calls in CI. Verified by artifact + live: audit 100% (all 37
|
||||
files), 187 tests, ruff clean, restore `list`/`file` round-trip confirmed.
|
||||
- **Backup uses the repo-root pyright config like every citizen.** Removed
|
||||
backup's standalone `pyrightconfig.json` (a leftover from its pre-namespace
|
||||
standalone days, archived) so it inherits the root config — resolving imports
|
||||
consistently with the rest of AIPass. Dead PyQt5 `ui/settings_window.py`
|
||||
(never wired) archived.
|
||||
|
||||
- **Backup versioned baseline + per-file diff engine (FPLAN-0267, Phase 3 of
|
||||
FPLAN-0264 — the heart).** Faithful port of the GOLD versioned engine,
|
||||
replacing the mtime full-copy-into-timestamped-dirs remnant. One persistent
|
||||
store (`.backup_system/versioned/`) with GOLD's file-folder packaging: each
|
||||
file gets `<parent>/<name>/` holding the current copy, a
|
||||
`<stem>-baseline-<date>.<ext>` full copy from the first run (never touched
|
||||
again), and `<name>_diffs/<name>_v<old-mtime>.diff` unified-diff patches on
|
||||
every change — append-only, versioned **never deletes** (cleanup stays
|
||||
snapshot-only). Versioned and snapshot back up the identical file set (same
|
||||
scan + ignore patterns; `all` shares one scan). Change detection is
|
||||
ledger-free (source mtime vs store-current mtime, `copy2`-preserved) — kills
|
||||
the regression where running snapshot starved the next versioned via the
|
||||
shared `timestamps.json`. New `diff/restore.py` (`list_versions` +
|
||||
`restore_file`); `diff/generator.py` wired (binary detection + diff
|
||||
include/ignore patterns). +15 tests (125 total). Verified by artifact + live
|
||||
end-to-end: snapshot-first-then-versioned still baselines everything
|
||||
(starvation dead), edit → real diff with old-mtime timestamp, source delete →
|
||||
versioned store untouched while snapshot mirror-deletes, restore round-trip
|
||||
byte-identical.
|
||||
|
||||
- **Backup snapshot fidelity + shared core (FPLAN-0266, Phase 2 of FPLAN-0264).**
|
||||
Restored the snapshot-side machinery the 2026-04-23 rewrite degraded, ported
|
||||
from the GOLD archive onto the current per-project handlers. New
|
||||
`handlers/cleanup/mirror.py` `cleanup_deleted_files` — exception-aware
|
||||
mirror-delete: files removed from source are now removed from the snapshot
|
||||
(was a blind `rmtree`+recopy), respecting ignore-exceptions. `copy/snapshot.py`
|
||||
gains mtime-skip (quick-check fast path — unchanged files no longer re-copied),
|
||||
a long-path guard (>260), and read-only handling. `report/result.py`
|
||||
`BackupResult` now tracks critical vs non-critical errors + warnings +
|
||||
`files_deleted`; `ignore/patterns.py` gains `IGNORE_EXCEPTIONS`/`is_exception()`.
|
||||
+16 tests (`test_snapshot_fidelity.py`, 110 total). Verified by artifact +
|
||||
live: audit 100%, 110 passed, and a real throwaway-project test (delete two
|
||||
files → re-snapshot → both mirror-deleted, kept files preserved, 3 skipped/0
|
||||
re-copied).
|
||||
|
||||
- **Backup test suite + seedgo 100% — restoration foundation (FPLAN-0265, Phase 1
|
||||
of FPLAN-0264).** Put a safety net under `backup` before the feature rebuild:
|
||||
new `tests/` suite (94 tests — json_handler, CLI routing, filesystem handlers,
|
||||
error resilience, mocked drive) ported from the canonical citizen conftest
|
||||
pattern (hermetic, `tmp_path`, stdlib-only → 3.10–3.13), driving module coverage
|
||||
to 27%. Standards brought to 100% across all 35: shared `--help/-h/help` guard
|
||||
wired into all 10 modules' `handle_command` (Cli + Introspection), the 6
|
||||
Phase-3 drive/diff/ui stubs wired-or-bypassed (Dead_Code + Unused_Function),
|
||||
`requirements.project.txt` added (Architecture), README module list + the small
|
||||
Modules/Trigger fixes (`display.handle_command`, `create_progress_bar` →
|
||||
`build_progress_bar`). Verified by artifact: re-ran audit (100%) + pytest
|
||||
(94 passed) + ruff (clean).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Memory rollover no longer silently loses rolled-off learnings ("No embeddings
|
||||
generated").** A capped `.trinity` file rolls its excess entries out to vectors;
|
||||
two combined bugs dropped them on the floor instead. (1) On the "embedding returned
|
||||
empty but success=True" path the orchestrator logged the error and continued — but
|
||||
the source file was *already* trimmed, so the entry was lost from both the file and
|
||||
ChromaDB; it now restores the pre-trim backup before continuing (fail-honest).
|
||||
(2) A concurrent-rollover race (two runs ~33ms apart) let the second run extract
|
||||
nothing yet still report success → empty embeddings → bug #1; `extract_with_metadata`
|
||||
now honors the `skipped` flag and the orchestrator skips no-op extractions before the
|
||||
embedding stage. Verified by artifact + live: a 25/25-capped test file rolls over →
|
||||
embeds (384-dim) → `drone @memory search` returns it at 91% similarity; audit 100%,
|
||||
876 tests (+4).
|
||||
|
||||
- **Backup Google Drive folder duplication + dedup-wipe fixed (GOLD-faithful lock
|
||||
restoration).** The Phase-4 port had narrowed `GoogleDriveSync`'s folder lock: a
|
||||
single `drive_sync` run's 3 upload workers raced the folder search+create →
|
||||
multiple "AIPass Backups" root folders, and `get_or_create_backup_folder` reset
|
||||
the dedup tracker on every call (re-uploading everything = the slowness). Restored
|
||||
GOLD's structure exactly: `get_or_create_project_folder` / `get_or_create_nested_folder`
|
||||
hold `_folder_cache_lock` across the **entire** method (cache + root-ensure + search
|
||||
+ create); `get_or_create_backup_folder` is lock-free (called inside the project
|
||||
lock — no re-entrant deadlock), short-circuits cached ids via `_verify_folder_id`,
|
||||
and clears the tracker only on a genuine brand-new root folder. Also: all four
|
||||
`drive_*` commands route by their underscore names (were hyphenated → "Unknown
|
||||
command"); `requirements.project.txt` now declares the three google libs. Verified
|
||||
by artifact (seedgo 100%, 197 tests incl. a 5-thread concurrency test → exactly one
|
||||
create) + live (real Drive backup: no duplicate folders).
|
||||
|
||||
- **Backup rich CLI output restored end-to-end (FPLAN-0263 + drone passthrough).**
|
||||
`drone @backup snapshot|versioned|all` rendered a flat text block instead of the
|
||||
original rich output. Two independent causes, both closed: (1) the rich rendering
|
||||
was never carried forward in backup's revival — rebuilt as a faithful 9-stage port
|
||||
(new `backup_timestamps` state handler + `display.py` pipeline: Last-backups panel →
|
||||
boxed header → live Rich progress bar → result summary → Backups-now panel;
|
||||
`BackupResult` extended with `files_checked`/`files_skipped`/`backup_path`; copy
|
||||
handlers emit `on_progress` callbacks). (2) drone was flattening it at the pipe —
|
||||
`@backup` ran through `capture_output=True` (non-TTY → Rich strips color, the
|
||||
`transient` progress bar renders to nothing) and the 30s capture timeout would kill
|
||||
large backups; added `backup` to drone's `INTERACTIVE_BRANCHES` so all `@backup`
|
||||
commands inherit the terminal (mirrors `cli`). Verified live under a pty: full color
|
||||
+ animated progress bar.
|
||||
|
||||
## [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
|
||||
|
||||
@@ -349,7 +903,7 @@ and this project uses [Calendar Versioning](https://calver.org/) in the format
|
||||
|
||||
---
|
||||
|
||||
## [2026.W22] - 2026-05-30
|
||||
## [2026-05-30]
|
||||
|
||||
### Added
|
||||
|
||||
@@ -411,7 +965,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
|
||||
|
||||
@@ -47,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
|
||||
@@ -91,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
|
||||
```
|
||||
|
||||
@@ -112,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
|
||||
```
|
||||
@@ -141,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"
|
||||
```
|
||||
@@ -161,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
|
||||
@@ -195,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 |
|
||||
@@ -212,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.
|
||||
@@ -225,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.
|
||||
|
||||
@@ -238,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)
|
||||
|
||||
+7
-2
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "aipass"
|
||||
version = "2.5.1"
|
||||
version = "2.6.0"
|
||||
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"
|
||||
@@ -31,6 +31,7 @@ dependencies = [
|
||||
"requests>=2.34.2",
|
||||
"psutil>=5.9",
|
||||
"questionary>=2.0",
|
||||
"pathspec>=0.12",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -73,7 +74,11 @@ packages = ["src/aipass"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests", "src"]
|
||||
norecursedirs = ["templates", "*.egg-info", ".git", ".venv", "__pycache__", ".archive", "my-project"]
|
||||
# ".*" restores pytest's default dot-dir exclusion (dropped when this list was
|
||||
# customized) so scaffolding dirs (.aipass, .trinity, .seedgo, ...) are never
|
||||
# recursed for tests — prevents conftest module-name collisions like a bundled
|
||||
# skill's .aipass/.../tests/conftest.py clashing with a branch's tests/conftest.py.
|
||||
norecursedirs = ["templates", "*.egg-info", ".*", "__pycache__", "my-project"]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["src/aipass"]
|
||||
|
||||
+5
-1
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"extraPaths": ["src", "src/aipass/memory/.venv/lib/python3.12/site-packages"],
|
||||
"extraPaths": [
|
||||
"src",
|
||||
".venv/lib/python3.12/site-packages",
|
||||
"src/aipass/memory/.venv/lib/python3.12/site-packages"
|
||||
],
|
||||
"pythonVersion": "3.10",
|
||||
"reportMissingImports": "error",
|
||||
"reportAttributeAccessIssue": "error",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -545,7 +631,8 @@ else:
|
||||
# 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:tier0_kernel"}]},
|
||||
{"hooks": [{"type": "command", "command": f"{bridge} UserPromptSubmit:navmap"}]},
|
||||
{"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"}]},
|
||||
|
||||
@@ -4,4 +4,4 @@ pip install aipass
|
||||
https://github.com/AIOSAI/AIPass
|
||||
"""
|
||||
|
||||
__version__ = "2.5.1"
|
||||
__version__ = "2.6.0"
|
||||
|
||||
@@ -60,11 +60,6 @@
|
||||
"standard": "deep_nesting",
|
||||
"reason": "2 functions: get_user_by_email() depth 4, get_all_users() depth 4 — registry lookup with path normalization and validation"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/email/dashboard_sync.py",
|
||||
"standard": "handlers",
|
||||
"reason": "Imports prax.apps.modules.dashboard.write_section — cross-branch module import required for dashboard integration. No ai_mail module wraps this."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/email/delivery.py",
|
||||
"standard": "handlers",
|
||||
@@ -93,7 +88,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",
|
||||
@@ -115,11 +115,6 @@
|
||||
"standard": "naming",
|
||||
"reason": "False positive — _append_footer is a function reference stored in a local variable, not a module-level constant."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/email/dashboard_sync.py",
|
||||
"standard": "naming",
|
||||
"reason": "False positive — _write_section is a lazy-import function reference, not a module-level constant."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/email/delivery.py",
|
||||
"standard": "naming",
|
||||
@@ -195,11 +190,6 @@
|
||||
"standard": "deep_nesting",
|
||||
"reason": "_send_direct() depth 5 (arg parsing with branch resolution, --from flag, --dispatch flag), handle_close() depth 4 (close with archive + dashboard update)"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/email/dashboard_sync.py",
|
||||
"standard": "deep_nesting",
|
||||
"reason": "_human_readable_age() depth 5, _calculate_section_data() depth 5 — timestamp parsing with multiple fallback formats"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/dispatch/dispatch_monitor.py",
|
||||
"standard": "deep_nesting",
|
||||
|
||||
@@ -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"
|
||||
@@ -95,16 +134,27 @@ def _send_bounce(branch_email: str, reason: str, sender: str, lock_file: str, st
|
||||
return False
|
||||
|
||||
|
||||
def _check_rate_limited(stderr_log: str) -> bool:
|
||||
"""Check if stderr indicates API rate limiting or overload."""
|
||||
def _read_agent_stderr(stderr_log: str) -> str:
|
||||
"""Read the dispatch stderr log, excluding the monitor's own framing lines.
|
||||
|
||||
The monitor writes header/footer/attempt markers (all prefixed with "--- ")
|
||||
that embed the PID and timestamps. Those numbers must NOT be scanned for API
|
||||
error markers -- e.g. a PID like 14290 contains "429" and would otherwise be
|
||||
misread as an HTTP 429 rate-limit. Returns "" if the log can't be read.
|
||||
"""
|
||||
try:
|
||||
with open(stderr_log, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
lower = content.lower()
|
||||
return "rate_limit" in lower or "429" in content or "overloaded" in lower or "529" in content
|
||||
return "".join(line for line in f if not line.lstrip().startswith("---"))
|
||||
except OSError as e:
|
||||
logger.warning("[monitor] _check_rate_limited failed reading %s: %s", stderr_log, e)
|
||||
return False
|
||||
logger.warning("[monitor] Failed reading stderr log %s: %s", stderr_log, e)
|
||||
return ""
|
||||
|
||||
|
||||
def _check_rate_limited(stderr_log: str) -> bool:
|
||||
"""Check if stderr indicates API rate limiting or overload."""
|
||||
content = _read_agent_stderr(stderr_log)
|
||||
lower = content.lower()
|
||||
return "rate_limit" in lower or "429" in content or "overloaded" in lower or "529" in content
|
||||
|
||||
|
||||
def _make_fresh_cmd(claude_cmd: list) -> list:
|
||||
@@ -176,7 +226,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 +244,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 +391,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 +414,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
|
||||
@@ -434,16 +538,14 @@ def main():
|
||||
|
||||
reason = f"All {len(attempts)} attempts failed after {duration}s.\n" + "\n".join(attempt_details)
|
||||
|
||||
# Check stderr for specific error categories
|
||||
try:
|
||||
with open(stderr_log, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
if "rate_limit" in content.lower() or "429" in content:
|
||||
reason = f"API rate limit (all {len(attempts)} attempts failed, {duration}s)"
|
||||
elif "overloaded" in content.lower() or "529" in content:
|
||||
reason = f"API overloaded (all {len(attempts)} attempts failed, {duration}s)"
|
||||
except OSError:
|
||||
logger.info("[monitor] Failed to read stderr log for diagnostics")
|
||||
# Check stderr for specific error categories. Exclude the monitor's own
|
||||
# framing lines (PID/timestamp headers) so a number like a PID containing
|
||||
# "429" is not misread as an HTTP 429 rate-limit response.
|
||||
content = _read_agent_stderr(stderr_log)
|
||||
if "rate_limit" in content.lower() or "429" in content:
|
||||
reason = f"API rate limit (all {len(attempts)} attempts failed, {duration}s)"
|
||||
elif "overloaded" in content.lower() or "529" in content:
|
||||
reason = f"API overloaded (all {len(attempts)} attempts failed, {duration}s)"
|
||||
|
||||
_send_bounce(branch_email, reason, sender, lock_file, stderr_log)
|
||||
|
||||
|
||||
@@ -56,24 +56,17 @@ def batch_close(
|
||||
|
||||
def batch_close_post_ops(
|
||||
branch_path: Path,
|
||||
push_dashboard_fn: Optional[Callable] = None,
|
||||
update_central_fn: Optional[Callable] = None,
|
||||
purge_deleted_fn: Optional[Callable] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Run post-operations after a batch close (dashboard update + purge).
|
||||
Run post-operations after a batch close (central update + purge).
|
||||
|
||||
Args:
|
||||
branch_path: Path to branch directory
|
||||
push_dashboard_fn: Optional push_dashboard_update callable
|
||||
update_central_fn: Optional update_central callable
|
||||
purge_deleted_fn: Optional purge_deleted_folder callable
|
||||
"""
|
||||
if push_dashboard_fn:
|
||||
try:
|
||||
push_dashboard_fn(branch_path)
|
||||
except Exception as e:
|
||||
logger.warning("[close] push_dashboard_fn failed for %s: %s", branch_path, e)
|
||||
if update_central_fn:
|
||||
try:
|
||||
update_central_fn()
|
||||
|
||||
@@ -165,7 +165,7 @@ def _is_private_branch_email(email: str) -> bool:
|
||||
email address is registered to a private (isolated) branch.
|
||||
|
||||
Args:
|
||||
email: Email address to check (e.g., "@patrick_private")
|
||||
email: Email address to check (e.g., "@private_branch")
|
||||
|
||||
Returns:
|
||||
True if email belongs to a private branch, False otherwise
|
||||
|
||||
@@ -93,25 +93,18 @@ def on_email_delivered(
|
||||
new_count: int,
|
||||
opened_count: int,
|
||||
total: int,
|
||||
push_dashboard_fn: Optional[Callable] = None,
|
||||
update_central_fn: Optional[Callable] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Post-delivery callback: update dashboard and central.
|
||||
Post-delivery callback: update central.
|
||||
|
||||
Args:
|
||||
branch_path: Path to the branch that received email
|
||||
new_count: Number of new (unread) messages
|
||||
opened_count: Number of opened messages
|
||||
total: Total message count
|
||||
push_dashboard_fn: Callable for push_dashboard_update
|
||||
update_central_fn: Callable for update_central
|
||||
"""
|
||||
if push_dashboard_fn:
|
||||
try:
|
||||
push_dashboard_fn(branch_path)
|
||||
except Exception as e:
|
||||
logger.warning("[error_dispatch] dashboard update failed for %s: %s", branch_path, e)
|
||||
if update_central_fn:
|
||||
try:
|
||||
update_central_fn()
|
||||
|
||||
@@ -38,13 +38,6 @@ def _get_inbox_lock():
|
||||
return _inbox_lock
|
||||
|
||||
|
||||
def _get_push_dashboard_update() -> Any:
|
||||
"""Lazy import push_dashboard_update from dashboard_sync."""
|
||||
from aipass.ai_mail.apps.handlers.email.dashboard_sync import push_dashboard_update
|
||||
|
||||
return push_dashboard_update
|
||||
|
||||
|
||||
def _get_update_central() -> Any:
|
||||
"""Lazy import update_central."""
|
||||
from aipass.ai_mail.apps.handlers.central_writer import update_central
|
||||
@@ -191,13 +184,7 @@ def mark_all_read_and_archive(branch_path: Path) -> Tuple[bool, str, int]:
|
||||
|
||||
|
||||
def _update_dashboard(branch_path: Path, new: int, opened: int, total: int) -> None:
|
||||
"""Update dashboard ai_mail section with enriched data via write-through API."""
|
||||
try:
|
||||
_get_push_dashboard_update()(branch_path)
|
||||
except Exception as e:
|
||||
logger.warning("[cleanup] dashboard update failed for %s: %s", branch_path, e)
|
||||
|
||||
# Update central after any inbox changes
|
||||
"""Update central stats after inbox changes."""
|
||||
try:
|
||||
_get_update_central()()
|
||||
except Exception as e:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -267,7 +267,6 @@ def _orchestrate_dispatch_send(args: List[str]) -> bool:
|
||||
from aipass.ai_mail.apps.handlers.email.delivery import deliver_email_to_branch
|
||||
from aipass.ai_mail.apps.handlers.email.header import prepend_dispatch_header
|
||||
from aipass.ai_mail.apps.handlers.email.error_dispatch import dispatch_send_error, on_email_delivered
|
||||
from aipass.ai_mail.apps.handlers.email.dashboard_sync import push_dashboard_update
|
||||
from aipass.ai_mail.apps.handlers.users.user import get_current_user
|
||||
from aipass.ai_mail.apps.handlers.registry.read import get_branch_by_email
|
||||
|
||||
@@ -286,7 +285,6 @@ def _orchestrate_dispatch_send(args: List[str]) -> bool:
|
||||
new_count,
|
||||
opened_count,
|
||||
total,
|
||||
push_dashboard_fn=push_dashboard_update,
|
||||
update_central_fn=update_central,
|
||||
)
|
||||
|
||||
|
||||
@@ -23,15 +23,8 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
# Infrastructure
|
||||
_AI_MAIL_DIR = Path(__file__).resolve().parents[2]
|
||||
_REPO_ROOT = _AI_MAIL_DIR.parents[2]
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.cli.apps.modules import console, error
|
||||
|
||||
# Handlers - business logic providers
|
||||
from aipass.ai_mail.apps.handlers.email.dashboard_sync import push_dashboard_update
|
||||
from aipass.ai_mail.apps.handlers.email.create import load_email_file
|
||||
from aipass.ai_mail.apps.handlers.email.format import format_email_list_item, format_email_header
|
||||
from aipass.ai_mail.apps.handlers.email.inbox_ops import load_inbox
|
||||
@@ -48,6 +41,9 @@ from aipass.ai_mail.apps.handlers.email.close_ops import batch_close, batch_clos
|
||||
from aipass.ai_mail.apps.handlers.email.inbox_resolve import resolve_inbox_target
|
||||
from aipass.ai_mail.apps.modules.email_send import handle_send
|
||||
|
||||
_AI_MAIL_DIR = Path(__file__).resolve().parents[2]
|
||||
_REPO_ROOT = _AI_MAIL_DIR.parents[2]
|
||||
|
||||
try:
|
||||
from aipass.ai_mail.apps.handlers.central_writer import update_central
|
||||
except ImportError as e:
|
||||
@@ -255,7 +251,7 @@ def handle_close(args: List[str]) -> bool:
|
||||
except ImportError as e:
|
||||
logger.warning("[email] purge import unavailable: %s", e)
|
||||
run_purge = None
|
||||
batch_close_post_ops(branch_path, push_dashboard_update, update_central, run_purge)
|
||||
batch_close_post_ops(branch_path, update_central, run_purge)
|
||||
console.print(f"\nClosed {closed}, failed {failed}")
|
||||
return True
|
||||
except Exception as e:
|
||||
@@ -277,7 +273,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:
|
||||
@@ -386,7 +383,6 @@ def print_introspection():
|
||||
console.print(" - reply.py (get_email_by_id — retrieve email by message ID)")
|
||||
console.print(" - reply.py (send_reply — send reply to an email)")
|
||||
console.print(" - header.py (prepend_dispatch_header — prepend dispatch header to message)")
|
||||
console.print(" - dashboard_sync.py (push_dashboard_update — push email stats to dashboard)")
|
||||
console.print(" - error_dispatch.py (dispatch_send_error — handle and report send errors)")
|
||||
console.print(" - error_dispatch.py (on_email_delivered — post-delivery callback handler)")
|
||||
console.print(" handlers/users/")
|
||||
|
||||
@@ -17,14 +17,10 @@ under the size threshold.
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
_AI_MAIL_DIR = Path(__file__).resolve().parents[2]
|
||||
_REPO_ROOT = _AI_MAIL_DIR.parents[2]
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.cli.apps.modules import console, error
|
||||
from aipass.trigger.apps.modules.core import trigger
|
||||
|
||||
from aipass.ai_mail.apps.handlers.email.dashboard_sync import push_dashboard_update
|
||||
from aipass.ai_mail.apps.handlers.email.delivery import deliver_email_to_branch
|
||||
from aipass.ai_mail.apps.handlers.email.create import create_email_file, load_email_file
|
||||
from aipass.ai_mail.apps.handlers.email.header import prepend_dispatch_header
|
||||
@@ -40,6 +36,9 @@ from aipass.ai_mail.apps.handlers.email.send import (
|
||||
from aipass.ai_mail.apps.handlers.email.error_dispatch import dispatch_send_error, on_email_delivered
|
||||
from aipass.ai_mail.apps.handlers.email.send_args import parse_send_args, resolve_dispatch_target
|
||||
|
||||
_AI_MAIL_DIR = Path(__file__).resolve().parents[2]
|
||||
_REPO_ROOT = _AI_MAIL_DIR.parents[2]
|
||||
|
||||
try:
|
||||
from aipass.ai_mail.apps.handlers.central_writer import update_central
|
||||
except ImportError as e:
|
||||
@@ -54,7 +53,6 @@ def _delivery_callback(branch_path, new_count, opened_count, total):
|
||||
new_count,
|
||||
opened_count,
|
||||
total,
|
||||
push_dashboard_fn=push_dashboard_update,
|
||||
update_central_fn=update_central,
|
||||
)
|
||||
|
||||
|
||||
@@ -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).
|
||||
@@ -120,13 +120,11 @@ def test_batch_close_post_ops_all_fns_called(tmp_path: Path):
|
||||
branch_path = tmp_path / "branch"
|
||||
branch_path.mkdir()
|
||||
|
||||
push_fn = MagicMock()
|
||||
central_fn = MagicMock()
|
||||
purge_fn = MagicMock()
|
||||
|
||||
mod.batch_close_post_ops(branch_path, push_fn, central_fn, purge_fn)
|
||||
mod.batch_close_post_ops(branch_path, central_fn, purge_fn)
|
||||
|
||||
push_fn.assert_called_once_with(branch_path)
|
||||
central_fn.assert_called_once_with()
|
||||
purge_fn.assert_called_once_with(branch_path / ".ai_mail.local")
|
||||
|
||||
@@ -137,22 +135,7 @@ def test_batch_close_post_ops_none_fns(tmp_path: Path):
|
||||
branch_path.mkdir()
|
||||
|
||||
# Should not raise
|
||||
mod.batch_close_post_ops(branch_path, None, None, None)
|
||||
|
||||
|
||||
def test_batch_close_post_ops_push_exception_suppressed(tmp_path: Path):
|
||||
"""Exception in push_dashboard_fn is caught; other fns still called."""
|
||||
branch_path = tmp_path / "branch"
|
||||
branch_path.mkdir()
|
||||
|
||||
push_fn = MagicMock(side_effect=RuntimeError("push failed"))
|
||||
central_fn = MagicMock()
|
||||
purge_fn = MagicMock()
|
||||
|
||||
mod.batch_close_post_ops(branch_path, push_fn, central_fn, purge_fn)
|
||||
|
||||
central_fn.assert_called_once()
|
||||
purge_fn.assert_called_once()
|
||||
mod.batch_close_post_ops(branch_path, None, None)
|
||||
|
||||
|
||||
def test_batch_close_post_ops_central_exception_suppressed(tmp_path: Path):
|
||||
@@ -160,13 +143,11 @@ def test_batch_close_post_ops_central_exception_suppressed(tmp_path: Path):
|
||||
branch_path = tmp_path / "branch"
|
||||
branch_path.mkdir()
|
||||
|
||||
push_fn = MagicMock()
|
||||
central_fn = MagicMock(side_effect=RuntimeError("central failed"))
|
||||
purge_fn = MagicMock()
|
||||
|
||||
mod.batch_close_post_ops(branch_path, push_fn, central_fn, purge_fn)
|
||||
mod.batch_close_post_ops(branch_path, central_fn, purge_fn)
|
||||
|
||||
push_fn.assert_called_once()
|
||||
purge_fn.assert_called_once()
|
||||
|
||||
|
||||
@@ -175,13 +156,11 @@ def test_batch_close_post_ops_purge_exception_suppressed(tmp_path: Path):
|
||||
branch_path = tmp_path / "branch"
|
||||
branch_path.mkdir()
|
||||
|
||||
push_fn = MagicMock()
|
||||
central_fn = MagicMock()
|
||||
purge_fn = MagicMock(side_effect=RuntimeError("purge failed"))
|
||||
|
||||
mod.batch_close_post_ops(branch_path, push_fn, central_fn, purge_fn)
|
||||
mod.batch_close_post_ops(branch_path, central_fn, purge_fn)
|
||||
|
||||
push_fn.assert_called_once()
|
||||
central_fn.assert_called_once()
|
||||
|
||||
|
||||
@@ -192,6 +171,6 @@ def test_batch_close_post_ops_partial_fns(tmp_path: Path):
|
||||
|
||||
central_fn = MagicMock()
|
||||
|
||||
mod.batch_close_post_ops(branch_path, None, central_fn, None)
|
||||
mod.batch_close_post_ops(branch_path, central_fn, None)
|
||||
|
||||
central_fn.assert_called_once_with()
|
||||
|
||||
@@ -47,7 +47,6 @@ _H_CREATE = "aipass.ai_mail.apps.handlers.email.create"
|
||||
_H_DELIVERY = "aipass.ai_mail.apps.handlers.email.delivery"
|
||||
_H_HEADER = "aipass.ai_mail.apps.handlers.email.header"
|
||||
_H_ERR = "aipass.ai_mail.apps.handlers.email.error_dispatch"
|
||||
_H_DASH = "aipass.ai_mail.apps.handlers.email.dashboard_sync"
|
||||
_H_USERS = "aipass.ai_mail.apps.handlers.users.user"
|
||||
_H_REG = "aipass.ai_mail.apps.handlers.registry.read"
|
||||
_H_CENTRAL = "aipass.ai_mail.apps.handlers.central_writer"
|
||||
@@ -658,7 +657,6 @@ def _send_patches(overrides: dict | None = None) -> ExitStack:
|
||||
f"{_H_HEADER}.prepend_dispatch_header": MagicMock(return_value="[DISPATCH] Body"),
|
||||
f"{_H_SEND}.send_to_single": MagicMock(return_value=(True, None)),
|
||||
f"{_H_ERR}.on_email_delivered": MagicMock(),
|
||||
f"{_H_DASH}.push_dashboard_update": MagicMock(),
|
||||
f"{_H_USERS}.get_current_user": MagicMock(return_value={"name": "test"}),
|
||||
f"{_H_REG}.get_branch_by_email": MagicMock(return_value={"email": "@target"}),
|
||||
f"{_H_CENTRAL}.update_central": MagicMock(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -385,7 +385,7 @@ class TestHandleClose:
|
||||
post_ops_called = []
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.modules.email.batch_close_post_ops",
|
||||
lambda bp, push_fn, central_fn, purge_fn: post_ops_called.append(True),
|
||||
lambda bp, central_fn, purge_fn: post_ops_called.append(True),
|
||||
)
|
||||
mock_console = MagicMock()
|
||||
mock_console.print = lambda msg, **kw: None
|
||||
@@ -1286,7 +1286,7 @@ class TestHandleCloseExtended:
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.modules.email.batch_close_post_ops",
|
||||
lambda bp, push_fn, central_fn, purge_fn: None,
|
||||
lambda bp, central_fn, purge_fn: None,
|
||||
)
|
||||
printed: list[str] = []
|
||||
errors: list[str] = []
|
||||
@@ -1338,7 +1338,7 @@ class TestHandleCloseExtended:
|
||||
post_ops_called: list[bool] = []
|
||||
monkeypatch.setattr(
|
||||
"aipass.ai_mail.apps.modules.email.batch_close_post_ops",
|
||||
lambda bp, push_fn, central_fn, purge_fn: post_ops_called.append(True),
|
||||
lambda bp, central_fn, purge_fn: post_ops_called.append(True),
|
||||
)
|
||||
printed: list[str] = []
|
||||
mock_console = MagicMock()
|
||||
@@ -1450,7 +1450,6 @@ class TestDeliveryCallback:
|
||||
new_count,
|
||||
opened_count,
|
||||
total,
|
||||
push_dashboard_fn=None,
|
||||
update_central_fn=None,
|
||||
):
|
||||
"""Capture on_email_delivered arguments."""
|
||||
@@ -1460,7 +1459,6 @@ class TestDeliveryCallback:
|
||||
"new_count": new_count,
|
||||
"opened_count": opened_count,
|
||||
"total": total,
|
||||
"push_dashboard_fn": push_dashboard_fn,
|
||||
"update_central_fn": update_central_fn,
|
||||
}
|
||||
)
|
||||
@@ -1478,7 +1476,6 @@ class TestDeliveryCallback:
|
||||
assert delivered_args[0]["new_count"] == 3
|
||||
assert delivered_args[0]["opened_count"] == 2
|
||||
assert delivered_args[0]["total"] == 5
|
||||
assert delivered_args[0]["push_dashboard_fn"] is not None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
@@ -1761,3 +1758,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"
|
||||
|
||||
@@ -155,51 +155,34 @@ def test_dispatch_send_error_passes_correct_email_data(monkeypatch):
|
||||
# ---- on_email_delivered tests --------------------------------
|
||||
|
||||
|
||||
def test_on_email_delivered_with_both_callbacks():
|
||||
"""Both callbacks are invoked when provided."""
|
||||
push_fn = MagicMock()
|
||||
def test_on_email_delivered_with_central_callback():
|
||||
"""Central callback is invoked when provided."""
|
||||
update_fn = MagicMock()
|
||||
branch_path = "/some/path"
|
||||
|
||||
on_email_delivered(branch_path, 3, 1, 10, push_fn, update_fn)
|
||||
on_email_delivered(branch_path, 3, 1, 10, update_central_fn=update_fn)
|
||||
|
||||
push_fn.assert_called_once_with(branch_path)
|
||||
update_fn.assert_called_once_with()
|
||||
|
||||
|
||||
def test_on_email_delivered_with_none_callbacks():
|
||||
"""No error when both callbacks are None."""
|
||||
on_email_delivered("/some/path", 3, 1, 10, None, None)
|
||||
|
||||
|
||||
def test_on_email_delivered_dashboard_failure_does_not_block_central():
|
||||
"""Dashboard failure does not prevent central update from running."""
|
||||
push_fn = MagicMock(side_effect=RuntimeError("dashboard broken"))
|
||||
update_fn = MagicMock()
|
||||
|
||||
on_email_delivered("/some/path", 3, 1, 10, push_fn, update_fn)
|
||||
|
||||
push_fn.assert_called_once()
|
||||
update_fn.assert_called_once()
|
||||
"""No error when callback is None."""
|
||||
on_email_delivered("/some/path", 3, 1, 10, None)
|
||||
|
||||
|
||||
def test_on_email_delivered_central_failure_does_not_raise():
|
||||
"""Central update failure is caught silently."""
|
||||
push_fn = MagicMock()
|
||||
update_fn = MagicMock(side_effect=RuntimeError("central broken"))
|
||||
|
||||
on_email_delivered("/some/path", 3, 1, 10, push_fn, update_fn)
|
||||
on_email_delivered("/some/path", 3, 1, 10, update_central_fn=update_fn)
|
||||
|
||||
push_fn.assert_called_once()
|
||||
update_fn.assert_called_once()
|
||||
|
||||
|
||||
def test_on_email_delivered_both_fail_no_exception():
|
||||
"""Both callbacks failing does not raise any exception."""
|
||||
push_fn = MagicMock(side_effect=RuntimeError("push fail"))
|
||||
def test_on_email_delivered_central_fail_no_exception():
|
||||
"""Central callback failing does not raise any exception."""
|
||||
update_fn = MagicMock(side_effect=RuntimeError("update fail"))
|
||||
|
||||
on_email_delivered("/some/path", 3, 1, 10, push_fn, update_fn)
|
||||
on_email_delivered("/some/path", 3, 1, 10, update_central_fn=update_fn)
|
||||
|
||||
push_fn.assert_called_once()
|
||||
update_fn.assert_called_once()
|
||||
|
||||
@@ -42,12 +42,6 @@ def _mock_inbox_lock(monkeypatch):
|
||||
monkeypatch.setattr(mod, "_get_inbox_lock", lambda: _noop_lock)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_dashboard(monkeypatch):
|
||||
"""Replace _get_push_dashboard_update with a no-op."""
|
||||
monkeypatch.setattr(mod, "_get_push_dashboard_update", lambda: lambda _bp: None)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_central(monkeypatch):
|
||||
"""Replace _get_update_central with a no-op."""
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Tests for miscellaneous handlers -- central_writer.update_central, dispatch status.check_pid_status,
|
||||
daemon.run_daemon, json_handler.increment_counter/update_data_metrics, delivery.deliver_to_inbox_file,
|
||||
dashboard_sync.push_dashboard_update, inbox_resolve.resolve_inbox_target."""
|
||||
inbox_resolve.resolve_inbox_target."""
|
||||
|
||||
import json
|
||||
import os
|
||||
@@ -14,7 +14,6 @@ import aipass.ai_mail.apps.handlers.central_writer as central_mod
|
||||
import aipass.ai_mail.apps.handlers.dispatch.daemon as daemon_mod
|
||||
import aipass.ai_mail.apps.handlers.json_utils.json_handler as json_handler_mod
|
||||
import aipass.ai_mail.apps.handlers.email.delivery as delivery_mod
|
||||
import aipass.ai_mail.apps.handlers.email.dashboard_sync as dashboard_mod
|
||||
from aipass.ai_mail.apps.handlers.central_writer import update_central
|
||||
from aipass.ai_mail.apps.handlers.dispatch.status import check_pid_status
|
||||
from aipass.ai_mail.apps.handlers.json_utils.json_handler import (
|
||||
@@ -22,7 +21,6 @@ from aipass.ai_mail.apps.handlers.json_utils.json_handler import (
|
||||
update_data_metrics,
|
||||
)
|
||||
from aipass.ai_mail.apps.handlers.email.delivery import deliver_to_inbox_file
|
||||
from aipass.ai_mail.apps.handlers.email.dashboard_sync import push_dashboard_update
|
||||
from aipass.ai_mail.apps.handlers.email.inbox_resolve import resolve_inbox_target
|
||||
|
||||
|
||||
@@ -61,14 +59,6 @@ def _silence_json_handler_delivery():
|
||||
yield mock_jh
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _silence_json_handler_dashboard():
|
||||
"""Prevent log_operation in dashboard_sync from writing real JSON files."""
|
||||
with patch("aipass.ai_mail.apps.handlers.email.dashboard_sync.json_handler") as mock_jh:
|
||||
mock_jh.log_operation.return_value = True
|
||||
yield mock_jh
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _silence_json_handler_inbox_resolve():
|
||||
"""Prevent log_operation in inbox_resolve from writing real JSON files."""
|
||||
@@ -399,64 +389,6 @@ def test_deliver_to_inbox_file_preserves_existing_messages(tmp_path, _noop_inbox
|
||||
assert result["messages"][1]["subject"] == "Old email"
|
||||
|
||||
|
||||
# ==============================================================
|
||||
# push_dashboard_update tests
|
||||
# ==============================================================
|
||||
|
||||
|
||||
def test_push_dashboard_update_happy_path(tmp_path):
|
||||
"""Successful dashboard push returns True."""
|
||||
branch_path = tmp_path / "trigger"
|
||||
inbox_dir = branch_path / ".ai_mail.local"
|
||||
inbox_dir.mkdir(parents=True)
|
||||
inbox_file = inbox_dir / "inbox.json"
|
||||
inbox_data = {
|
||||
"messages": [
|
||||
{"id": "m1", "status": "new", "timestamp": "2026-04-01 10:00:00"},
|
||||
{"id": "m2", "status": "opened", "timestamp": "2026-04-01 09:00:00"},
|
||||
]
|
||||
}
|
||||
inbox_file.write_text(json.dumps(inbox_data), encoding="utf-8")
|
||||
|
||||
mock_write = MagicMock(return_value=True)
|
||||
|
||||
with patch.object(dashboard_mod, "_get_write_section", return_value=mock_write):
|
||||
result = push_dashboard_update(branch_path)
|
||||
|
||||
assert result is True
|
||||
mock_write.assert_called_once()
|
||||
section_data = mock_write.call_args[0][1]
|
||||
assert section_data == "ai_mail"
|
||||
|
||||
|
||||
def test_push_dashboard_update_no_inbox(tmp_path):
|
||||
"""Returns True with zero stats when no inbox exists."""
|
||||
branch_path = tmp_path / "empty_branch"
|
||||
branch_path.mkdir()
|
||||
|
||||
mock_write = MagicMock(return_value=True)
|
||||
|
||||
with patch.object(dashboard_mod, "_get_write_section", return_value=mock_write):
|
||||
result = push_dashboard_update(branch_path)
|
||||
|
||||
assert result is True
|
||||
mock_write.assert_called_once()
|
||||
section_data = mock_write.call_args[0][2]
|
||||
assert section_data["new"] == 0
|
||||
assert section_data["total"] == 0
|
||||
|
||||
|
||||
def test_push_dashboard_update_catches_exceptions(tmp_path):
|
||||
"""Returns False on any exception (never raises)."""
|
||||
branch_path = tmp_path / "broken"
|
||||
branch_path.mkdir()
|
||||
|
||||
with patch.object(dashboard_mod, "_get_write_section", side_effect=RuntimeError("broken")):
|
||||
result = push_dashboard_update(branch_path)
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
# ==============================================================
|
||||
# resolve_inbox_target tests
|
||||
# ==============================================================
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -274,7 +274,67 @@
|
||||
{
|
||||
"file": "apps/handlers/json/json_handler.py",
|
||||
"standard": "test_quality",
|
||||
"reason": "save_json now raises ValueError on invalid structure (aipass.common contract, TDPLAN-0006 P2). Tested via pytest.raises — no False return path to test."
|
||||
"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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ Init Bootstrap Handler - PRIVATE implementation
|
||||
|
||||
Business logic for `aipass init`. Creates the project scaffold:
|
||||
1. {NAME}_REGISTRY.json — project registry with UUID
|
||||
2. .aipass/aipass_global_prompt.md — global prompt (injected every turn)
|
||||
2. .aipass/tier0_kernel.md — tier 0 kernel prompt (every turn)
|
||||
2b..aipass/tier1_navmap.md — tier 1 navigation map (periodic)
|
||||
3. CLAUDE.md — project prompt (Claude Code reads this)
|
||||
4. AGENTS.md — Codex equivalent of CLAUDE.md
|
||||
5. README.md — getting started guide
|
||||
@@ -69,14 +70,6 @@ def _detect_aipass_home() -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_global_prompt(name: str, aipass_home: str | None, dest: Path) -> str:
|
||||
"""Resolve global prompt content from source template or fallback generator."""
|
||||
source = Path(aipass_home) / ".aipass" / "project_global_prompt.md" if aipass_home else None
|
||||
if source and source.is_file():
|
||||
return source.read_text(encoding="utf-8").replace("{name}", name)
|
||||
return sc.with_source(sc.global_prompt_md(name), dest)
|
||||
|
||||
|
||||
def _hook_fingerprint(hook_entry: dict) -> str:
|
||||
"""Extract a comparable fingerprint from a hook entry."""
|
||||
commands = []
|
||||
@@ -323,10 +316,14 @@ def init_project(target: Path, project_name: str | None = None) -> dict:
|
||||
aipass_dir = target / ".aipass"
|
||||
aipass_dir.mkdir(exist_ok=True)
|
||||
|
||||
global_prompt_path = aipass_dir / "aipass_global_prompt.md"
|
||||
if not global_prompt_path.exists():
|
||||
global_prompt_path.write_text(_resolve_global_prompt(name, aipass_home, global_prompt_path), encoding="utf-8")
|
||||
created.append(str(global_prompt_path))
|
||||
# 2. .aipass/tier0_kernel.md + tier1_navmap.md — tiered prompt injection
|
||||
for tier_file in ("tier0_kernel.md", "tier1_navmap.md"):
|
||||
tier_dest = aipass_dir / tier_file
|
||||
if not tier_dest.exists() and aipass_home:
|
||||
tier_src = Path(aipass_home) / ".aipass" / tier_file
|
||||
if tier_src.is_file():
|
||||
shutil.copy2(str(tier_src), str(tier_dest))
|
||||
created.append(str(tier_dest))
|
||||
|
||||
# 2b. .aipass/hooks.json — project hook config from template
|
||||
hooks_json_path = aipass_dir / "hooks.json"
|
||||
@@ -488,14 +485,21 @@ def update_project(target: Path) -> dict:
|
||||
|
||||
# --- Managed files: write only when content has changed ---
|
||||
|
||||
global_prompt_path = aipass_dir / "aipass_global_prompt.md"
|
||||
aipass_home = aipass_home or _detect_aipass_home()
|
||||
generated = _resolve_global_prompt(name, aipass_home, global_prompt_path)
|
||||
if not global_prompt_path.exists() or global_prompt_path.read_text(encoding="utf-8") != generated:
|
||||
global_prompt_path.write_text(generated, encoding="utf-8")
|
||||
updated.append(str(global_prompt_path))
|
||||
else:
|
||||
already_current.append(str(global_prompt_path))
|
||||
|
||||
# tier0_kernel.md + tier1_navmap.md — tiered prompt injection
|
||||
for tier_file in ("tier0_kernel.md", "tier1_navmap.md"):
|
||||
tier_dest = aipass_dir / tier_file
|
||||
tier_src = Path(aipass_home) / ".aipass" / tier_file if aipass_home else None
|
||||
if tier_src and tier_src.is_file():
|
||||
canonical = tier_src.read_text(encoding="utf-8")
|
||||
if not tier_dest.exists() or tier_dest.read_text(encoding="utf-8") != canonical:
|
||||
tier_dest.write_text(canonical, encoding="utf-8")
|
||||
updated.append(str(tier_dest))
|
||||
else:
|
||||
already_current.append(str(tier_dest))
|
||||
elif tier_dest.exists():
|
||||
already_current.append(str(tier_dest))
|
||||
|
||||
# settings.json — smart merge: preserve user hooks + env, update AIPass hooks
|
||||
settings_path = claude_dir / "settings.json"
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: json_handler.py
|
||||
# Description: Branch-local shim — delegates to aipass.common.json_handler
|
||||
# Description: Branch-local shim — delegates to aipass.aipass.shared.json_handler
|
||||
# Version: 2.0.0
|
||||
# Created: 2026-04-16
|
||||
# Modified: 2026-06-06
|
||||
# =============================================
|
||||
|
||||
"""Branch-local JSON handler — thin shim over the shared ``aipass.common`` library.
|
||||
"""Branch-local JSON handler — thin shim over the shared ``aipass.aipass.shared`` library.
|
||||
|
||||
All logic lives in ``aipass.common.json_handler.JsonHandler``.
|
||||
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.
|
||||
@@ -20,7 +20,7 @@ import inspect
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from aipass.common.json_handler import JsonHandler
|
||||
from aipass.aipass.shared.json_handler import JsonHandler
|
||||
|
||||
|
||||
def _get_caller_module_name() -> str:
|
||||
|
||||
@@ -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")
|
||||
@@ -20,9 +20,19 @@ from typing import Dict, List, NamedTuple
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.prax import logger
|
||||
|
||||
from aipass.common.registry_discovery import find_registry as _discover_registry
|
||||
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,
|
||||
@@ -545,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()
|
||||
@@ -560,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:
|
||||
@@ -620,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()
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ from typing import List, NamedTuple
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.prax import logger
|
||||
|
||||
from aipass.common.registry_discovery import find_registry as _discover_registry
|
||||
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 (
|
||||
|
||||
@@ -16,7 +16,7 @@ Usage:
|
||||
aipass init # show progress / introspection
|
||||
aipass init run # interactive
|
||||
aipass init run --non-interactive # CI/headless, all defaults
|
||||
aipass init run --name Patrick --cli claude
|
||||
aipass init run --name YourName --cli claude
|
||||
aipass init run --dry-run # walk all 12 stages, no destructive ops
|
||||
# - skips drone @spawn create (stage 8)
|
||||
# - skips tmux/wt handoff (stage 11)
|
||||
@@ -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:
|
||||
@@ -825,7 +876,7 @@ def print_help() -> None:
|
||||
console.print("[yellow]USAGE:[/yellow]")
|
||||
console.print(" [green]aipass init run[/green] [dim]# interactive[/dim]")
|
||||
console.print(" [green]aipass init run --non-interactive[/green] [dim]# CI/headless[/dim]")
|
||||
console.print(" [green]aipass init run --name Patrick[/green] [dim]# pre-fill name[/dim]")
|
||||
console.print(" [green]aipass init run --name YourName[/green] [dim]# pre-fill name[/dim]")
|
||||
console.print(" [green]aipass init run --cli claude[/green] [dim]# pre-fill CLI[/dim]")
|
||||
console.print(" [green]aipass init run --no-docker[/green] [dim]# skip docker offer[/dim]")
|
||||
console.print(" [green]aipass init run --dry-run[/green] [dim]# walk all stages, no writes[/dim]")
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# aipass.aipass.shared — shared leaf utilities (no branch dependencies, loads pre-drone)
|
||||
@@ -3,7 +3,7 @@
|
||||
# Description: Shared JSON handler with injectable storage directory
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-06-06
|
||||
# Modified: 2026-06-06
|
||||
# Modified: 2026-06-10
|
||||
# =============================================
|
||||
|
||||
"""Shared JSON handler — auto-creating, self-healing JSON system.
|
||||
@@ -3,7 +3,7 @@
|
||||
# Description: Shared JSON operations — deep merge and backup
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-06-06
|
||||
# Modified: 2026-06-06
|
||||
# Modified: 2026-06-10
|
||||
# =============================================
|
||||
|
||||
"""Shared JSON operations — deep merge and backup utilities.
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
# Description: Shared registry file discovery (walk-up search)
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-06-06
|
||||
# Modified: 2026-06-06
|
||||
# Modified: 2026-06-10
|
||||
# =============================================
|
||||
|
||||
"""Registry discovery — find *_REGISTRY.json by walking up the directory tree.
|
||||
@@ -98,7 +98,6 @@ def test_init_project_creates_all_expected_files(tmp_path):
|
||||
|
||||
expected_files = [
|
||||
target / "DEMO_REGISTRY.json",
|
||||
target / ".aipass" / "aipass_global_prompt.md",
|
||||
target / "CLAUDE.md",
|
||||
target / "AGENTS.md",
|
||||
target / "README.md",
|
||||
@@ -107,8 +106,13 @@ def test_init_project_creates_all_expected_files(tmp_path):
|
||||
target / ".claude" / "commands" / "prep.md",
|
||||
target / "src" / "demo" / "__init__.py",
|
||||
]
|
||||
# Tier files are env-dependent (need AIPASS_HOME)
|
||||
if result["aipass_home"]:
|
||||
expected_files.append(target / ".aipass" / "tier0_kernel.md")
|
||||
expected_files.append(target / ".aipass" / "tier1_navmap.md")
|
||||
for f in expected_files:
|
||||
assert f.exists(), f"Expected file not created: {f}"
|
||||
assert not (target / ".aipass" / "aipass_global_prompt.md").exists(), "Retired global prompt should NOT be seeded"
|
||||
|
||||
# src/<package>/ is a directory with __init__.py
|
||||
assert (target / "src" / "demo").is_dir(), "Expected src/demo/ package directory"
|
||||
@@ -126,7 +130,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"]) >= 11
|
||||
assert len(result["created_files"]) >= 10
|
||||
|
||||
|
||||
def test_init_project_return_dict_structure(tmp_path):
|
||||
@@ -288,19 +292,6 @@ def test_init_project_settings_no_hooks(tmp_path):
|
||||
assert "permissions" in data
|
||||
|
||||
|
||||
def test_init_project_global_prompt_content(tmp_path):
|
||||
"""Global prompt contains project name and AIPass terminology."""
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
|
||||
init_project(target, project_name="alpha")
|
||||
|
||||
content = (target / ".aipass" / "aipass_global_prompt.md").read_text(encoding="utf-8")
|
||||
assert "# ALPHA" in content
|
||||
assert "ALPHA_REGISTRY.json" in content
|
||||
assert "# Commands" in content
|
||||
|
||||
|
||||
def test_init_project_readme_md_content(tmp_path):
|
||||
"""README.md contains getting started guide with project name."""
|
||||
target = tmp_path / "proj"
|
||||
@@ -324,7 +315,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"]) >= 11
|
||||
assert len(result["created_files"]) >= 10
|
||||
|
||||
|
||||
def test_init_project_defaults_name_from_directory(tmp_path):
|
||||
@@ -359,7 +350,6 @@ def test_init_project_skips_existing_optional_files(tmp_path):
|
||||
# Pre-create optional files
|
||||
aipass_dir = target / ".aipass"
|
||||
aipass_dir.mkdir()
|
||||
(aipass_dir / "aipass_global_prompt.md").write_text("# Custom global\n", encoding="utf-8")
|
||||
(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")
|
||||
@@ -542,10 +532,12 @@ def test_update_project_creates_missing_managed_dirs(tmp_path):
|
||||
|
||||
result = update_project(target)
|
||||
|
||||
assert (target / ".aipass" / "aipass_global_prompt.md").exists()
|
||||
assert (target / ".claude" / "settings.json").exists()
|
||||
# Managed files in deleted dirs re-written (global_prompt, hooks.json, settings, prep)
|
||||
assert len(result["updated_files"]) == 4
|
||||
# Managed files in deleted dirs re-written (tier0_kernel, tier1_navmap, hooks.json, settings, prep)
|
||||
if result["aipass_home"]:
|
||||
assert len(result["updated_files"]) == 5
|
||||
else:
|
||||
assert len(result["updated_files"]) == 2
|
||||
assert len(result["already_current"]) >= 2
|
||||
|
||||
|
||||
@@ -850,6 +842,154 @@ def test_update_project_hooks_json_already_current(tmp_path):
|
||||
assert any("hooks.json" in f for f in result["already_current"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tiered prompt injection tests (FPLAN-0284)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_init_project_creates_tier_files(tmp_path):
|
||||
"""init_project seeds tier0_kernel.md and tier1_navmap.md when AIPASS_HOME available."""
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
|
||||
result = init_project(target, project_name="tiers")
|
||||
|
||||
if result["aipass_home"] is None:
|
||||
pytest.skip("AIPASS_HOME not detectable in this environment")
|
||||
|
||||
assert (target / ".aipass" / "tier0_kernel.md").exists()
|
||||
assert (target / ".aipass" / "tier1_navmap.md").exists()
|
||||
|
||||
|
||||
def test_init_project_tier_files_match_canonical(tmp_path):
|
||||
"""Tier files in new project match the canonical source exactly."""
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
|
||||
result = init_project(target, project_name="canon")
|
||||
|
||||
if result["aipass_home"] is None:
|
||||
pytest.skip("AIPASS_HOME not detectable in this environment")
|
||||
|
||||
for tier_file in ("tier0_kernel.md", "tier1_navmap.md"):
|
||||
canonical = Path(result["aipass_home"]) / ".aipass" / tier_file
|
||||
if not canonical.exists():
|
||||
pytest.skip(f"{tier_file} not found in canonical .aipass/")
|
||||
assert (target / ".aipass" / tier_file).read_bytes() == canonical.read_bytes()
|
||||
|
||||
|
||||
def test_init_project_tier_files_in_created_list(tmp_path):
|
||||
"""Tier files appear in created_files list."""
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
|
||||
result = init_project(target, project_name="listed")
|
||||
|
||||
if result["aipass_home"] is None:
|
||||
pytest.skip("AIPASS_HOME not detectable in this environment")
|
||||
|
||||
assert any("tier0_kernel.md" in f for f in result["created_files"])
|
||||
assert any("tier1_navmap.md" in f for f in result["created_files"])
|
||||
|
||||
|
||||
def test_init_project_no_tier_files_without_aipass_home(tmp_path, monkeypatch):
|
||||
"""Without AIPASS_HOME, tier files are not created."""
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||
lambda: None,
|
||||
)
|
||||
|
||||
init_project(target, project_name="notiers")
|
||||
|
||||
assert not (target / ".aipass" / "tier0_kernel.md").exists()
|
||||
assert not (target / ".aipass" / "tier1_navmap.md").exists()
|
||||
|
||||
|
||||
def test_init_project_hooks_json_has_tiers_enabled(tmp_path):
|
||||
"""hooks.json from template has tier0_kernel and navmap enabled, no global_prompt."""
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
|
||||
result = init_project(target, project_name="hookstier")
|
||||
|
||||
if result["aipass_home"] is None:
|
||||
pytest.skip("AIPASS_HOME not detectable in this environment")
|
||||
|
||||
hooks_json = target / ".aipass" / "hooks.json"
|
||||
data = json.loads(hooks_json.read_text(encoding="utf-8"))
|
||||
ups = data["UserPromptSubmit"]
|
||||
|
||||
assert ups["tier0_kernel"]["enabled"] is True
|
||||
assert ups["navmap"]["enabled"] is True
|
||||
assert "global_prompt" not in ups
|
||||
|
||||
|
||||
def test_update_project_adds_tier_files_to_existing(tmp_path):
|
||||
"""update_project adds tier files to a project that lacks them."""
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
|
||||
registry_data = {
|
||||
"metadata": {
|
||||
"id": "test-id",
|
||||
"name": "OLD",
|
||||
"version": "1.0.0",
|
||||
"created": "2026-01-01",
|
||||
"last_updated": "2026-01-01",
|
||||
"total_branches": 0,
|
||||
},
|
||||
"branches": [],
|
||||
}
|
||||
(target / "OLD_REGISTRY.json").write_text(json.dumps(registry_data), encoding="utf-8")
|
||||
(target / ".aipass").mkdir()
|
||||
|
||||
result = update_project(target)
|
||||
|
||||
if result["aipass_home"] is None:
|
||||
pytest.skip("AIPASS_HOME not detectable in this environment")
|
||||
|
||||
assert (target / ".aipass" / "tier0_kernel.md").exists()
|
||||
assert (target / ".aipass" / "tier1_navmap.md").exists()
|
||||
assert any("tier0_kernel.md" in f for f in result["updated_files"])
|
||||
assert any("tier1_navmap.md" in f for f in result["updated_files"])
|
||||
|
||||
|
||||
def test_update_project_tier_files_already_current(tmp_path):
|
||||
"""update reports tier files as already_current when unchanged."""
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
result = init_project(target, project_name="tiercurr")
|
||||
|
||||
if result["aipass_home"] is None:
|
||||
pytest.skip("AIPASS_HOME not detectable in this environment")
|
||||
|
||||
result = update_project(target)
|
||||
|
||||
assert any("tier0_kernel.md" in f for f in result["already_current"])
|
||||
assert any("tier1_navmap.md" in f for f in result["already_current"])
|
||||
|
||||
|
||||
def test_update_project_refreshes_stale_tier_files(tmp_path):
|
||||
"""update overwrites tier files when they differ from canonical source."""
|
||||
target = tmp_path / "proj"
|
||||
target.mkdir()
|
||||
result = init_project(target, project_name="stale")
|
||||
|
||||
if result["aipass_home"] is None:
|
||||
pytest.skip("AIPASS_HOME not detectable in this environment")
|
||||
|
||||
(target / ".aipass" / "tier0_kernel.md").write_text("# stale\n", encoding="utf-8")
|
||||
|
||||
result = update_project(target)
|
||||
|
||||
assert any("tier0_kernel.md" in f for f in result["updated_files"])
|
||||
content = (target / ".aipass" / "tier0_kernel.md").read_text(encoding="utf-8")
|
||||
assert "AIPass" in content
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# scaffold_content — global_prompt_md tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -488,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"):
|
||||
|
||||
@@ -60,7 +60,7 @@ class TestDefaultFactory:
|
||||
|
||||
def test_unknown_type_raises(self):
|
||||
"""Unknown json_type raises ValueError."""
|
||||
from aipass.common.json_handler import JsonHandler
|
||||
from aipass.aipass.shared.json_handler import JsonHandler
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
JsonHandler._create_default("unknown_type", "test_mod")
|
||||
@@ -220,7 +220,7 @@ class TestSave:
|
||||
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.common.json_handler.JsonHandler.write_json", return_value=False):
|
||||
with patch("aipass.aipass.shared.json_handler.JsonHandler.write_json", return_value=False):
|
||||
result = save_json("s", "config", data)
|
||||
assert result is False
|
||||
|
||||
|
||||
@@ -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}"
|
||||
@@ -357,7 +357,7 @@ class TestRegistryConsistency:
|
||||
class TestFindRegistry:
|
||||
def test_finds_registry(self, tmp_path: Path) -> None:
|
||||
"""Shared find_registry finds *_REGISTRY.json from start_path."""
|
||||
from aipass.common.registry_discovery import find_registry
|
||||
from aipass.aipass.shared.registry_discovery import find_registry
|
||||
|
||||
(tmp_path / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||
result = find_registry(start_path=tmp_path)
|
||||
@@ -366,7 +366,7 @@ class TestFindRegistry:
|
||||
|
||||
def test_fallback_when_missing(self, tmp_path: Path) -> None:
|
||||
"""Shared find_registry returns fallback when no registry in isolated dir."""
|
||||
from aipass.common.registry_discovery import find_registry
|
||||
from aipass.aipass.shared.registry_discovery import find_registry
|
||||
|
||||
isolated = tmp_path / "no_registry"
|
||||
isolated.mkdir()
|
||||
|
||||
@@ -130,6 +130,11 @@
|
||||
"standard": "unused_function",
|
||||
"reason": "fetch_api_key() and fetch_validate_key() are module-level wrappers called from tests/test_critical_paths.py. The unused_function checker excludes test dirs from its search corpus. Encapsulation standard requires tests to go through modules — these functions serve that purpose (DPLAN-0155)."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/api_key.py",
|
||||
"standard": "cli",
|
||||
"reason": "get_secret_cmd() --list uses console.print() for slug names (identifiers, not secrets). Machine consumers use the in-process module aipass.api.apps.modules.secrets.get_secret; CLI never prints raw values (DPLAN-0211)."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_aggregation.py",
|
||||
"standard": "architecture",
|
||||
@@ -224,6 +229,16 @@
|
||||
"file": "tests/test_integrations_manager.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test file — lives in tests/ by convention, not in the 3-layer app structure. Test files are exempt from layer architecture standard."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_secrets.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test file — lives in tests/ by convention, not in the 3-layer app structure. Test files are exempt from layer architecture standard."
|
||||
},
|
||||
{
|
||||
"file": "tests/test_secrets.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Test file — imports handler functions directly for unit testing. Tests need direct access to verify handler behavior."
|
||||
}
|
||||
],
|
||||
"notes": {
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
> Centralized external API gateway — authenticated service clients for all external APIs
|
||||
|
||||
**Module:** `aipass.api` | **Role:** `api_gateway`
|
||||
**Seedgo:** 99% (35/36 at 100%) | **Tests:** 447 pass | **Functions:** 77 public (77 tested)
|
||||
**Last Updated:** 2026-05-16
|
||||
**Seedgo:** 100% (37/37 at 100%) | **Tests:** 504 pass | **Functions:** 82 public (82 tested)
|
||||
**Last Updated:** 2026-06-15
|
||||
|
||||
---
|
||||
|
||||
@@ -26,6 +26,7 @@ drone @api <command> [args]
|
||||
| `validate [provider]` | Validate API key (default: openrouter) |
|
||||
| `validate google` | Validate Google OAuth2 credentials |
|
||||
| `reauth google` | Re-authenticate Google OAuth2 |
|
||||
| `get-secret <provider/slug> [--out FILE] [--json] [--list]` | Secret access (masked summary; --out writes to file) |
|
||||
| `list-providers` | List available API providers |
|
||||
| `init` | Initialize .env template at ~/.secrets/aipass/ |
|
||||
| `test` | Test OpenRouter connection status |
|
||||
@@ -48,8 +49,9 @@ drone @api <command> [args]
|
||||
api/
|
||||
├── apps/
|
||||
│ ├── api.py # Entry point — module discovery, command routing
|
||||
│ ├── modules/ # Orchestration layer (7 modules)
|
||||
│ ├── modules/ # Orchestration layer (8 modules)
|
||||
│ │ ├── api_key.py # Key retrieval, validation, provider listing
|
||||
│ │ ├── secrets.py # Cross-branch secrets door (in-process API)
|
||||
│ │ ├── openrouter_client.py # OpenRouter client — calls, models, status
|
||||
│ │ ├── google_client.py # Google API services (Drive, Calendar, etc.)
|
||||
│ │ ├── usage_tracker.py # Usage metrics — track, stats, cleanup
|
||||
@@ -57,7 +59,7 @@ api/
|
||||
│ │ ├── integrations_manager.py # Contract dispatch — integrations list/call
|
||||
│ │ └── registry.py # Driver auto-discovery (load_drivers)
|
||||
│ ├── handlers/ # Business logic (7 packages, 15 files)
|
||||
│ │ ├── auth/env.py, keys.py
|
||||
│ │ ├── auth/env.py, keys.py, secrets.py
|
||||
│ │ ├── config/provider.py
|
||||
│ │ ├── google/auth.py, service_factory.py, retry.py
|
||||
│ │ ├── integrations/list.py, call.py
|
||||
@@ -66,7 +68,7 @@ api/
|
||||
│ │ └── usage/aggregation.py, cleanup.py, tracking.py
|
||||
│ └── integrations/ # Private driver space (gitignored)
|
||||
│ └── {project}/driver.py
|
||||
└── tests/ # 447 tests across 27 files
|
||||
└── tests/ # 504 tests across 28 files
|
||||
```
|
||||
|
||||
Three-tier: entry point routes to modules (orchestration), modules delegate to handlers (business logic). Modules auto-discovered from `apps/modules/*.py` via `handle_command()`.
|
||||
@@ -85,6 +87,12 @@ service = get_drive_service(thread_safe=True) # For concurrent workers
|
||||
|
||||
from aipass.api.apps.modules.google_client import get_google_service
|
||||
service = get_google_service("calendar", "v3")
|
||||
|
||||
from aipass.api.apps.modules.secrets import get_secret, list_secrets
|
||||
token = get_secret("telegram", "bot") # Returns bot_token string
|
||||
config = get_secret("telegram", "bot", as_json=True) # Returns full dict
|
||||
slugs = list_secrets("telegram") # Returns ["bot", "webhook", ...]
|
||||
# CLI never prints raw values — use the Python API above for programmatic access
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -156,6 +156,7 @@ def print_help():
|
||||
table.add_column("Description", style="white")
|
||||
|
||||
table.add_row("get-key", "Retrieve API key for provider")
|
||||
table.add_row("get-secret", "Read secret from provider store")
|
||||
table.add_row("validate", "Validate API credentials and connection")
|
||||
table.add_row("validate google", "Validate Google OAuth2 credentials")
|
||||
table.add_row("reauth google", "Re-authenticate Google OAuth2")
|
||||
@@ -205,7 +206,8 @@ def print_help():
|
||||
console.print()
|
||||
|
||||
console.print(
|
||||
"[dim]Commands: get-key, validate, test, models, status, call, list-providers, init, track, stats, session, caller-usage, cleanup[/dim]"
|
||||
"[dim]Commands: get-key, get-secret, validate, test, models, status, call,"
|
||||
" list-providers, init, track, stats, session, caller-usage, cleanup[/dim]"
|
||||
)
|
||||
console.print()
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: secrets.py
|
||||
# Description: Secrets Store Handler
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-06-15
|
||||
# Modified: 2026-06-15
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Secrets Store Handler
|
||||
|
||||
Reads structured secrets from ~/.secrets/aipass/<provider>/<slug>.
|
||||
Supports JSON config files and raw secret files.
|
||||
|
||||
Functions:
|
||||
get_secret() - Read a secret by provider/slug
|
||||
list_secrets() - List available slugs for a provider
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.api.apps.handlers.json import json_handler
|
||||
|
||||
SECRETS_BASE = Path.home() / ".secrets" / "aipass"
|
||||
|
||||
# Keys to search for when returning a plain (non-JSON) secret value
|
||||
_TOKEN_KEYS = ("bot_token", "api_key", "token", "secret", "password", "key")
|
||||
|
||||
|
||||
# ==============================================
|
||||
# SECRET RETRIEVAL
|
||||
# ==============================================
|
||||
|
||||
|
||||
def get_secret(provider: str, slug: str, as_json: bool = False) -> Optional[Any]:
|
||||
"""
|
||||
Get secret value from provider store.
|
||||
|
||||
Source: ~/.secrets/aipass/<provider>/<slug>.json or <slug>
|
||||
|
||||
Args:
|
||||
provider: Provider directory name (e.g., 'telegram', 'discord')
|
||||
slug: Secret file name (without .json extension)
|
||||
as_json: If True, return full parsed dict; otherwise extract primary token
|
||||
|
||||
Returns:
|
||||
Secret value (str or dict) or None if not found
|
||||
|
||||
Example:
|
||||
>>> token = get_secret('telegram', 'bot')
|
||||
>>> if token:
|
||||
... print(f"Got token: {token[:10]}...")
|
||||
"""
|
||||
provider_dir = SECRETS_BASE / provider
|
||||
|
||||
if not provider_dir.exists() or not provider_dir.is_dir():
|
||||
logger.warning(f"Provider directory not found: {provider_dir}")
|
||||
return None
|
||||
|
||||
# Try JSON file first
|
||||
json_path = provider_dir / f"{slug}.json"
|
||||
if json_path.exists():
|
||||
result = _read_json_secret(json_path, as_json)
|
||||
if result is not None:
|
||||
json_handler.log_operation("secret_retrieved", {"provider": provider, "slug": slug, "format": "json"})
|
||||
return result
|
||||
|
||||
# Fall back to raw file
|
||||
raw_path = provider_dir / slug
|
||||
if raw_path.exists():
|
||||
result = _read_raw_secret(raw_path)
|
||||
if result is not None:
|
||||
json_handler.log_operation("secret_retrieved", {"provider": provider, "slug": slug, "format": "raw"})
|
||||
return result
|
||||
|
||||
logger.warning(f"Secret not found: {provider}/{slug}")
|
||||
return None
|
||||
|
||||
|
||||
def list_secrets(provider: str) -> List[str]:
|
||||
"""
|
||||
List available secret slugs for a provider.
|
||||
|
||||
Args:
|
||||
provider: Provider directory name
|
||||
|
||||
Returns:
|
||||
Sorted list of slug names (JSON extensions stripped)
|
||||
|
||||
Example:
|
||||
>>> slugs = list_secrets('telegram')
|
||||
>>> print(slugs)
|
||||
['bot', 'webhook']
|
||||
"""
|
||||
provider_dir = SECRETS_BASE / provider
|
||||
|
||||
if not provider_dir.exists() or not provider_dir.is_dir():
|
||||
return []
|
||||
|
||||
slugs = []
|
||||
for entry in provider_dir.iterdir():
|
||||
if entry.name.startswith(".") or entry.name == "__pycache__":
|
||||
continue
|
||||
if not entry.is_file():
|
||||
continue
|
||||
|
||||
name = entry.name
|
||||
if name.endswith(".json"):
|
||||
name = name[:-5]
|
||||
slugs.append(name)
|
||||
|
||||
return sorted(slugs)
|
||||
|
||||
|
||||
# ==============================================
|
||||
# PRIVATE HELPERS
|
||||
# ==============================================
|
||||
|
||||
|
||||
def _read_json_secret(path: Path, as_json: bool) -> Optional[Any]:
|
||||
"""
|
||||
Read and parse a JSON secret file.
|
||||
|
||||
Args:
|
||||
path: Path to JSON file
|
||||
as_json: If True, return full dict; otherwise extract primary token
|
||||
|
||||
Returns:
|
||||
Parsed data or extracted token, or None on error
|
||||
"""
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.warning(f"Error reading secret file {path}: {e}")
|
||||
return None
|
||||
|
||||
if as_json:
|
||||
return data
|
||||
|
||||
if isinstance(data, dict):
|
||||
for key in _TOKEN_KEYS:
|
||||
if key in data:
|
||||
return str(data[key])
|
||||
return json.dumps(data)
|
||||
|
||||
return str(data)
|
||||
|
||||
|
||||
def _read_raw_secret(path: Path) -> Optional[str]:
|
||||
"""
|
||||
Read a raw (non-JSON) secret file.
|
||||
|
||||
Args:
|
||||
path: Path to raw secret file
|
||||
|
||||
Returns:
|
||||
Stripped file contents or None on error
|
||||
"""
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return f.read().strip()
|
||||
except OSError as e:
|
||||
logger.warning(f"Error reading secret file {path}: {e}")
|
||||
return None
|
||||
@@ -22,7 +22,7 @@ from typing import List
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.cli.apps.modules import console, header, success, error
|
||||
from aipass.api.apps.handlers.json import json_handler
|
||||
from aipass.api.apps.handlers.auth import keys, env
|
||||
from aipass.api.apps.handlers.auth import keys, env, secrets
|
||||
|
||||
|
||||
def print_introspection():
|
||||
@@ -61,7 +61,7 @@ def handle_command(command: str, args: List[str]) -> bool:
|
||||
True if command was handled, False otherwise
|
||||
"""
|
||||
try:
|
||||
if command not in ["get-key", "validate", "list-providers", "init"]:
|
||||
if command not in ["get-key", "validate", "list-providers", "init", "get-secret"]:
|
||||
return False
|
||||
|
||||
# Help gate
|
||||
@@ -82,6 +82,9 @@ def handle_command(command: str, args: List[str]) -> bool:
|
||||
if command == "get-key":
|
||||
get_key(args)
|
||||
return True
|
||||
if command == "get-secret":
|
||||
get_secret_cmd(args)
|
||||
return True
|
||||
if command == "validate":
|
||||
validate_key(args)
|
||||
return True
|
||||
@@ -168,6 +171,69 @@ def init_env():
|
||||
error("Failed to create environment template")
|
||||
|
||||
|
||||
def get_secret_cmd(args: List[str]):
|
||||
"""Orchestrate secret retrieval workflow (masked output only — no raw values to stdout)"""
|
||||
import json
|
||||
import os
|
||||
|
||||
if not args:
|
||||
error("Usage: drone @api get-secret <provider/slug> [--out FILE] [--json] [--list]")
|
||||
return
|
||||
|
||||
has_json = "--json" in args
|
||||
has_list = "--list" in args
|
||||
has_out = "--out" in args
|
||||
out_file = None
|
||||
if has_out:
|
||||
out_idx = args.index("--out")
|
||||
if out_idx + 1 < len(args):
|
||||
out_file = args[out_idx + 1]
|
||||
else:
|
||||
error("--out requires a file path argument")
|
||||
return
|
||||
|
||||
clean_args = [a for a in args if not a.startswith("--")]
|
||||
if has_out and out_file in clean_args:
|
||||
clean_args.remove(out_file)
|
||||
|
||||
if not clean_args:
|
||||
error("Usage: drone @api get-secret <provider/slug> [--out FILE] [--json] [--list]")
|
||||
return
|
||||
|
||||
parts = clean_args[0].split("/", 1)
|
||||
provider = parts[0]
|
||||
|
||||
if has_list:
|
||||
slugs = secrets.list_secrets(provider)
|
||||
for slug in slugs:
|
||||
# codeql[py/clear-text-logging-sensitive-data] # slug names are identifiers, not secret values
|
||||
console.print(slug)
|
||||
return
|
||||
|
||||
if len(parts) != 2 or not parts[1]:
|
||||
error("Expected format: <provider>/<slug> (e.g. telegram/bot)")
|
||||
return
|
||||
|
||||
slug = parts[1]
|
||||
result = secrets.get_secret(provider, slug, as_json=has_json)
|
||||
|
||||
if result is None:
|
||||
error(f"Secret not found: {provider}/{slug}")
|
||||
return
|
||||
|
||||
if out_file:
|
||||
content = json.dumps(result, indent=2) if has_json else str(result)
|
||||
fd = os.open(out_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
try:
|
||||
os.write(fd, content.encode("utf-8"))
|
||||
finally:
|
||||
os.close(fd)
|
||||
success(f"Wrote {provider}/{slug} to {out_file}")
|
||||
else:
|
||||
value_len = len(json.dumps(result)) if has_json else len(str(result))
|
||||
success(f"{provider}/{slug}: set ({value_len} chars)")
|
||||
|
||||
|
||||
def fetch_api_key(provider: str = "openrouter"):
|
||||
"""Retrieve a validated API key for a provider from secrets."""
|
||||
return keys.get_api_key(provider)
|
||||
@@ -194,6 +260,7 @@ def print_help():
|
||||
epilog="""
|
||||
COMMANDS:
|
||||
get-key - Retrieve API key for a provider
|
||||
get-secret - Read secret from provider store
|
||||
validate - Validate API key
|
||||
list-providers - List available providers
|
||||
init - Initialize .env template
|
||||
@@ -206,6 +273,21 @@ EXAMPLES:
|
||||
# Get key for provider
|
||||
drone @api get-key openrouter
|
||||
|
||||
# Check if a secret exists (masked summary, no raw value)
|
||||
drone @api get-secret telegram/bot
|
||||
|
||||
# Write secret to a protected file
|
||||
drone @api get-secret telegram/bot --out /tmp/token.txt
|
||||
|
||||
# Write secret as JSON to a protected file
|
||||
drone @api get-secret telegram/bot --out /tmp/bot.json --json
|
||||
|
||||
# List secrets for a provider
|
||||
drone @api get-secret telegram --list
|
||||
|
||||
# Programmatic access (in-process, no stdout):
|
||||
# from aipass.api.apps.modules.secrets import get_secret
|
||||
|
||||
# Validate key
|
||||
drone @api validate openrouter
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: secrets.py
|
||||
# Description: Secrets Module — cross-branch in-process door
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-06-15
|
||||
# Modified: 2026-06-15
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Secrets Module
|
||||
|
||||
Cross-branch in-process API for reading secrets from the provider store.
|
||||
Consumers import directly instead of shelling out to the CLI.
|
||||
|
||||
Functions:
|
||||
get_secret() - Read a secret by provider/slug
|
||||
list_secrets() - List available slugs for a provider
|
||||
handle_command() - Route CLI commands (seedgo module discovery)
|
||||
"""
|
||||
|
||||
import sys
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from aipass.prax import logger # noqa: F401 — seedgo imports standard
|
||||
from aipass.cli.apps.modules import console, header
|
||||
from aipass.api.apps.handlers.json import json_handler
|
||||
from aipass.api.apps.handlers.auth import secrets as _handler
|
||||
|
||||
|
||||
def print_introspection():
|
||||
"""Show module introspection - connected handlers and capabilities"""
|
||||
console.print()
|
||||
header("Secrets Module Introspection")
|
||||
console.print()
|
||||
|
||||
console.print("[cyan]Purpose:[/cyan] Cross-branch secrets access (in-process)")
|
||||
console.print()
|
||||
|
||||
console.print("[cyan]Connected Handlers:[/cyan]")
|
||||
console.print(" • api.apps.handlers.auth.secrets")
|
||||
console.print()
|
||||
|
||||
console.print("[cyan]Available Workflows:[/cyan]")
|
||||
console.print(" • get_secret() - Read secret by provider/slug")
|
||||
console.print(" • list_secrets() - List slugs for a provider")
|
||||
console.print()
|
||||
|
||||
|
||||
def print_help():
|
||||
"""Print help output for secrets module"""
|
||||
print_introspection()
|
||||
|
||||
|
||||
def handle_command(command: str, args: List[str]) -> bool:
|
||||
"""
|
||||
Handle secrets commands (module discovery hook).
|
||||
|
||||
This module does not own any CLI commands — get-secret is routed
|
||||
through api_key.py. This exists for seedgo module discovery only.
|
||||
|
||||
Args:
|
||||
command: Command name
|
||||
args: Command arguments
|
||||
|
||||
Returns:
|
||||
False — no commands handled here
|
||||
"""
|
||||
if not args:
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
if args[0] in ("--help", "-h", "help"):
|
||||
print_help()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_secret(provider: str, slug: str, as_json: bool = False) -> Optional[Any]:
|
||||
"""
|
||||
Read a secret from the provider store.
|
||||
|
||||
This is the sanctioned cross-branch import path. Consumers call this
|
||||
instead of shelling out to 'drone @api get-secret'.
|
||||
|
||||
Args:
|
||||
provider: Provider directory name (e.g., 'telegram', 'openrouter')
|
||||
slug: Secret identifier (without .json extension)
|
||||
as_json: If True, return full parsed dict; otherwise extract primary token
|
||||
|
||||
Returns:
|
||||
Secret value (str or dict) or None if not found
|
||||
"""
|
||||
result = _handler.get_secret(provider, slug, as_json=as_json)
|
||||
json_handler.log_operation("secrets_get", {"provider": provider, "slug": slug, "found": result is not None})
|
||||
return result
|
||||
|
||||
|
||||
def list_secrets(provider: str) -> List[str]:
|
||||
"""
|
||||
List available secret slugs for a provider.
|
||||
|
||||
Args:
|
||||
provider: Provider directory name
|
||||
|
||||
Returns:
|
||||
Sorted list of slug names
|
||||
"""
|
||||
return _handler.list_secrets(provider)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
"""Standalone execution mode"""
|
||||
args = sys.argv[1:]
|
||||
|
||||
if len(args) == 0:
|
||||
print_introspection()
|
||||
sys.exit(0)
|
||||
|
||||
if args[0] in ["--help", "-h", "help"]:
|
||||
print_help()
|
||||
sys.exit(0)
|
||||
|
||||
console.print()
|
||||
console.print(f"[red]Unknown command: {args[0]}[/red]")
|
||||
console.print()
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,493 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_secrets.py
|
||||
# Description: Tests for secrets handler and get_secret_cmd orchestrator
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-06-15
|
||||
# Modified: 2026-06-15
|
||||
# =============================================
|
||||
|
||||
"""Tests for apps/handlers/auth/secrets.py, apps/modules/secrets.py, and api_key.get_secret_cmd.
|
||||
|
||||
Tests — handlers/auth/secrets.py (get_secret, list_secrets):
|
||||
- get_secret: JSON token extraction via _TOKEN_KEYS
|
||||
- get_secret: as_json returns full parsed dict
|
||||
- get_secret: raw file fallback returns stripped content
|
||||
- get_secret: missing provider directory returns None
|
||||
- get_secret: missing slug file returns None
|
||||
- get_secret: malformed JSON returns None
|
||||
- get_secret: unreadable file (OSError) returns None
|
||||
- get_secret: JSON with no matching token key returns json.dumps of dict
|
||||
- list_secrets: returns sorted slug names, strips .json extension
|
||||
- list_secrets: non-existent provider returns empty list
|
||||
- list_secrets: skips dotfiles, __pycache__, directories
|
||||
|
||||
Tests — modules/secrets.py (in-process door):
|
||||
- get_secret wraps handler and logs operation
|
||||
- list_secrets wraps handler
|
||||
|
||||
Tests — api_key.py (get_secret_cmd — hardened, no raw values to stdout):
|
||||
- get_secret_cmd default prints masked summary only
|
||||
- get_secret_cmd --out writes to file with 0o600 perms
|
||||
- get_secret_cmd --out --json writes JSON to file
|
||||
- get_secret_cmd --list prints slug names
|
||||
- get_secret_cmd no args calls error()
|
||||
- get_secret_cmd provider only (no --list) calls error()
|
||||
- get_secret_cmd only flags calls error()
|
||||
- get_secret_cmd not found calls error()
|
||||
- get_secret_cmd --out missing path calls error()
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from aipass.api.apps.modules.api_key import handle_command as _hc # noqa: F401 — seedgo test_coverage detection
|
||||
from aipass.api.apps.modules.secrets import handle_command as _hc2 # noqa: F401 — seedgo test_coverage detection
|
||||
from aipass.api.apps.handlers.auth.secrets import (
|
||||
get_secret,
|
||||
list_secrets,
|
||||
)
|
||||
from aipass.api.apps.modules.api_key import get_secret_cmd
|
||||
from aipass.api.apps.modules import secrets as secrets_module
|
||||
|
||||
|
||||
# Patch targets
|
||||
PATCH_SECRETS_BASE = "aipass.api.apps.handlers.auth.secrets.SECRETS_BASE"
|
||||
PATCH_JSON_HANDLER = "aipass.api.apps.handlers.auth.secrets.json_handler"
|
||||
PATCH_LOGGER = "aipass.api.apps.handlers.auth.secrets.logger"
|
||||
|
||||
PATCH_CMD_SECRETS = "aipass.api.apps.modules.api_key.secrets"
|
||||
PATCH_CMD_ERROR = "aipass.api.apps.modules.api_key.error"
|
||||
PATCH_CMD_SUCCESS = "aipass.api.apps.modules.api_key.success"
|
||||
PATCH_CMD_CONSOLE = "aipass.api.apps.modules.api_key.console"
|
||||
PATCH_CMD_JSON_HANDLER = "aipass.api.apps.modules.api_key.json_handler"
|
||||
|
||||
PATCH_MOD_HANDLER = "aipass.api.apps.modules.secrets._handler"
|
||||
PATCH_MOD_JSON_HANDLER = "aipass.api.apps.modules.secrets.json_handler"
|
||||
|
||||
|
||||
# =============================================
|
||||
# get_secret
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestGetSecret:
|
||||
"""Verifies secret retrieval under various conditions."""
|
||||
|
||||
def test_json_token_extraction(self, tmp_path: Path) -> None:
|
||||
"""JSON file with a known token key returns the extracted token string."""
|
||||
provider_dir = tmp_path / "telegram"
|
||||
provider_dir.mkdir()
|
||||
secret_file = provider_dir / "bot.json"
|
||||
secret_file.write_text(json.dumps({"bot_token": "abc123", "extra": "stuff"}))
|
||||
|
||||
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER):
|
||||
result = get_secret("telegram", "bot")
|
||||
|
||||
assert result == "abc123"
|
||||
|
||||
def test_json_token_extraction_searches_keys_in_order(self, tmp_path: Path) -> None:
|
||||
"""Token extraction tries _TOKEN_KEYS in order; first match wins."""
|
||||
provider_dir = tmp_path / "discord"
|
||||
provider_dir.mkdir()
|
||||
# Has both 'api_key' and 'token'; api_key comes first in _TOKEN_KEYS
|
||||
secret_file = provider_dir / "creds.json"
|
||||
secret_file.write_text(json.dumps({"token": "second", "api_key": "first"}))
|
||||
|
||||
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER):
|
||||
result = get_secret("discord", "creds")
|
||||
|
||||
assert result == "first"
|
||||
|
||||
def test_as_json_returns_full_dict(self, tmp_path: Path) -> None:
|
||||
"""as_json=True returns the full parsed dictionary."""
|
||||
provider_dir = tmp_path / "telegram"
|
||||
provider_dir.mkdir()
|
||||
data = {"bot_token": "abc123", "webhook_url": "https://example.com"}
|
||||
(provider_dir / "bot.json").write_text(json.dumps(data))
|
||||
|
||||
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER):
|
||||
result = get_secret("telegram", "bot", as_json=True)
|
||||
|
||||
assert result == data
|
||||
|
||||
def test_raw_file_fallback(self, tmp_path: Path) -> None:
|
||||
"""When no JSON file exists, falls back to raw file and returns stripped content."""
|
||||
provider_dir = tmp_path / "generic"
|
||||
provider_dir.mkdir()
|
||||
(provider_dir / "api_token").write_text(" raw-secret-value \n")
|
||||
|
||||
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER):
|
||||
result = get_secret("generic", "api_token")
|
||||
|
||||
assert result == "raw-secret-value"
|
||||
|
||||
def test_missing_provider_directory(self, tmp_path: Path) -> None:
|
||||
"""Non-existent provider directory returns None."""
|
||||
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER):
|
||||
result = get_secret("nonexistent", "bot")
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_missing_slug_file(self, tmp_path: Path) -> None:
|
||||
"""Provider exists but slug file does not -- returns None."""
|
||||
provider_dir = tmp_path / "telegram"
|
||||
provider_dir.mkdir()
|
||||
|
||||
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER):
|
||||
result = get_secret("telegram", "missing_slug")
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_malformed_json_returns_none(self, tmp_path: Path) -> None:
|
||||
"""Malformed JSON file returns None and logs a warning."""
|
||||
provider_dir = tmp_path / "telegram"
|
||||
provider_dir.mkdir()
|
||||
(provider_dir / "bot.json").write_text("{not valid json")
|
||||
|
||||
mock_logger = MagicMock()
|
||||
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER, mock_logger):
|
||||
result = get_secret("telegram", "bot")
|
||||
|
||||
assert result is None
|
||||
mock_logger.warning.assert_called()
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform == "win32",
|
||||
reason="chmod(0o000) does not make a file unreadable to its owner on Windows",
|
||||
)
|
||||
def test_unreadable_file_returns_none(self, tmp_path: Path) -> None:
|
||||
"""OSError when reading file returns None."""
|
||||
provider_dir = tmp_path / "telegram"
|
||||
provider_dir.mkdir()
|
||||
secret_file = provider_dir / "bot.json"
|
||||
secret_file.write_text(json.dumps({"bot_token": "abc"}))
|
||||
# Make unreadable
|
||||
secret_file.chmod(0o000)
|
||||
|
||||
mock_logger = MagicMock()
|
||||
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER, mock_logger):
|
||||
result = get_secret("telegram", "bot")
|
||||
|
||||
# Restore permissions for cleanup
|
||||
secret_file.chmod(0o644)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_json_no_matching_token_key(self, tmp_path: Path) -> None:
|
||||
"""JSON dict with no recognized token key returns json.dumps of the dict."""
|
||||
provider_dir = tmp_path / "custom"
|
||||
provider_dir.mkdir()
|
||||
data = {"username": "admin", "host": "localhost"}
|
||||
(provider_dir / "config.json").write_text(json.dumps(data))
|
||||
|
||||
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER):
|
||||
result = get_secret("custom", "config")
|
||||
|
||||
assert result == json.dumps(data)
|
||||
|
||||
def test_json_non_dict_value(self, tmp_path: Path) -> None:
|
||||
"""JSON file containing a non-dict value (e.g., a string) returns str of it."""
|
||||
provider_dir = tmp_path / "simple"
|
||||
provider_dir.mkdir()
|
||||
(provider_dir / "token.json").write_text(json.dumps("plain-string-secret"))
|
||||
|
||||
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER):
|
||||
result = get_secret("simple", "token")
|
||||
|
||||
assert result == "plain-string-secret"
|
||||
|
||||
def test_json_preferred_over_raw(self, tmp_path: Path) -> None:
|
||||
"""When both JSON and raw files exist, JSON takes priority."""
|
||||
provider_dir = tmp_path / "dual"
|
||||
provider_dir.mkdir()
|
||||
(provider_dir / "cred.json").write_text(json.dumps({"api_key": "from-json"}))
|
||||
(provider_dir / "cred").write_text("from-raw")
|
||||
|
||||
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER):
|
||||
result = get_secret("dual", "cred")
|
||||
|
||||
assert result == "from-json"
|
||||
|
||||
def test_provider_is_file_not_dir(self, tmp_path: Path) -> None:
|
||||
"""If provider path exists but is a file (not a directory), returns None."""
|
||||
(tmp_path / "notadir").write_text("file content")
|
||||
|
||||
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER):
|
||||
result = get_secret("notadir", "slug")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
# =============================================
|
||||
# list_secrets
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestListSecrets:
|
||||
"""Verifies secret listing under various conditions."""
|
||||
|
||||
def test_returns_sorted_slugs(self, tmp_path: Path) -> None:
|
||||
"""Returns sorted slug names with .json extension stripped."""
|
||||
provider_dir = tmp_path / "telegram"
|
||||
provider_dir.mkdir()
|
||||
(provider_dir / "webhook.json").write_text("{}")
|
||||
(provider_dir / "bot.json").write_text("{}")
|
||||
(provider_dir / "raw_token").write_text("tok")
|
||||
|
||||
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_LOGGER):
|
||||
result = list_secrets("telegram")
|
||||
|
||||
assert result == ["bot", "raw_token", "webhook"]
|
||||
|
||||
def test_nonexistent_provider_returns_empty(self, tmp_path: Path) -> None:
|
||||
"""Non-existent provider returns empty list."""
|
||||
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_LOGGER):
|
||||
result = list_secrets("nonexistent")
|
||||
|
||||
assert result == []
|
||||
|
||||
def test_skips_dotfiles(self, tmp_path: Path) -> None:
|
||||
"""Entries starting with '.' are excluded."""
|
||||
provider_dir = tmp_path / "provider"
|
||||
provider_dir.mkdir()
|
||||
(provider_dir / ".hidden").write_text("secret")
|
||||
(provider_dir / "visible.json").write_text("{}")
|
||||
|
||||
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_LOGGER):
|
||||
result = list_secrets("provider")
|
||||
|
||||
assert result == ["visible"]
|
||||
|
||||
def test_skips_pycache(self, tmp_path: Path) -> None:
|
||||
"""__pycache__ directory is excluded."""
|
||||
provider_dir = tmp_path / "provider"
|
||||
provider_dir.mkdir()
|
||||
# __pycache__ as a file (the check is name-based, not type-based for this entry)
|
||||
pycache = provider_dir / "__pycache__"
|
||||
pycache.mkdir()
|
||||
(provider_dir / "real.json").write_text("{}")
|
||||
|
||||
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_LOGGER):
|
||||
result = list_secrets("provider")
|
||||
|
||||
assert result == ["real"]
|
||||
|
||||
def test_skips_directories(self, tmp_path: Path) -> None:
|
||||
"""Subdirectories (non-files) are excluded."""
|
||||
provider_dir = tmp_path / "provider"
|
||||
provider_dir.mkdir()
|
||||
(provider_dir / "subdir").mkdir()
|
||||
(provider_dir / "secret.json").write_text("{}")
|
||||
|
||||
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_LOGGER):
|
||||
result = list_secrets("provider")
|
||||
|
||||
assert result == ["secret"]
|
||||
|
||||
def test_provider_is_file_not_dir(self, tmp_path: Path) -> None:
|
||||
"""If provider path is a file instead of a directory, returns empty list."""
|
||||
(tmp_path / "notadir").write_text("file")
|
||||
|
||||
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_LOGGER):
|
||||
result = list_secrets("notadir")
|
||||
|
||||
assert result == []
|
||||
|
||||
|
||||
# =============================================
|
||||
# get_secret_cmd
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestSecretsModule:
|
||||
"""Verifies the in-process module door (apps/modules/secrets.py)."""
|
||||
|
||||
def test_get_secret_wraps_handler(self) -> None:
|
||||
"""Module get_secret delegates to handler and logs the operation."""
|
||||
mock_handler = MagicMock()
|
||||
mock_handler.get_secret.return_value = "token123"
|
||||
mock_jh = MagicMock()
|
||||
|
||||
with patch(PATCH_MOD_HANDLER, mock_handler), patch(PATCH_MOD_JSON_HANDLER, mock_jh):
|
||||
result = secrets_module.get_secret("telegram", "bot")
|
||||
|
||||
assert result == "token123"
|
||||
mock_handler.get_secret.assert_called_once_with("telegram", "bot", as_json=False)
|
||||
mock_jh.log_operation.assert_called_once()
|
||||
|
||||
def test_get_secret_as_json(self) -> None:
|
||||
"""Module get_secret passes as_json through to handler."""
|
||||
mock_handler = MagicMock()
|
||||
data = {"bot_token": "abc"}
|
||||
mock_handler.get_secret.return_value = data
|
||||
mock_jh = MagicMock()
|
||||
|
||||
with patch(PATCH_MOD_HANDLER, mock_handler), patch(PATCH_MOD_JSON_HANDLER, mock_jh):
|
||||
result = secrets_module.get_secret("telegram", "bot", as_json=True)
|
||||
|
||||
assert result == data
|
||||
mock_handler.get_secret.assert_called_once_with("telegram", "bot", as_json=True)
|
||||
|
||||
def test_get_secret_not_found_logs(self) -> None:
|
||||
"""Module get_secret logs even when handler returns None."""
|
||||
mock_handler = MagicMock()
|
||||
mock_handler.get_secret.return_value = None
|
||||
mock_jh = MagicMock()
|
||||
|
||||
with patch(PATCH_MOD_HANDLER, mock_handler), patch(PATCH_MOD_JSON_HANDLER, mock_jh):
|
||||
result = secrets_module.get_secret("telegram", "missing")
|
||||
|
||||
assert result is None
|
||||
log_call = mock_jh.log_operation.call_args
|
||||
assert log_call[0][1]["found"] is False
|
||||
|
||||
def test_list_secrets_wraps_handler(self) -> None:
|
||||
"""Module list_secrets delegates to handler."""
|
||||
mock_handler = MagicMock()
|
||||
mock_handler.list_secrets.return_value = ["bot", "webhook"]
|
||||
|
||||
with patch(PATCH_MOD_HANDLER, mock_handler):
|
||||
result = secrets_module.list_secrets("telegram")
|
||||
|
||||
assert result == ["bot", "webhook"]
|
||||
mock_handler.list_secrets.assert_called_once_with("telegram")
|
||||
|
||||
|
||||
# =============================================
|
||||
# get_secret_cmd (hardened — no raw values to stdout)
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestGetSecretCmd:
|
||||
"""Verifies the hardened get_secret_cmd (DPLAN-0211: no raw secrets to stdout)."""
|
||||
|
||||
@patch(PATCH_CMD_JSON_HANDLER)
|
||||
@patch(PATCH_CMD_SECRETS)
|
||||
@patch(PATCH_CMD_SUCCESS)
|
||||
def test_default_prints_masked_summary(self, mock_success, mock_secrets, mock_jh) -> None:
|
||||
"""Default (no flags) prints masked summary, never the raw value."""
|
||||
mock_secrets.get_secret.return_value = "my-secret-token-value"
|
||||
|
||||
get_secret_cmd(["telegram/bot"])
|
||||
|
||||
mock_secrets.get_secret.assert_called_once_with("telegram", "bot", as_json=False)
|
||||
msg = mock_success.call_args[0][0]
|
||||
assert "telegram/bot" in msg
|
||||
assert "set" in msg
|
||||
assert "chars" in msg
|
||||
assert "my-secret-token-value" not in msg
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="File permission checks are POSIX-only")
|
||||
@patch(PATCH_CMD_JSON_HANDLER)
|
||||
@patch(PATCH_CMD_SECRETS)
|
||||
@patch(PATCH_CMD_SUCCESS)
|
||||
def test_out_writes_file_with_0600(self, mock_success, mock_secrets, mock_jh, tmp_path: Path) -> None:
|
||||
"""--out writes secret value to file with 0o600 permissions."""
|
||||
mock_secrets.get_secret.return_value = "secret-token-here"
|
||||
out_file = str(tmp_path / "token.txt")
|
||||
|
||||
get_secret_cmd(["telegram/bot", "--out", out_file])
|
||||
|
||||
assert Path(out_file).exists()
|
||||
assert Path(out_file).read_text(encoding="utf-8") == "secret-token-here"
|
||||
file_mode = stat.S_IMODE(os.stat(out_file).st_mode)
|
||||
assert file_mode == 0o600
|
||||
msg = mock_success.call_args[0][0]
|
||||
assert out_file in msg
|
||||
assert "secret-token-here" not in msg
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="File permission checks are POSIX-only")
|
||||
@patch(PATCH_CMD_JSON_HANDLER)
|
||||
@patch(PATCH_CMD_SECRETS)
|
||||
@patch(PATCH_CMD_SUCCESS)
|
||||
def test_out_json_writes_json_file(self, mock_success, mock_secrets, mock_jh, tmp_path: Path) -> None:
|
||||
"""--out --json writes JSON-formatted secret to file."""
|
||||
data = {"bot_token": "abc123", "allowed": [1, 2]}
|
||||
mock_secrets.get_secret.return_value = data
|
||||
out_file = str(tmp_path / "bot.json")
|
||||
|
||||
get_secret_cmd(["telegram/bot", "--out", out_file, "--json"])
|
||||
|
||||
content = Path(out_file).read_text(encoding="utf-8")
|
||||
assert json.loads(content) == data
|
||||
file_mode = stat.S_IMODE(os.stat(out_file).st_mode)
|
||||
assert file_mode == 0o600
|
||||
|
||||
@patch(PATCH_CMD_JSON_HANDLER)
|
||||
@patch(PATCH_CMD_SECRETS)
|
||||
@patch(PATCH_CMD_CONSOLE)
|
||||
def test_list_prints_slugs(self, mock_console, mock_secrets, mock_jh) -> None:
|
||||
"""--list prints slug names via console.print."""
|
||||
mock_secrets.list_secrets.return_value = ["bot", "webhook"]
|
||||
|
||||
get_secret_cmd(["telegram", "--list"])
|
||||
|
||||
mock_secrets.list_secrets.assert_called_once_with("telegram")
|
||||
calls = [c for c in mock_console.print.call_args_list if c[0][0] in ("bot", "webhook")]
|
||||
assert len(calls) == 2
|
||||
|
||||
@patch(PATCH_CMD_JSON_HANDLER)
|
||||
@patch(PATCH_CMD_ERROR)
|
||||
def test_no_args_calls_error(self, mock_error, mock_jh) -> None:
|
||||
"""Empty args list calls error() with usage message."""
|
||||
get_secret_cmd([])
|
||||
|
||||
mock_error.assert_called_once()
|
||||
assert "Usage" in mock_error.call_args[0][0]
|
||||
|
||||
@patch(PATCH_CMD_JSON_HANDLER)
|
||||
@patch(PATCH_CMD_ERROR)
|
||||
def test_provider_only_without_list_calls_error(self, mock_error, mock_jh) -> None:
|
||||
"""Single provider name without --list flag calls error() with format message."""
|
||||
get_secret_cmd(["telegram"])
|
||||
|
||||
mock_error.assert_called_once()
|
||||
|
||||
@patch(PATCH_CMD_JSON_HANDLER)
|
||||
@patch(PATCH_CMD_ERROR)
|
||||
def test_only_flags_no_positional_args_calls_error(self, mock_error, mock_jh) -> None:
|
||||
"""Only flags (no positional args after stripping) calls error()."""
|
||||
get_secret_cmd(["--json"])
|
||||
|
||||
mock_error.assert_called_once()
|
||||
assert "Usage" in mock_error.call_args[0][0]
|
||||
|
||||
@patch(PATCH_CMD_JSON_HANDLER)
|
||||
@patch(PATCH_CMD_SECRETS)
|
||||
@patch(PATCH_CMD_ERROR)
|
||||
def test_secret_not_found_calls_error(self, mock_error, mock_secrets, mock_jh) -> None:
|
||||
"""When get_secret returns None, error() is called."""
|
||||
mock_secrets.get_secret.return_value = None
|
||||
|
||||
get_secret_cmd(["telegram/bot"])
|
||||
|
||||
mock_error.assert_called_once()
|
||||
assert "not found" in mock_error.call_args[0][0].lower()
|
||||
|
||||
@patch(PATCH_CMD_JSON_HANDLER)
|
||||
@patch(PATCH_CMD_ERROR)
|
||||
def test_out_missing_path_calls_error(self, mock_error, mock_jh) -> None:
|
||||
"""--out without a file path argument calls error()."""
|
||||
get_secret_cmd(["telegram/bot", "--out"])
|
||||
|
||||
mock_error.assert_called_once()
|
||||
assert "--out" in mock_error.call_args[0][0]
|
||||
|
||||
@patch(PATCH_CMD_JSON_HANDLER)
|
||||
@patch(PATCH_CMD_SECRETS)
|
||||
@patch(PATCH_CMD_CONSOLE)
|
||||
def test_list_empty_provider(self, mock_console, mock_secrets, mock_jh) -> None:
|
||||
"""--list with provider that has no secrets prints nothing."""
|
||||
mock_secrets.list_secrets.return_value = []
|
||||
|
||||
get_secret_cmd(["empty_provider", "--list"])
|
||||
|
||||
mock_secrets.list_secrets.assert_called_once_with("empty_provider")
|
||||
@@ -0,0 +1,3 @@
|
||||
# Branch Prompt
|
||||
|
||||
AI context for `BACKUP`. The `aipass_local_prompt.md` file is injected every turn, telling the AI who you are and how to work in your branch.
|
||||
@@ -0,0 +1,76 @@
|
||||
# BACKUP — Branch Prompt
|
||||
|
||||
*Injected every turn. Breadcrumbs only — details in README, --help, .trinity/ memories, STATUS.local.md.*
|
||||
|
||||
## Identity
|
||||
|
||||
You are BACKUP — standalone backup system providing project-owned, local-first backups for any directory on the PC.
|
||||
|
||||
## What I Do
|
||||
|
||||
- Snapshot backups (full mirror copy of a project)
|
||||
- Versioned backups (incremental, timestamped with automatic pruning)
|
||||
- Project registration and @name resolution
|
||||
- Ignore pattern management (gitignore-style via .backupignore)
|
||||
- Backup status and changelog tracking per project
|
||||
|
||||
## Key Commands
|
||||
|
||||
```
|
||||
drone @backup register <path> [--name <name>] # Register a project for backup
|
||||
drone @backup snapshot <path|@name> # Full mirror backup
|
||||
drone @backup versioned <path|@name> # Incremental timestamped backup
|
||||
drone @backup all <path|@name> # Snapshot + versioned in sequence
|
||||
drone @backup status <path|@name> # Show backup info and history
|
||||
drone @backup --version # Show version
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
apps/
|
||||
├── backup.py # Entry point (auto-discovery router)
|
||||
├── modules/
|
||||
│ ├── register.py # Project registration + @name resolution
|
||||
│ ├── snapshot.py # Full mirror backup
|
||||
│ ├── versioned.py # Incremental timestamped backup
|
||||
│ ├── all.py # Snapshot + versioned orchestration
|
||||
│ ├── status.py # Backup status display
|
||||
│ ├── settings.py # Settings UI (stub — low priority)
|
||||
│ ├── drive_sync.py # Drive sync (stub — DPLAN-003)
|
||||
│ ├── drive_stats.py # Drive stats (stub)
|
||||
│ ├── drive_test.py # Drive test (stub)
|
||||
│ └── drive_clear.py # Drive clear (stub)
|
||||
└── handlers/
|
||||
├── copy/ # File copying (snapshot + versioned)
|
||||
├── diff/ # Diff generation
|
||||
├── ignore/ # .backupignore patterns + whitelist
|
||||
├── json/ # JSON persistence, atomic writes, ops log
|
||||
├── path/ # Backup path building
|
||||
├── project/ # Config, registry, setup (.backup_system/)
|
||||
├── report/ # Result formatting
|
||||
├── scan/ # Directory walking + filtering
|
||||
├── state/ # Changelog, metadata, timestamps
|
||||
├── drive/ # Google Drive handlers (stubs)
|
||||
└── ui/ # Settings window (stub)
|
||||
```
|
||||
|
||||
## Integration
|
||||
|
||||
- **Depends on:** @prax for logging, @cli for Rich console output
|
||||
- **Serves:** Any project on the PC — backups are project-owned (.backup_system/ in target root)
|
||||
|
||||
## Working Habits
|
||||
|
||||
- Project-owned design: .backup_system/ and .backupignore live in the TARGET project, not centrally
|
||||
- Normal citizen namespace: uses `from aipass.backup.apps.modules.*` / `from aipass.backup.apps.handlers.*`
|
||||
- Entry point sets AIPASS_BRANCH_NAME env var for Prax
|
||||
- BUILTIN_IGNORES in patterns.py is the single source for default ignore patterns
|
||||
|
||||
## Known Gotchas
|
||||
|
||||
- `drone @backup` only resolves from within the Backup-System project tree (drone CWD limitation)
|
||||
- Direct invocation via absolute python path works from anywhere
|
||||
- handlers/__init__.py has an access guard that blocks cross-branch imports — uses path-based check, not hardcoded module name
|
||||
- json_handler.log_operation() writes to branch-root logs/operations.jsonl — path-depth must match branch location
|
||||
- Drive handlers are intentional stubs (DPLAN-003 deferred)
|
||||
@@ -0,0 +1,5 @@
|
||||
# Claude Code Settings
|
||||
|
||||
Claude Code configuration for `BACKUP`.
|
||||
|
||||
Contains `settings.local.json` with permission rules. Most branches are denied raw git commands and must use `drone @git` instead.
|
||||
@@ -0,0 +1,14 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.env
|
||||
*.egg-info/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
dist/
|
||||
build/
|
||||
*.log
|
||||
*.tmp
|
||||
*.swp
|
||||
@@ -0,0 +1,5 @@
|
||||
# Standards Bypass
|
||||
|
||||
Seedgo audit bypass config for `BACKUP`.
|
||||
|
||||
When an audit flags a false positive that doesn't apply to your architecture, add a bypass entry in `bypass.json` with a reason explaining why it's justified.
|
||||
@@ -0,0 +1,184 @@
|
||||
{
|
||||
"metadata": {
|
||||
"version": "1.0.0",
|
||||
"created": "2026-04-16",
|
||||
"description": "Standards bypass configuration for this branch"
|
||||
},
|
||||
"bypass": [
|
||||
{
|
||||
"file": "tests/conftest.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test infrastructure lives in tests/, not in apps/ 3-layer structure",
|
||||
"pattern": "File not in standard 3-layer structure"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_json_handler.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test file lives in tests/, not in apps/ 3-layer structure",
|
||||
"pattern": "File not in standard 3-layer structure"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_cli_routing.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test file lives in tests/, not in apps/ 3-layer structure",
|
||||
"pattern": "File not in standard 3-layer structure"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_handlers_filesystem.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test file lives in tests/, not in apps/ 3-layer structure",
|
||||
"pattern": "File not in standard 3-layer structure"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_handlers_filesystem.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Unit tests must import handlers directly to test them",
|
||||
"pattern": "Handler imported directly"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_error_resilience.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test file lives in tests/, not in apps/ 3-layer structure",
|
||||
"pattern": "File not in standard 3-layer structure"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_drive_mocked.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test file lives in tests/, not in apps/ 3-layer structure",
|
||||
"pattern": "File not in standard 3-layer structure"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_snapshot_fidelity.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test file lives in tests/, not in apps/ 3-layer structure",
|
||||
"pattern": "File not in standard 3-layer structure"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_snapshot_fidelity.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Unit tests must import handlers directly to test them",
|
||||
"pattern": "Handler imported directly"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_versioned_engine.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test file lives in tests/, not in apps/ 3-layer structure",
|
||||
"pattern": "File not in standard 3-layer structure"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_versioned_engine.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Unit tests must import handlers directly to test them",
|
||||
"pattern": "Handler imported directly"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_versioned_engine.py",
|
||||
"standard": "trigger",
|
||||
"reason": "Test uses .unlink() to simulate deleted source \u2014 test infrastructure, not a real event",
|
||||
"pattern": ".unlink() file deletion"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_drive_pipeline.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test file lives in tests/, not in apps/ 3-layer structure",
|
||||
"pattern": "File not in standard 3-layer structure"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_drive_pipeline.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Unit tests must import handlers directly to test them",
|
||||
"pattern": "Handler imported directly"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_ignore_pathspec.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Test file lives in tests/, not in apps/ 3-layer structure",
|
||||
"pattern": "File not in standard 3-layer structure"
|
||||
},
|
||||
{
|
||||
"file": "tests/test_ignore_pathspec.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "Unit tests must import handlers directly to test them",
|
||||
"pattern": "Handler imported directly"
|
||||
},
|
||||
{
|
||||
"standard": "json_handler",
|
||||
"reason": "Backup has a log-only json_handler fork (JSONL append to logs/operations.jsonl). Architecture does not use module JSON pattern — backup manages files, not branch state. Pending migration decision."
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/drive/client.py",
|
||||
"standard": "handlers",
|
||||
"reason": "Auth routing requires importing @api gateway module -- per Phase 4 spec",
|
||||
"pattern": "Handler imports modules"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/drive/client.py",
|
||||
"standard": "diagnostics",
|
||||
"reason": "Type errors from dynamic import guard for Google API -- get_drive_service returns object, Drive API methods unresolvable at static analysis time",
|
||||
"pattern": "type errors"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/drive/upload.py",
|
||||
"standard": "diagnostics",
|
||||
"reason": "googleapiclient.http is a runtime dependency not installed in dev -- guarded by try/except ImportError",
|
||||
"pattern": "could not be resolved"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/drive/client.py",
|
||||
"standard": "unused_function",
|
||||
"reason": "Internal helpers called at runtime by upload handler -- not statically reachable from module layer",
|
||||
"pattern": "unused function"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/drive/tracker.py",
|
||||
"standard": "unused_function",
|
||||
"reason": "clean_tracker is called during sync when limit=0 -- runtime path not statically reachable",
|
||||
"pattern": "unused function"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/path/builder.py",
|
||||
"standard": "unused_function",
|
||||
"reason": "Legacy path builders (build_versioned_path, build_log_dir, build_drive_path) kept for backward compat and future use",
|
||||
"pattern": "unused function"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/project/config.py",
|
||||
"standard": "unused_function",
|
||||
"reason": "save_project_config is public API surface for settings module (deferred)",
|
||||
"pattern": "unused function"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/project/registry.py",
|
||||
"standard": "unused_function",
|
||||
"reason": "list_projects is public API surface for status/discovery commands",
|
||||
"pattern": "unused function"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/report/formatter.py",
|
||||
"standard": "unused_function",
|
||||
"reason": "format_result is public API surface called by CLI display layer",
|
||||
"pattern": "unused function"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/report/result.py",
|
||||
"standard": "unused_function",
|
||||
"reason": "new_result factory is public API surface for result creation",
|
||||
"pattern": "unused function"
|
||||
}
|
||||
],
|
||||
"notes": {
|
||||
"usage": "Add entries to bypass specific seedgo standard violations",
|
||||
"example": {
|
||||
"file": "apps/example.py",
|
||||
"standard": "imports",
|
||||
"reason": "Legacy import required for compatibility"
|
||||
},
|
||||
"fields": {
|
||||
"file": "Relative path to the file",
|
||||
"standard": "Which standard to bypass (imports, cli, naming, etc.)",
|
||||
"lines": "Optional array of line numbers",
|
||||
"pattern": "Optional regex pattern to match",
|
||||
"reason": "Why this bypass exists"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
# BACKUP
|
||||
|
||||
## Startup
|
||||
|
||||
On any greeting, silently read these files 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`
|
||||
**Check:** If `.ai_mail.local/inbox.json` exists, read it. Process any mail.
|
||||
**Run:** `git status`
|
||||
|
||||
## Identity
|
||||
|
||||
You are **BACKUP** — an AIPass citizen.
|
||||
|
||||
- **Module:** `aipass.backup`
|
||||
- **Role:**
|
||||
- **Purpose:** New agent - purpose TBD
|
||||
|
||||
## Memories
|
||||
|
||||
Update `.trinity/` at natural breakpoints, after milestones, and on `/memo`.
|
||||
|
||||
- `local.json` — Session history, key learnings, active tasks
|
||||
- `observations.json` — Collaboration patterns, insights
|
||||
- `passport.json` — Identity (rarely changes)
|
||||
|
||||
## AIPass Context
|
||||
|
||||
This branch is part of the AIPass multi-agent framework. Key concepts:
|
||||
|
||||
- **Branch** — your directory (`src/aipass/backup/`). Your home.
|
||||
- **Citizen** — the identity that lives in a branch. Has a passport, memories, mailbox.
|
||||
- **Agent** — a disposable worker spawned for a task. No passport, no memory.
|
||||
|
||||
## Commands
|
||||
|
||||
```
|
||||
drone systems # List available infrastructure
|
||||
drone @ai_mail inbox # Check mailbox
|
||||
drone @ai_mail send @branch "Subject" "Body" # Send mail
|
||||
drone @seedgo audit @backup # Run standards audit
|
||||
```
|
||||
@@ -0,0 +1,81 @@
|
||||
# BACKUP
|
||||
|
||||
**Purpose:** Standalone backup system — project-owned, local-first backups for any directory
|
||||
**Module:** `aipass.backup`
|
||||
**Version:** 1.0.0
|
||||
**Created:** 2026-04-16
|
||||
**Last Updated:** 2026-05-03
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
### What I Do
|
||||
|
||||
- Back up any project directory on the system (not just AIPass projects)
|
||||
- Each project owns its backup config (`.backup/`) and ignore patterns (`.backupignore`)
|
||||
- Snapshot mode: full mirror copy
|
||||
- Versioned mode: incremental timestamped backups with automatic pruning
|
||||
- Project registry for name-based lookups (`backup snapshot @AIPass`)
|
||||
|
||||
### How I Work
|
||||
- **Entry Point:** `apps/backup.py`
|
||||
- **Pattern:** Auto-discovers and routes to modules
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
apps/
|
||||
├── backup.py # Entry point (auto-discovery router)
|
||||
├── modules/
|
||||
│ ├── all.py # Snapshot + versioned orchestration
|
||||
│ ├── display.py # Rich CLI rendering (used by snapshot/versioned/all)
|
||||
│ ├── drive_clear.py # Drive clear (stub — DPLAN-003)
|
||||
│ ├── drive_stats.py # Drive stats (stub — DPLAN-003)
|
||||
│ ├── drive_sync.py # Drive sync (stub — DPLAN-003)
|
||||
│ ├── drive_check.py # Drive check (stub — DPLAN-003)
|
||||
│ ├── register.py # Project registration + @name resolution
|
||||
│ ├── restore.py # Version discovery + file restoration
|
||||
│ ├── settings.py # Settings UI (stub)
|
||||
│ ├── snapshot.py # Full mirror backup
|
||||
│ ├── status.py # Backup status display
|
||||
│ └── versioned.py # Incremental timestamped backup
|
||||
└── handlers/
|
||||
├── copy/ # File copying (snapshot + versioned)
|
||||
├── diff/ # Diff generation (stub)
|
||||
├── drive/ # Google Drive handlers (stubs)
|
||||
├── ignore/ # .backupignore patterns + whitelist
|
||||
├── json/ # JSON persistence, atomic writes, ops log
|
||||
├── path/ # Backup path building
|
||||
├── project/ # Config, registry, setup (.backup/)
|
||||
├── report/ # Result formatting
|
||||
├── scan/ # Directory walking + filtering
|
||||
├── state/ # Changelog, metadata, timestamps
|
||||
└── ui/ # Settings window (stub)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
```
|
||||
backup register <path> [--name <name>] # Register a project for backup
|
||||
backup snapshot <path|@name> # Full mirror backup
|
||||
backup versioned <path|@name> # Incremental timestamped backup
|
||||
backup all <path|@name> # Snapshot + versioned
|
||||
backup status <path|@name> # Show backup info and history
|
||||
backup --version # Show version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Depends On
|
||||
- @prax — logging
|
||||
- @cli — Rich console output
|
||||
|
||||
### Provides To
|
||||
- Any project on the PC — backups are project-owned (.backup/ in target root)
|
||||
@@ -0,0 +1,12 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: __init__.py - Backup package root
|
||||
# Date: 2026-04-16
|
||||
# Version: 1.0.0
|
||||
# Category: backup
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2026-04-16): Initial implementation
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Package root for the Backup system
|
||||
# =============================================
|
||||
@@ -0,0 +1,8 @@
|
||||
# Apps
|
||||
|
||||
Application layer for `BACKUP`.
|
||||
|
||||
- `backup.py` — Entry point. Auto-discovers and routes commands to modules.
|
||||
- `modules/` — Business logic and orchestration. One module per command.
|
||||
- `handlers/` — Implementation details. Called by modules, never by CLI directly.
|
||||
- `plugins/` — Scheduled tasks and extensions.
|
||||
@@ -0,0 +1,3 @@
|
||||
# BACKUP apps package
|
||||
|
||||
from . import handlers
|
||||
@@ -0,0 +1,177 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: backup.py
|
||||
# Description: BACKUP Branch — main orchestrator with auto-discovery
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-04-16
|
||||
# Modified: 2026-04-23
|
||||
# =============================================
|
||||
|
||||
"""BACKUP Branch - Main Orchestrator
|
||||
|
||||
Auto-discovery architecture:
|
||||
- Scans modules/ directory for .py files with handle_command()
|
||||
- Routes commands to discovered modules automatically
|
||||
- Accepts project paths or registered project names
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("AIPASS_BRANCH_NAME", "backup")
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.cli.apps.modules import console, header
|
||||
|
||||
VERSION = "1.0.0"
|
||||
MODULE_NAME = "backup"
|
||||
MODULES_DIR = Path(__file__).parent / "modules"
|
||||
|
||||
|
||||
def print_introspection(modules: list[Any]) -> None:
|
||||
"""Display discovered modules — the bare self-map (run with no args)."""
|
||||
console.print()
|
||||
console.print(f"[bold cyan]BACKUP[/bold cyan] v{VERSION} — project backup & drive sync")
|
||||
console.print()
|
||||
console.print(f"[yellow]Discovered Modules:[/yellow] {len(modules)}")
|
||||
console.print()
|
||||
for module in modules:
|
||||
name = module.__name__.split(".")[-1]
|
||||
doc = (module.__doc__ or "").strip().split("\n")[0]
|
||||
console.print(f" [cyan]-[/cyan] {name:20} [dim]{doc or 'No description'}[/dim]")
|
||||
console.print()
|
||||
console.print("[dim]Run 'drone @backup --help' for usage and commands[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def print_help() -> None:
|
||||
"""Display the curated Rich-formatted command reference."""
|
||||
console.print()
|
||||
header("BACKUP — project backup & drive sync")
|
||||
console.print()
|
||||
console.print("[dim]Snapshot, version, and sync project backups to a local store or remote drive.[/dim]")
|
||||
console.print()
|
||||
console.print("-" * 70)
|
||||
console.print()
|
||||
console.print("[bold cyan]USAGE:[/bold cyan]")
|
||||
console.print()
|
||||
console.print(" [dim]drone @backup <command> <project_path|@name>[/dim]")
|
||||
console.print(" [dim]drone @backup --help[/dim]")
|
||||
console.print()
|
||||
console.print("-" * 70)
|
||||
console.print()
|
||||
console.print("[bold cyan]COMMANDS:[/bold cyan]")
|
||||
console.print()
|
||||
console.print(" [green]snapshot[/green] Full mirror backup of a project")
|
||||
console.print(" [green]versioned[/green] Incremental timestamped backup")
|
||||
console.print(" [green]all[/green] Run snapshot then versioned in sequence")
|
||||
console.print(" [green]register[/green] Register a project + scaffold its .backup/")
|
||||
console.print(" [green]status[/green] Show backup info and recent history")
|
||||
console.print(" [green]settings[/green] View/edit backup settings")
|
||||
console.print(" [green]drive_sync[/green] Sync backups to the remote drive")
|
||||
console.print(" [green]drive_check[/green] Test the remote drive connection")
|
||||
console.print(" [green]drive_stats[/green] Drive usage statistics")
|
||||
console.print(" [green]drive_clear[/green] Clear backups from the remote drive")
|
||||
console.print()
|
||||
|
||||
|
||||
def discover_modules() -> list[Any]:
|
||||
"""Auto-discover modules in modules/ directory."""
|
||||
modules = []
|
||||
|
||||
if not MODULES_DIR.exists():
|
||||
return modules
|
||||
|
||||
for file_path in MODULES_DIR.glob("*.py"):
|
||||
if file_path.name.startswith("_"):
|
||||
continue
|
||||
|
||||
module_name = f"aipass.backup.apps.modules.{file_path.stem}"
|
||||
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
if hasattr(module, "handle_command"):
|
||||
modules.append(module)
|
||||
except Exception as e:
|
||||
logger.error(f"[BACKUP] Failed to load module {module_name}: {e}")
|
||||
|
||||
return modules
|
||||
|
||||
|
||||
def route_command(command: str, args: list[str], modules: list[Any]) -> bool:
|
||||
"""Route command to appropriate module."""
|
||||
for module in modules:
|
||||
try:
|
||||
if module.handle_command(command, args):
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[BACKUP] Module {module.__name__} error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point - routes commands or shows help."""
|
||||
args = sys.argv[1:]
|
||||
|
||||
if args and args[0] in ("--version", "-V"):
|
||||
console.print(f"backup {VERSION}")
|
||||
return 0
|
||||
|
||||
modules = discover_modules()
|
||||
|
||||
if len(args) == 0:
|
||||
print_introspection(modules)
|
||||
return 0
|
||||
|
||||
if args[0] in ["--help", "-h", "help"]:
|
||||
print_help()
|
||||
return 0
|
||||
|
||||
command = args[0]
|
||||
|
||||
if command == "backup" and len(args) > 1:
|
||||
from aipass.backup.apps.modules.register import resolve_project
|
||||
|
||||
target = args[1]
|
||||
project_root = resolve_project(target)
|
||||
if project_root is None:
|
||||
console.print(f"[red]Error:[/red] Cannot resolve project: {target}")
|
||||
return 1
|
||||
remaining = [project_root] + args[2:]
|
||||
mode = "snapshot"
|
||||
if "--versioned" in args:
|
||||
mode = "versioned"
|
||||
remaining = [r for r in remaining if r != "--versioned"]
|
||||
elif "--all" in args:
|
||||
mode = "all"
|
||||
remaining = [r for r in remaining if r != "--all"]
|
||||
|
||||
if route_command(mode, remaining, modules):
|
||||
return 0
|
||||
console.print(f"[red]Error:[/red] Unknown mode: {mode}")
|
||||
return 1
|
||||
|
||||
remaining = args[1:] if len(args) > 1 else []
|
||||
|
||||
if remaining and remaining[0].startswith("@"):
|
||||
from aipass.backup.apps.modules.register import resolve_project
|
||||
|
||||
resolved = resolve_project(remaining[0])
|
||||
if resolved is None:
|
||||
console.print(f"[red]Error:[/red] Cannot resolve project: {remaining[0]}")
|
||||
return 1
|
||||
remaining = [resolved] + remaining[1:]
|
||||
|
||||
if route_command(command, remaining, modules):
|
||||
return 0
|
||||
|
||||
console.print(f"[red]Unknown command:[/red] {command}")
|
||||
return 1
|
||||
|
||||
|
||||
# =============================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,5 @@
|
||||
# Handlers
|
||||
|
||||
Implementation details for `BACKUP`.
|
||||
|
||||
Handlers do the actual work. They are called by modules, never directly by the CLI. Keep business logic in modules, implementation in handlers.
|
||||
@@ -0,0 +1,88 @@
|
||||
"""BACKUP handlers package - Security protected."""
|
||||
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
|
||||
MY_BRANCH = "backup"
|
||||
_HANDLER_DIR = str(Path(__file__).resolve().parent)
|
||||
|
||||
|
||||
def _find_real_caller():
|
||||
"""Walk the stack to find the actual file that triggered this import.
|
||||
|
||||
Skips this file, importlib internals, and frozen modules.
|
||||
Returns tuple: (file_path, import_line) or (None, None).
|
||||
"""
|
||||
stack = inspect.stack()
|
||||
this_file = str(Path(__file__).resolve())
|
||||
|
||||
for frame_info in stack:
|
||||
filename = frame_info.filename
|
||||
|
||||
if this_file in str(Path(filename).resolve()):
|
||||
continue
|
||||
|
||||
if filename.startswith("<") or "importlib" in filename:
|
||||
continue
|
||||
|
||||
import_line = None
|
||||
if frame_info.code_context:
|
||||
import_line = frame_info.code_context[0].strip()
|
||||
|
||||
return str(Path(filename).resolve()), import_line
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
def _extract_branch_name(filepath: str) -> str:
|
||||
"""Extract branch name from a file path."""
|
||||
parts = Path(filepath).parts
|
||||
for i, part in enumerate(parts):
|
||||
if part == "aipass":
|
||||
if i + 1 < len(parts):
|
||||
return parts[i + 1]
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _guard_branch_access():
|
||||
"""Block cross-branch handler imports.
|
||||
|
||||
Only code from within the 'backup' branch can import these handlers.
|
||||
External branches must use aipass.backup.apps.modules instead.
|
||||
"""
|
||||
caller_file, import_line = _find_real_caller()
|
||||
|
||||
if caller_file is None:
|
||||
stack = inspect.stack()
|
||||
for frame in stack:
|
||||
if frame.filename in ("<string>", "<stdin>"):
|
||||
return
|
||||
return
|
||||
|
||||
branch_root = str(Path(_HANDLER_DIR).parents[1])
|
||||
if branch_root in caller_file.replace("\\", "/"):
|
||||
return
|
||||
|
||||
caller_branch = _extract_branch_name(caller_file)
|
||||
caller_filename = Path(caller_file).name
|
||||
blocked_import = import_line if import_line else "unknown"
|
||||
|
||||
raise ImportError(
|
||||
f"\n{'=' * 60}\n"
|
||||
f"ACCESS DENIED: Cross-branch handler import blocked\n"
|
||||
f"{'=' * 60}\n"
|
||||
f" Caller branch: {caller_branch}\n"
|
||||
f" Caller file: {caller_filename}\n"
|
||||
f" Blocked: {blocked_import}\n"
|
||||
f"\n"
|
||||
f" Handlers are internal to their branch.\n"
|
||||
f" Use the module API instead (apps/modules/).\n"
|
||||
f"\n"
|
||||
f" For full standards guide:\n"
|
||||
f" drone @seedgo handlers\n"
|
||||
f"{'=' * 60}"
|
||||
)
|
||||
|
||||
|
||||
# Run guard at import time
|
||||
_guard_branch_access()
|
||||
@@ -0,0 +1,127 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: mirror.py
|
||||
# Description: Mirror cleanup handler — removes snapshot files whose source no longer exists
|
||||
# Version: 2.0.0
|
||||
# Created: 2026-06-12
|
||||
# Modified: 2026-06-12
|
||||
# =============================================
|
||||
|
||||
"""Mirror cleanup handler — removes snapshot files whose source no longer exists."""
|
||||
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax import logger
|
||||
|
||||
from ..json import json_handler
|
||||
from ..report.result import BackupResult
|
||||
|
||||
|
||||
def _make_writable(path: Path) -> None:
|
||||
"""Best-effort chmod to make a file writable before deletion."""
|
||||
try:
|
||||
path.chmod(stat.S_IWRITE | stat.S_IREAD)
|
||||
except OSError as e:
|
||||
logger.info(f"[cleanup] Could not chmod {path}: {e}")
|
||||
|
||||
|
||||
def _should_delete(backup_file: Path, backup_path: Path, source_dir: Path) -> str | None:
|
||||
"""Return the relative path string if the file should be deleted, else None."""
|
||||
rel = backup_file.relative_to(backup_path)
|
||||
source_file = source_dir / rel
|
||||
|
||||
if source_file.exists():
|
||||
return None
|
||||
|
||||
return str(rel).replace("\\", "/")
|
||||
|
||||
|
||||
def _delete_stale_files(
|
||||
backup_path: Path,
|
||||
source_dir: Path,
|
||||
result: BackupResult,
|
||||
dry_run: bool,
|
||||
) -> None:
|
||||
"""Pass 1: delete files whose source is gone."""
|
||||
for backup_file in list(backup_path.rglob("*")):
|
||||
if not backup_file.is_file():
|
||||
continue
|
||||
try:
|
||||
rel_str = _should_delete(backup_file, backup_path, source_dir)
|
||||
if rel_str is None:
|
||||
continue
|
||||
|
||||
if dry_run:
|
||||
result.files_deleted += 1
|
||||
continue
|
||||
|
||||
_make_writable(backup_file)
|
||||
backup_file.unlink()
|
||||
result.files_deleted += 1
|
||||
except PermissionError as e:
|
||||
result.add_error(f"Permission denied deleting {backup_file}: {e}")
|
||||
logger.warning(f"[cleanup] Permission denied: {backup_file}: {e}")
|
||||
except Exception as e:
|
||||
result.add_warning(f"Error deleting {backup_file}: {e}")
|
||||
logger.warning(f"[cleanup] Error: {backup_file}: {e}")
|
||||
|
||||
|
||||
def _remove_empty_dirs(
|
||||
backup_path: Path,
|
||||
source_dir: Path,
|
||||
dry_run: bool,
|
||||
) -> None:
|
||||
"""Pass 2: remove empty directories bottom-up."""
|
||||
all_dirs = sorted(
|
||||
[d for d in backup_path.rglob("*") if d.is_dir()],
|
||||
key=lambda p: len(p.parts),
|
||||
reverse=True,
|
||||
)
|
||||
for d in all_dirs:
|
||||
try:
|
||||
if any(d.iterdir()):
|
||||
continue
|
||||
rel = d.relative_to(backup_path)
|
||||
source_d = source_dir / rel
|
||||
if not source_d.exists() and not dry_run:
|
||||
d.rmdir()
|
||||
except OSError as e:
|
||||
logger.info(f"[cleanup] Could not remove dir {d}: {e}")
|
||||
|
||||
|
||||
def cleanup_deleted_files(
|
||||
backup_path: Path,
|
||||
source_dir: Path,
|
||||
should_ignore,
|
||||
result: BackupResult,
|
||||
dry_run: bool = False,
|
||||
) -> None:
|
||||
"""Remove snapshot files whose source no longer exists.
|
||||
|
||||
Args:
|
||||
backup_path: Snapshot destination directory.
|
||||
source_dir: Original project root.
|
||||
should_ignore: Callable(Path) -> bool for ignore check.
|
||||
result: BackupResult to track deletions.
|
||||
dry_run: If True, only count what would be deleted.
|
||||
"""
|
||||
json_handler.log_operation("cleanup_started", {"backup_path": str(backup_path)})
|
||||
|
||||
if not backup_path.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
_delete_stale_files(backup_path, source_dir, result, dry_run)
|
||||
_remove_empty_dirs(backup_path, source_dir, dry_run)
|
||||
except Exception as e:
|
||||
result.add_warning(f"Cleanup scan error: {e}")
|
||||
logger.warning(f"[cleanup] Scan error: {e}")
|
||||
|
||||
json_handler.log_operation(
|
||||
"cleanup_complete",
|
||||
{"files_deleted": result.files_deleted, "dry_run": dry_run},
|
||||
)
|
||||
logger.info(f"[cleanup] Deleted {result.files_deleted} files (dry_run={dry_run})")
|
||||
|
||||
|
||||
# =============================================
|
||||
@@ -0,0 +1,162 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: snapshot.py
|
||||
# Description: Snapshot copy strategy — mirror destination tree with cleanup
|
||||
# Version: 3.0.0
|
||||
# Created: 2026-04-16
|
||||
# Modified: 2026-06-12
|
||||
# =============================================
|
||||
|
||||
"""Snapshot copy handler — mirror destination tree with cleanup."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
import pathspec
|
||||
|
||||
from aipass.prax import logger
|
||||
|
||||
from ..cleanup.mirror import cleanup_deleted_files
|
||||
from ..ignore.patterns import is_ignored
|
||||
from ..json import json_handler
|
||||
from ..report.result import BackupResult
|
||||
|
||||
|
||||
def _should_skip_mtime(abs_path: str, target: str) -> bool:
|
||||
"""Return True if source and target have identical mtime."""
|
||||
try:
|
||||
src_mtime = os.path.getmtime(abs_path)
|
||||
dst_mtime = os.path.getmtime(target)
|
||||
return src_mtime == dst_mtime
|
||||
except OSError as e:
|
||||
logger.info(f"[snapshot] mtime check failed, will recopy: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _make_target_writable(target_path: Path) -> None:
|
||||
"""Best-effort chmod to make an existing target writable before overwrite."""
|
||||
try:
|
||||
target_path.chmod(stat.S_IWRITE | stat.S_IREAD)
|
||||
except OSError as e:
|
||||
logger.warning(f"[snapshot] Could not chmod {target_path}: {e}")
|
||||
|
||||
|
||||
def _should_ignore_for_cleanup(path: Path, project_root: str, spec: pathspec.PathSpec) -> bool:
|
||||
"""Check whether a path matches the ignore spec."""
|
||||
try:
|
||||
rel = str(path.relative_to(project_root)).replace("\\", "/")
|
||||
except ValueError as e:
|
||||
logger.info(f"[snapshot] Path not relative to project root: {path}: {e}")
|
||||
return False
|
||||
return is_ignored(rel, spec)
|
||||
|
||||
|
||||
def _copy_single_file(
|
||||
abs_path: str,
|
||||
rel_path: str,
|
||||
dest_path: Path,
|
||||
errors: list[str],
|
||||
) -> int:
|
||||
"""Copy a single file to the snapshot destination, returning bytes copied.
|
||||
|
||||
Skips unchanged files (same mtime), handles read-only targets.
|
||||
Returns bytes copied (0 if skipped or errored).
|
||||
"""
|
||||
target = str(dest_path / rel_path)
|
||||
|
||||
# Long-path guard
|
||||
if len(target) > 260:
|
||||
logger.warning(f"Path too long (>260 chars), skipping: {rel_path}")
|
||||
errors.append(f"{rel_path}: path too long (>260 chars)")
|
||||
return -1 # signal: skipped due to error
|
||||
|
||||
target_path = Path(target)
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Skip if target exists and has same mtime
|
||||
if target_path.exists():
|
||||
if _should_skip_mtime(abs_path, target):
|
||||
return -1 # signal: skipped, unchanged
|
||||
_make_target_writable(target_path)
|
||||
|
||||
shutil.copy2(abs_path, target)
|
||||
return os.path.getsize(abs_path)
|
||||
|
||||
|
||||
def _run_mirror_cleanup(dest_path: Path, project_root: str, spec: pathspec.PathSpec) -> int:
|
||||
"""Run mirror-delete cleanup on an existing snapshot destination."""
|
||||
cleanup_result = BackupResult(mode="snapshot", project_root=project_root)
|
||||
cleanup_deleted_files(
|
||||
dest_path,
|
||||
Path(project_root),
|
||||
lambda p: _should_ignore_for_cleanup(p, project_root, spec),
|
||||
cleanup_result,
|
||||
)
|
||||
return cleanup_result.files_deleted
|
||||
|
||||
|
||||
def copy_snapshot(
|
||||
files: list[tuple[str, str]],
|
||||
dest: str,
|
||||
project_root: str,
|
||||
spec: pathspec.PathSpec,
|
||||
on_progress=None,
|
||||
) -> dict:
|
||||
"""Copy files into a snapshot destination with mirror-delete.
|
||||
|
||||
Args:
|
||||
files: List of (absolute_path, relative_path) tuples.
|
||||
dest: Absolute destination directory path.
|
||||
project_root: Project root for cleanup source reference.
|
||||
spec: Compiled PathSpec for ignore matching during cleanup.
|
||||
on_progress: Optional callback after each file.
|
||||
|
||||
Returns:
|
||||
Dict with files_copied, bytes_copied, errors, files_deleted.
|
||||
"""
|
||||
dest_path = Path(os.path.realpath(dest))
|
||||
|
||||
# Mirror-delete: remove snapshot files whose source is gone
|
||||
files_deleted = 0
|
||||
if dest_path.exists():
|
||||
files_deleted = _run_mirror_cleanup(dest_path, project_root, spec)
|
||||
|
||||
dest_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
files_copied = 0
|
||||
bytes_copied = 0
|
||||
errors: list[str] = []
|
||||
|
||||
for abs_path, rel_path in files:
|
||||
try:
|
||||
result_bytes = _copy_single_file(abs_path, rel_path, dest_path, errors)
|
||||
if result_bytes >= 0:
|
||||
bytes_copied += result_bytes
|
||||
files_copied += 1
|
||||
except OSError as e:
|
||||
logger.warning(f"Failed to copy {rel_path}: {e}")
|
||||
errors.append(f"{rel_path}: {e}")
|
||||
|
||||
if on_progress:
|
||||
on_progress()
|
||||
|
||||
result = {
|
||||
"files_copied": files_copied,
|
||||
"bytes_copied": bytes_copied,
|
||||
"errors": errors,
|
||||
"files_deleted": files_deleted,
|
||||
}
|
||||
json_handler.log_operation(
|
||||
"copy_snapshot",
|
||||
{
|
||||
"project_root": project_root,
|
||||
"files_copied": files_copied,
|
||||
"bytes_copied": bytes_copied,
|
||||
"files_deleted": files_deleted,
|
||||
},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# =============================================
|
||||
@@ -0,0 +1,161 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: versioned.py
|
||||
# Description: Versioned copy — per-file baseline + diff engine
|
||||
# Version: 2.0.0
|
||||
# Created: 2026-04-16
|
||||
# Modified: 2026-06-12
|
||||
# =============================================
|
||||
|
||||
"""Versioned copy handler — per-file baseline + unified diff engine.
|
||||
|
||||
Each file gets a file-folder in the persistent store containing:
|
||||
- <name> (current version, copy2 preserves mtime)
|
||||
- <stem>-baseline-<YYYY-MM-DD>.<ext> (first-run full copy, never overwritten)
|
||||
- <name>_diffs/<name>_v<YYYY-MM-DD_HH-MM-SS>.diff (old version's mtime timestamp)
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax import logger
|
||||
|
||||
from ..diff.generator import generate_diff_content, should_create_diff
|
||||
from ..json import json_handler
|
||||
from ..path.builder import build_versioned_file_path
|
||||
|
||||
|
||||
def _make_baseline_name(target: Path) -> str:
|
||||
"""Build the baseline filename: <stem>-baseline-<YYYY-MM-DD>.<ext>."""
|
||||
date_str = datetime.datetime.now().strftime("%Y-%m-%d")
|
||||
parts = target.name.rsplit(".", 1)
|
||||
if len(parts) == 2:
|
||||
return f"{parts[0]}-baseline-{date_str}.{parts[1]}"
|
||||
return f"{target.name}-baseline-{date_str}"
|
||||
|
||||
|
||||
def _ensure_writable(path: Path) -> None:
|
||||
"""Best-effort chmod to make a path writable."""
|
||||
try:
|
||||
path.chmod(stat.S_IWRITE | stat.S_IREAD)
|
||||
except OSError as e:
|
||||
logger.info(f"[versioned] Could not chmod {path}: {e}")
|
||||
|
||||
|
||||
def _copy_new_file(source: Path, target: Path) -> bool:
|
||||
"""Handle a new file: create baseline + current."""
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Current copy (mtime preserved via copy2)
|
||||
shutil.copy2(str(source), str(target))
|
||||
|
||||
# Baseline copy (never overwritten after creation)
|
||||
baseline_name = _make_baseline_name(target)
|
||||
baseline_path = target.parent / baseline_name
|
||||
if not baseline_path.exists():
|
||||
shutil.copy2(str(source), str(baseline_path))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _copy_changed_file(source: Path, target: Path) -> bool:
|
||||
"""Handle a changed file: diff old current, then overwrite current."""
|
||||
# Generate diff before overwriting
|
||||
if should_create_diff(source):
|
||||
old_mtime = target.stat().st_mtime
|
||||
ts = datetime.datetime.fromtimestamp(old_mtime).strftime("%Y-%m-%d_%H-%M-%S")
|
||||
diff_dir = target.parent / f"{target.name}_diffs"
|
||||
diff_dir.mkdir(parents=True, exist_ok=True)
|
||||
diff_name = f"{target.name}_v{ts}.diff"
|
||||
diff_path = diff_dir / diff_name
|
||||
|
||||
diff_content = generate_diff_content(target, source)
|
||||
if diff_content:
|
||||
diff_path.write_text(diff_content, encoding="utf-8")
|
||||
|
||||
# Overwrite current with new version
|
||||
_ensure_writable(target)
|
||||
shutil.copy2(str(source), str(target))
|
||||
return True
|
||||
|
||||
|
||||
def copy_versioned(
|
||||
files: list[tuple[str, str]],
|
||||
project_root: str,
|
||||
on_progress=None,
|
||||
) -> dict:
|
||||
"""Copy files into the persistent versioned store.
|
||||
|
||||
For each file:
|
||||
- New: create baseline + current (two copies)
|
||||
- Changed (mtime differs): diff old->new, overwrite current
|
||||
- Unchanged: skip
|
||||
|
||||
Args:
|
||||
files: List of (absolute_path, relative_path) tuples.
|
||||
project_root: Project root (used to build store paths).
|
||||
on_progress: Optional callback after each file.
|
||||
|
||||
Returns:
|
||||
Dict with files_copied, files_unchanged, bytes_copied, errors.
|
||||
"""
|
||||
files_copied = 0
|
||||
files_unchanged = 0
|
||||
bytes_copied = 0
|
||||
errors: list[str] = []
|
||||
|
||||
for abs_path, rel_path in files:
|
||||
source = Path(abs_path)
|
||||
target = Path(build_versioned_file_path(project_root, rel_path))
|
||||
|
||||
# Long-path guard
|
||||
if len(str(target)) > 260:
|
||||
logger.warning(f"Path too long (>260), skipping: {rel_path}")
|
||||
errors.append(f"{rel_path}: path too long (>260 chars)")
|
||||
if on_progress:
|
||||
on_progress()
|
||||
continue
|
||||
|
||||
try:
|
||||
if not target.exists():
|
||||
# New file: baseline + current
|
||||
_copy_new_file(source, target)
|
||||
bytes_copied += os.path.getsize(abs_path)
|
||||
files_copied += 1
|
||||
else:
|
||||
# Existing: compare mtimes
|
||||
src_mtime = source.stat().st_mtime
|
||||
tgt_mtime = target.stat().st_mtime
|
||||
if src_mtime != tgt_mtime:
|
||||
_copy_changed_file(source, target)
|
||||
bytes_copied += os.path.getsize(abs_path)
|
||||
files_copied += 1
|
||||
else:
|
||||
files_unchanged += 1
|
||||
except OSError as e:
|
||||
logger.warning(f"Failed to process {rel_path}: {e}")
|
||||
errors.append(f"{rel_path}: {e}")
|
||||
|
||||
if on_progress:
|
||||
on_progress()
|
||||
|
||||
result = {
|
||||
"files_copied": files_copied,
|
||||
"files_unchanged": files_unchanged,
|
||||
"bytes_copied": bytes_copied,
|
||||
"errors": errors,
|
||||
}
|
||||
json_handler.log_operation(
|
||||
"copy_versioned",
|
||||
{
|
||||
"project_root": project_root,
|
||||
"files_copied": files_copied,
|
||||
"files_unchanged": files_unchanged,
|
||||
},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# =============================================
|
||||
@@ -0,0 +1,146 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: generator.py
|
||||
# Description: Unified diff generation with binary detection and pattern filtering
|
||||
# Version: 2.0.0
|
||||
# Created: 2026-04-16
|
||||
# Modified: 2026-06-12
|
||||
# =============================================
|
||||
|
||||
"""Diff generator — unified diffs between file versions with binary detection."""
|
||||
|
||||
import datetime
|
||||
import difflib
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax import logger
|
||||
|
||||
from ..json import json_handler
|
||||
|
||||
DIFF_IGNORE_PATTERNS = [
|
||||
"*.pyc",
|
||||
"*.pyo",
|
||||
"*.so",
|
||||
"*.dylib",
|
||||
"*.dll",
|
||||
"*.exe",
|
||||
"*.bin",
|
||||
"*.dat",
|
||||
"*.db",
|
||||
"*.sqlite",
|
||||
"*.sqlite3",
|
||||
"*.jpg",
|
||||
"*.jpeg",
|
||||
"*.png",
|
||||
"*.gif",
|
||||
"*.bmp",
|
||||
"*.ico",
|
||||
"*.svg",
|
||||
"*.woff",
|
||||
"*.woff2",
|
||||
"*.ttf",
|
||||
"*.eot",
|
||||
"*.mp3",
|
||||
"*.mp4",
|
||||
"*.wav",
|
||||
"*.avi",
|
||||
"*.zip",
|
||||
"*.tar",
|
||||
"*.gz",
|
||||
"*.bz2",
|
||||
"*.7z",
|
||||
"*.rar",
|
||||
"*.pdf",
|
||||
"*.doc",
|
||||
"*.docx",
|
||||
"*.xls",
|
||||
"*.xlsx",
|
||||
]
|
||||
|
||||
DIFF_INCLUDE_PATTERNS = [
|
||||
"*.py",
|
||||
"*.js",
|
||||
"*.ts",
|
||||
"*.jsx",
|
||||
"*.tsx",
|
||||
"*.json",
|
||||
"*.yaml",
|
||||
"*.yml",
|
||||
"*.toml",
|
||||
"*.cfg",
|
||||
"*.ini",
|
||||
"*.md",
|
||||
"*.rst",
|
||||
"*.txt",
|
||||
"*.html",
|
||||
"*.css",
|
||||
"*.sh",
|
||||
"*.bash",
|
||||
"*.sql",
|
||||
"*.xml",
|
||||
"*.csv",
|
||||
]
|
||||
|
||||
|
||||
def should_create_diff(file_path: Path) -> bool:
|
||||
"""Check if file should have diffs created based on patterns.
|
||||
|
||||
Include patterns override ignore patterns. Default = create diff.
|
||||
"""
|
||||
for pattern in DIFF_INCLUDE_PATTERNS:
|
||||
if file_path.match(pattern):
|
||||
return True
|
||||
for pattern in DIFF_IGNORE_PATTERNS:
|
||||
if file_path.match(pattern):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def is_binary_file(file_path: Path) -> bool:
|
||||
"""Check if a file is likely binary (null byte in first 1KB)."""
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
chunk = f.read(1024)
|
||||
return b"\0" in chunk
|
||||
except Exception as e:
|
||||
logger.info(f"[diff] Could not read {file_path}, assuming binary: {e}")
|
||||
return True
|
||||
|
||||
|
||||
def generate_diff_content(old_file: Path, new_file: Path) -> str:
|
||||
"""Generate unified diff between two file versions.
|
||||
|
||||
Args:
|
||||
old_file: Path to old version (store current before overwrite).
|
||||
new_file: Path to new version (source file).
|
||||
|
||||
Returns:
|
||||
Unified diff string, or binary-change marker.
|
||||
"""
|
||||
try:
|
||||
if is_binary_file(old_file) or is_binary_file(new_file):
|
||||
return f"Binary file {old_file.name} changed\n"
|
||||
|
||||
with open(old_file, encoding="utf-8", errors="replace") as f:
|
||||
old_lines = f.readlines()
|
||||
with open(new_file, encoding="utf-8", errors="replace") as f:
|
||||
new_lines = f.readlines()
|
||||
|
||||
diff_lines = difflib.unified_diff(
|
||||
old_lines,
|
||||
new_lines,
|
||||
fromfile=f"a/{old_file.name}",
|
||||
tofile=f"b/{new_file.name}",
|
||||
fromfiledate=datetime.datetime.fromtimestamp(old_file.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
tofiledate=datetime.datetime.fromtimestamp(new_file.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
lineterm="",
|
||||
)
|
||||
|
||||
result = "\n".join(diff_lines)
|
||||
json_handler.log_operation("diff_generated", {"file": old_file.name})
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning(f"[diff] Failed to generate diff: {old_file} -> {new_file}: {e}")
|
||||
return f"Error generating diff: {e}\n"
|
||||
|
||||
|
||||
# =============================================
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user