Skip to content

Commit 86f75c6

Browse files
ZD Studiosclaude
andcommitted
feat: master-password vault, aios uninstall, and a plugin store
VAULT (aios_vault.py) — a master password set at install that gates destructive, irreversible operations: `aios uninstall`, and disabling the guardrails. The obvious promise — "the AI can't delete it" — is not achievable by encryption alone: the agents run as you and have a shell, so any file you can delete they can delete. What IS enforceable, and what this does: 1. The password is never stored, only scrypt(pw, salt) with n=2^15 (~32MB, ~0.1s/attempt). There is nothing to read back, even with the file. 2. Deleting it must not GRANT anything. Every protected op fails CLOSED — no vault, a corrupt vault, or two copies that disagree all REFUSE. That turns "delete the password to escape the gate" into locking yourself out. 3. Tamper-evidence: self-checksummed and mirrored outside the repo, so a `git clean` or a rewritten project tree can't silently drop it. 4. The agents' shell refuses to touch the vault paths (aios_sec.guard), checked BEFORE the guardrails-enabled switch — since disabling guardrails is itself vault-protected, letting that unlock vault deletion is circular. Adversarially tested: editing the hash, deleting both copies, deleting one copy, and replacing one copy with a correctly-checksummed forgery are all rejected; deleting the vault refuses the uninstall rather than allowing it; a single surviving copy auto-heals once the real password proves ownership. UNINSTALL — `aios uninstall` requires the password, then stops services, removes the global CLI shim and autostart, and KEEPS your .aios state unless --purge. It never deletes the project folder. Refuses with exit 1 on a wrong password and when no password is set at all. Fixed while testing: getpass() on Windows reads the console directly and ignores a pipe, so `echo pw | aios uninstall` hung forever instead of failing. Non-tty stdin now reads the line, which makes it scriptable and unable to hang. PLUGIN STORE (aios_plugins.py) — one place to browse and install everything installable, normalising four genuinely different mechanisms: OpenClaw channels (26), ruflo packs (40, via `npx ruflo plugin add`), skills (13), and OpenClaw feature plugins. The catalogue is read from what's actually on disk rather than hardcoded, so it reflects this install. New Plugin Store view with kind filters and search, plus GET/POST /api/plugins and /api/vault (which never returns anything secret). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f8be98c commit 86f75c6

6 files changed

Lines changed: 704 additions & 3 deletions

File tree

aios.py

Lines changed: 189 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -820,6 +820,7 @@ def cmd_setup(args):
820820

821821
# 7b) hand the agents the machine, and mint the token that keeps that safe
822822
ensure_hub_token()
823+
ensure_master_password(interactive=not args.non_interactive)
823824
if not args.skip_wire:
824825
wire_full_control(cfg)
825826

@@ -1952,6 +1953,183 @@ def cmd_web(args):
19521953
print(json.dumps(r, indent=2)[:12000])
19531954

19541955

1956+
def _vault():
1957+
sys.path.insert(0, str(ROOT))
1958+
import aios_vault
1959+
return aios_vault
1960+
1961+
1962+
def _ask_password(prompt: str = "Master password: ") -> str:
1963+
"""Read a password without echoing it.
1964+
1965+
getpass() on Windows reads the console device directly and ignores a pipe,
1966+
so `echo pw | aios uninstall` would hang forever rather than fail. When stdin
1967+
isn't a terminal, read the line instead — that keeps it scriptable and, more
1968+
importantly, means it can never hang."""
1969+
if not sys.stdin.isatty():
1970+
line = sys.stdin.readline()
1971+
if not line:
1972+
return ""
1973+
return line.rstrip("\r\n")
1974+
import getpass
1975+
try:
1976+
return getpass.getpass(prompt)
1977+
except Exception:
1978+
return input(prompt)
1979+
1980+
1981+
def ensure_master_password(interactive: bool = True) -> bool:
1982+
"""Offered once at setup. Gates uninstall and other irreversible operations."""
1983+
V = _vault()
1984+
if V.is_set():
1985+
return True
1986+
if not interactive or not sys.stdin.isatty():
1987+
warn("no master password set — `aios uninstall` and other protected "
1988+
"operations will refuse until you run `aios password set`")
1989+
return False
1990+
head("Master password")
1991+
say(" Sets a password required to UNINSTALL The AI OS or disable its guardrails.")
1992+
say(f" {C.GRY}Stored as a memory-hard scrypt hash — never in plain text, so it")
1993+
say(f" cannot be read back. Deleting it does not bypass anything: protected")
1994+
say(f" operations refuse when the vault is missing.{C.R}\n")
1995+
for _ in range(3):
1996+
p1 = _ask_password(" Choose a master password (min 8 chars, blank to skip): ")
1997+
if not p1.strip():
1998+
warn("skipped — set one later with `aios password set`")
1999+
return False
2000+
p2 = _ask_password(" Confirm: ")
2001+
if p1 != p2:
2002+
warn("passwords didn't match — try again")
2003+
continue
2004+
r = V.set_password(p1)
2005+
if r.get("ok"):
2006+
ok("master password set")
2007+
say(f" {C.GRY}stored (hashed) at {V.VAULT} and mirrored to {V.MIRROR}{C.R}")
2008+
return True
2009+
warn(r.get("error", "could not set password"))
2010+
return False
2011+
2012+
2013+
def cmd_password(args):
2014+
V = _vault()
2015+
action = getattr(args, "action", "status")
2016+
if action == "status":
2017+
st = V.status()
2018+
head("Master password")
2019+
say(f" set : {C.GRN if st['set'] else C.YEL}{st['set']}{C.R}")
2020+
say(f" primary : {st['primary']} {C.GRY}{st['paths']['primary']}{C.R}")
2021+
say(f" mirror : {st['mirror']} {C.GRY}{st['paths']['mirror']}{C.R}")
2022+
if st.get("tampered"):
2023+
warn("a copy is corrupt or was edited — protected operations will refuse")
2024+
if st.get("mismatch"):
2025+
warn("the two copies disagree — compare them and remove the wrong one")
2026+
if not st["set"]:
2027+
say(f"\n {C.GRY}Set one with:{C.R} aios password set")
2028+
return
2029+
if action == "set":
2030+
if V.is_set():
2031+
cur = _ask_password("Current master password: ")
2032+
else:
2033+
cur = None
2034+
for _ in range(3):
2035+
p1 = _ask_password("New master password (min 8 chars): ")
2036+
p2 = _ask_password("Confirm: ")
2037+
if p1 != p2:
2038+
warn("passwords didn't match")
2039+
continue
2040+
r = V.set_password(p1, current=cur)
2041+
(ok if r.get("ok") else warn)(
2042+
("rotated" if r.get("rotated") else "master password set")
2043+
if r.get("ok") else r.get("error"))
2044+
return
2045+
return
2046+
if action == "verify":
2047+
r = V.verify(_ask_password())
2048+
(ok if r.get("ok") else warn)("correct" if r.get("ok") else r.get("error"))
2049+
sys.exit(0 if r.get("ok") else 1)
2050+
2051+
2052+
def cmd_uninstall(args):
2053+
"""Remove The AI OS. Protected by the master password, and never silent."""
2054+
V = _vault()
2055+
head("Uninstall The AI OS")
2056+
2057+
st = V.status()
2058+
if not st["set"]:
2059+
warn("no master password is set, so uninstall is refused.")
2060+
say(f" {C.GRY}This is deliberate: a missing vault must never unlock a "
2061+
f"destructive action.{C.R}")
2062+
say(f" {C.GRY}Set one with `aios password set`, or remove the folder "
2063+
f"manually if you've lost it.{C.R}")
2064+
sys.exit(1)
2065+
2066+
r = V.require(_ask_password("Master password: "), "uninstall")
2067+
if not r.get("ok"):
2068+
warn(r.get("error", "incorrect password"))
2069+
sys.exit(1)
2070+
ok("password accepted")
2071+
2072+
keep_data = not getattr(args, "purge", False)
2073+
say(f"\n{C.B}This will:{C.R}")
2074+
say(" · stop every AIOS service")
2075+
say(" · remove the global `aios` command and any autostart entry")
2076+
say(f" · {'KEEP' if keep_data else 'DELETE'} your state in .aios/ "
2077+
f"(memory, tasks, flows, audit log){'' if keep_data else ' ← --purge'}")
2078+
say(f" · {C.GRY}leave the project folder itself — delete it yourself when happy{C.R}")
2079+
if not getattr(args, "yes", False):
2080+
if input(f"\n{C.YEL}Type 'uninstall' to confirm: {C.R}").strip().lower() != "uninstall":
2081+
say("aborted — nothing changed.")
2082+
return
2083+
2084+
head("Stopping services")
2085+
try:
2086+
cmd_stop(argparse.Namespace(service=["all"]))
2087+
except Exception as e:
2088+
warn(f"stop reported: {e}")
2089+
2090+
head("Removing integration")
2091+
try:
2092+
cmd_autostart(argparse.Namespace(action="disable"))
2093+
except Exception as e:
2094+
warn(f"autostart: {e}")
2095+
try:
2096+
install_cli_remove()
2097+
except Exception as e:
2098+
warn(f"cli shim: {e}")
2099+
2100+
if not keep_data:
2101+
head("Deleting state")
2102+
for p in (STATE, V.MIRROR.parent):
2103+
try:
2104+
if p.exists():
2105+
shutil.rmtree(p, ignore_errors=True)
2106+
ok(f"removed {p}")
2107+
except Exception as e:
2108+
warn(f"could not remove {p}: {e}")
2109+
2110+
say()
2111+
ok("The AI OS is uninstalled.")
2112+
say(f" {C.GRY}The project folder is still at {ROOT} — delete it when you're ready.{C.R}")
2113+
if keep_data:
2114+
say(f" {C.GRY}Your state was kept in {STATE} (use --purge to delete it too).{C.R}")
2115+
2116+
2117+
def install_cli_remove():
2118+
"""Undo install_cli()."""
2119+
d = _cli_bin_dir()
2120+
removed = []
2121+
for name in ("aios", "aios.cmd", "aios.ps1"):
2122+
p = d / name
2123+
try:
2124+
if p.exists():
2125+
p.unlink()
2126+
removed.append(str(p))
2127+
except Exception:
2128+
pass
2129+
(ok if removed else warn)(f"removed CLI shim(s): {', '.join(removed)}" if removed
2130+
else "no CLI shim found on PATH")
2131+
2132+
19552133
def cmd_updates(args):
19562134
"""Supervised updates: what moved upstream, what the agents think, apply/rollback."""
19572135
sys.path.insert(0, str(ROOT))
@@ -2201,9 +2379,9 @@ def _print_urls(cfg):
22012379
# unreadable once you have 25 subcommands.
22022380
HELP_GROUPS = [
22032381
("Get started", ["setup", "start", "stop", "restart", "status", "url"]),
2204-
("Everyday", ["logs", "doctor", "exec", "web", "channels", "profile", "token"]),
2382+
("Everyday", ["logs", "doctor", "exec", "web", "channels", "profile", "token", "password"]),
22052383
("Agents & updates", ["updates", "update", "claude-login", "ruflo", "attach", "migrate", "wire"]),
2206-
("Advanced", ["test", "debug", "autostart", "install-cli", "bootstrap"]),
2384+
("Advanced", ["test", "debug", "autostart", "install-cli", "bootstrap", "uninstall"]),
22072385
]
22082386
TAGLINE = "Nine open-source AI projects. One operating system."
22092387

@@ -2351,6 +2529,15 @@ def build_parser():
23512529
help="passed to `npx ruflo` — or: plugins | mcp-register")
23522530
s.set_defaults(func=cmd_ruflo)
23532531

2532+
s = sub.add_parser("password", help="master password that gates uninstall + guardrail changes")
2533+
s.add_argument("action", nargs="?", choices=["status", "set", "verify"], default="status")
2534+
s.set_defaults(func=cmd_password)
2535+
2536+
s = sub.add_parser("uninstall", help="remove The AI OS (requires the master password)")
2537+
s.add_argument("--purge", action="store_true", help="also delete .aios state (memory, tasks, audit)")
2538+
s.add_argument("--yes", action="store_true", help="skip the typed confirmation")
2539+
s.set_defaults(func=cmd_uninstall)
2540+
23542541
s = sub.add_parser("token", help="print the hub token (needed for non-loopback access)")
23552542
s.add_argument("--rotate", action="store_true", help="generate a fresh token")
23562543
s.set_defaults(func=cmd_token)

aios_hub.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@
3636
import aios_updates as updates # noqa: E402 agent-supervised dependency updates
3737
import aios_web as web # noqa: E402 YouTube / Reddit / HN / page readers
3838
import aios_swarm as swarm # noqa: E402 decompose big tasks across agents
39+
import aios_plugins as plugins # noqa: E402 the plugin store
40+
import aios_vault as vault # noqa: E402 master password for destructive ops
3941

4042
PORT = int(os.environ.get("AIOS_HUB_PORT", "8787"))
4143
ROOT = Path(os.environ.get("AIOS_ROOT", Path(__file__).resolve().parent))
@@ -1500,6 +1502,10 @@ def do_GET(self):
15001502
elif self.path == "/api/activity":
15011503
self._send(200, activity(self._query.get("turn", [""])[0],
15021504
int(self._query.get("since", ["0"])[0] or 0)))
1505+
elif self.path == "/api/plugins":
1506+
self._send(200, {"plugins": plugins.catalog()})
1507+
elif self.path == "/api/vault":
1508+
self._send(200, vault.status()) # never returns anything secret
15031509
elif self.path == "/api/actions":
15041510
# Cursor-based so the live monitor never re-sends what it already has.
15051511
since = int(self._query.get("since", ["0"])[0] or 0)
@@ -1762,6 +1768,32 @@ def do_POST(self):
17621768
brain.audit(payload.get("by", "agent"), "web." + op,
17631769
f"{q[:120]} -> ok={r.get('ok')}")
17641770
self._send(200, r)
1771+
elif self.path == "/api/plugins":
1772+
op = payload.get("op", "install")
1773+
pid = payload.get("id", "")
1774+
if op == "install":
1775+
r = plugins.install(pid, payload)
1776+
elif op == "uninstall":
1777+
r = plugins.uninstall(pid)
1778+
else:
1779+
r = {"ok": False, "error": "unknown op"}
1780+
brain.audit("hub", "plugin." + op, f"{pid} -> ok={r.get('ok')}")
1781+
self._send(200, r)
1782+
elif self.path == "/api/vault":
1783+
# Set or rotate from the dashboard. Verification happens server-side;
1784+
# the password is never stored, logged, or echoed back.
1785+
op = payload.get("op", "status")
1786+
if op == "set":
1787+
r = vault.set_password(payload.get("password", ""),
1788+
current=payload.get("current"))
1789+
brain.audit("hub", "vault.set", f"ok={r.get('ok')}")
1790+
self._send(200, {k: v for k, v in r.items()})
1791+
elif op == "verify":
1792+
r = vault.verify(payload.get("password", ""))
1793+
brain.audit("hub", "vault.verify", f"ok={r.get('ok')}", ok=r.get("ok", False))
1794+
self._send(200, {"ok": r.get("ok", False), "error": r.get("error", "")})
1795+
else:
1796+
self._send(200, vault.status())
17651797
elif self.path == "/api/swarm":
17661798
msg = payload.get("message", "")
17671799
if not msg:

0 commit comments

Comments
 (0)