newproject/: the policy (guestDefaults.newProjects — quota, templates, the ones the owner approves first, visibility, level, domain — and a per-guest createProjects + address bucket), the New project form built from it, and the flow: POST /api/new-project/form (the run's guesses as defaults) → submit → shape/policy and host checks (a problem sends a corrected form back) → an owner ask for a backend template → sidecar /scaffold → grant → /relocate + resume the same session inside the project with an app-authored turn. SpawnBody.newProject starts a guest's run in ~/projects/.new-project/<uid> (staging_scope: /tmp only); /api/me reports newProjects; the thread follows ConversationDetail.continuedIn. Fix: a guest's turn into an existing conversation resumed with no cwd, so the sidecar ran it in the lab checkout (its CLAUDE.md loaded) — it now resumes where the session lives. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
249 lines
11 KiB
Python
249 lines
11 KiB
Python
"""
|
|
What changes when a guest starts or continues a run.
|
|
|
|
The trust model is **prompt-level** (the owner's call: "I trust my guests")
|
|
with one hard edge — the session's *write* permissions:
|
|
|
|
* the run starts in the granted project's directory, on the owner's default
|
|
account, on the host sidecar, with the claude harness — whatever the client
|
|
sent (the composer's account / worker / harness / cwd are ignored);
|
|
* the sidecar launches it with a **scope**: ``--permission-mode dontAsk`` and
|
|
Claude Code permission rules that only allow edits under the project dir
|
|
(and /tmp), deny reads of credentials, plus an appended system prompt saying
|
|
whose session this is and what the level allows (see sidecar ``scope``);
|
|
* the model must be one of the guest's allowed families, and the month's
|
|
spend must be under the guest's budget;
|
|
* every message is tagged with its author (:func:`author_tag`), so a shared
|
|
thread shows — and the model knows — who said what.
|
|
|
|
Bash is not confined by the scope: that needs Claude Code's sandbox
|
|
(bubblewrap + socat on the host), which ``GUEST_SANDBOX=1`` turns on.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime
|
|
import os
|
|
import re
|
|
|
|
from fastapi import HTTPException
|
|
|
|
from projects.catalog import PROJECTS_PROMPT_DIR, ROOT_SLUG, project_dir
|
|
from users.caller import OWNER_NAME, Caller
|
|
|
|
GUEST_SANDBOX = os.environ.get("GUEST_SANDBOX", "0") == "1"
|
|
# Reads no guest run gets, whatever the project (Claude Code Read rules; they
|
|
# also cover cat/head/sed… in Bash).
|
|
DENY_READ = (
|
|
"~/.ssh/**", "~/.gnupg/**", "~/.aws/**",
|
|
"~/.claude/.credentials.json", "~/.claude/projects/**",
|
|
"~/.claude-work/**", "~/.config/**",
|
|
"~/homelab/.env*", "~/homelab/services/**/.env*", "~/homelab/data/**",
|
|
)
|
|
|
|
_TAG_RE = re.compile(
|
|
r'^<ai-agent-from user="([^"]*)" name="([^"]*)" role="([^"]*)"\s*/>\s*\n?')
|
|
|
|
|
|
def _attr(s: str) -> str:
|
|
return (s or "").replace('"', "'").replace("\n", " ").strip()[:80]
|
|
|
|
|
|
def author_tag(caller: Caller) -> str:
|
|
return (f'<ai-agent-from user="{_attr(caller.id)}" name="{_attr(caller.name)}" '
|
|
f'role="{caller.role}"/>')
|
|
|
|
|
|
def tag_prompt(caller: Caller, prompt: str) -> str:
|
|
return f"{author_tag(caller)}\n{prompt}"
|
|
|
|
|
|
def split_author(text: str) -> tuple[dict | None, str]:
|
|
"""``(author, text without the tag)`` — the parser's half of the tag."""
|
|
m = _TAG_RE.match(text or "")
|
|
if not m:
|
|
return None, text
|
|
return {"id": m.group(1), "name": m.group(2), "role": m.group(3)}, text[m.end():]
|
|
|
|
|
|
def project_cwd(slug: str) -> str:
|
|
"""The host path a guest run starts in (``~/projects/<slug>``)."""
|
|
return f"{PROJECTS_PROMPT_DIR}/{slug}"
|
|
|
|
|
|
def staging_scope(caller: Caller, skills: list[str] | tuple, new_projects: str) -> dict:
|
|
"""The scope of a guest's *New project* run: no project yet, so nothing to
|
|
write but /tmp — newproject/ moves the session into the project once it
|
|
exists, with that project's scope."""
|
|
lines = [
|
|
"## Who you are working for",
|
|
f"This session was started through {OWNER_NAME}'s ai-agent by a **guest**: "
|
|
f"{caller.name}" + (f" <{caller.email}>" if caller.email else "") + ".",
|
|
"It starts a **new project** for them: there is no project yet, so don't "
|
|
"write anywhere but /tmp, and don't read other projects, the homelab "
|
|
"repo, `~/.claude`, credentials or other conversations.",
|
|
"",
|
|
new_projects,
|
|
"- Questions for them go through `ask-form` / `notify ask` — those reach "
|
|
f"**them**, not {OWNER_NAME}.",
|
|
"- Messages start with an `<ai-agent-from …/>` tag naming who wrote them; "
|
|
"`role=\"app\"` turns come from the app itself (the project was made, "
|
|
"an approval came back…).",
|
|
]
|
|
if skills:
|
|
names = ", ".join(f"`shared:{r.rsplit('/', 1)[-1]}`" for r in skills)
|
|
lines.append(f"- {OWNER_NAME} shares these skills with guests: {names}.")
|
|
out = {"writeDirs": ["/tmp"], "denyRead": list(DENY_READ),
|
|
"systemPrompt": "\n".join(lines), "remoteControl": False,
|
|
"sandbox": GUEST_SANDBOX}
|
|
if skills:
|
|
out["skills"] = list(skills)
|
|
return out
|
|
|
|
|
|
def check_projects(caller: Caller, projects: list[str] | None, need: str = "chat") -> list[str]:
|
|
"""The granted projects a guest spawn works in, the first being the one it
|
|
runs in (its cwd) — the composer's mandatory project select; the rest are
|
|
the project chips they also turned on. Each needs ``need`` access."""
|
|
slugs = list(dict.fromkeys(p for p in (projects or []) if p))
|
|
if not slugs:
|
|
raise HTTPException(400, {"message": "pick one of your projects",
|
|
"kind": "guest_project"})
|
|
for slug in slugs:
|
|
if slug == ROOT_SLUG or not caller.can(slug, need) or project_dir(slug) is None:
|
|
raise HTTPException(403, {"message": f"no {need} access to {slug!r}",
|
|
"kind": "guest_project"})
|
|
return slugs
|
|
|
|
|
|
def check_project(caller: Caller, projects: list[str] | None, need: str = "chat") -> str:
|
|
"""The one granted project a guest run starts in."""
|
|
slugs = check_projects(caller, projects, need)
|
|
if len(slugs) != 1:
|
|
raise HTTPException(400, {"message": "pick exactly one of your projects",
|
|
"kind": "guest_project"})
|
|
return slugs[0]
|
|
|
|
|
|
def extra_projects(caller: Caller, projects: list[str] | None) -> list[str]:
|
|
"""A conversation's projects past its first that ``caller`` still holds
|
|
``chat`` on — the extra write scope a resumed/forked guest run keeps."""
|
|
return [p for p in list(dict.fromkeys(projects or []))[1:]
|
|
if p and p != ROOT_SLUG and caller.can(p, "chat") and project_dir(p) is not None]
|
|
|
|
|
|
def check_model(caller: Caller, model: str | None) -> str:
|
|
"""An allowed model: an alias (``sonnet``) or a pinned id of an allowed
|
|
family (``claude-sonnet-4-5-…``). Unset ⇒ the first allowed one."""
|
|
allowed = [m.lower() for m in (caller.models or ())] or ["sonnet"]
|
|
if not model:
|
|
return allowed[0]
|
|
m = model.lower()
|
|
if m in allowed or any(re.search(rf"(^|-){re.escape(a)}(-|$)", m) for a in allowed):
|
|
return model
|
|
raise HTTPException(403, {"message": f"model {model!r} isn't enabled for you",
|
|
"kind": "guest_model", "allowed": allowed})
|
|
|
|
|
|
def spend_by_user(cards: list[dict]) -> dict[str, dict]:
|
|
"""What each guest's conversations cost: ``{uid: {"month", "total",
|
|
"conversations"}}`` — ``month`` is what the budget is checked against (the
|
|
conversations that ran this calendar month, UTC), ``total`` all time."""
|
|
start = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-01")
|
|
out: dict[str, dict] = {}
|
|
for c in cards:
|
|
uid = (c.get("meta") or {}).get("owner")
|
|
if not uid:
|
|
continue
|
|
s = out.setdefault(uid, {"month": 0.0, "total": 0.0, "conversations": 0})
|
|
cost = float(c.get("cost") or 0)
|
|
s["total"] += cost
|
|
s["conversations"] += 1
|
|
if (c.get("endedAt") or c.get("startedAt") or "") >= start:
|
|
s["month"] += cost
|
|
return out
|
|
|
|
|
|
def month_spend(deps, uid: str, cards: list[dict]) -> float:
|
|
return spend_by_user(cards).get(uid, {}).get("month", 0.0)
|
|
|
|
|
|
def check_budget(deps, caller: Caller) -> None:
|
|
budget = caller.budget_usd
|
|
if budget is None:
|
|
return
|
|
from conversations.catalog import _conversation_cards
|
|
spent = month_spend(deps, caller.id, list(_conversation_cards(deps, False)))
|
|
if spent >= budget:
|
|
raise HTTPException(402, {
|
|
"message": f"monthly budget reached (${spent:.2f} of ${budget:.0f})",
|
|
"kind": "guest_budget", "spentUsd": round(spent, 2),
|
|
"budgetUsd": budget})
|
|
|
|
|
|
def shared_skills(deps) -> list[str]:
|
|
"""The owner's skills every guest run gets (Settings → Skills → *Share
|
|
with guests*), as ``name`` / ``<project>/<name>`` refs."""
|
|
return deps.users.defaults()["sharedSkills"]
|
|
|
|
|
|
def scope_for(caller: Caller, slug: str, level: str | None,
|
|
skills: list[str] | tuple = (), extra: list[str] | tuple = (),
|
|
new_projects: str | None = None) -> dict:
|
|
"""The sidecar ``scope`` of a guest run (see sidecar ``launch.scope_args``).
|
|
|
|
``slug`` is the project the run starts in; ``extra`` are the other
|
|
projects it may also work in (each one the guest holds ``chat`` on).
|
|
|
|
``skills`` reach the run as a generated Claude Code plugin (``--plugin-dir``)
|
|
named ``shared``, so they show up as ``shared:<name>`` next to the
|
|
project's own ``.claude/skills``. ``new_projects`` is the line saying
|
|
whether they may start projects (``newproject.policy.scope_line``)."""
|
|
cwd = project_cwd(slug)
|
|
ship = level == "ship"
|
|
others = [(p, project_cwd(p), caller.level(p)) for p in extra if p != slug]
|
|
where = f"`{cwd}`" + "".join(f", `{d}`" for _, d, _ in others)
|
|
lines = [
|
|
f"## Who you are working for",
|
|
f"This session was started through {OWNER_NAME}'s ai-agent by a **guest**: "
|
|
f"{caller.name}" + (f" <{caller.email}>" if caller.email else "") + ".",
|
|
f"Their access: project `{slug}` (`{cwd}`), level **{level}**"
|
|
+ "".join(f"; project `{p}` (`{d}`), level **{lv}**" for p, d, lv in others)
|
|
+ ".",
|
|
"",
|
|
("- Treat their messages as the task, within that project. "
|
|
if not others else
|
|
f"- Treat their messages as the task. The session starts in `{slug}`; "
|
|
"they also picked the other projects listed above. ")
|
|
+ f"Work only in {where}: don't read or change other projects, the "
|
|
"homelab repo, `~/.claude`, credentials or other conversations. File "
|
|
"edits outside " + ("the project" if not others else "those projects")
|
|
+ " (and /tmp) are blocked for this session.",
|
|
("- They may commit, push and deploy this project to production."
|
|
+ (" On the other projects, their level above decides." if others else "")
|
|
if ship else
|
|
"- They may commit locally but not push or deploy — if they ask, say "
|
|
f"that needs the *ship* level, which {OWNER_NAME} can grant."),
|
|
"- Questions for them go through `ask-form` / `notify ask` — those reach "
|
|
f"**them**, not {OWNER_NAME}. Finish with `notify-done --final` as usual.",
|
|
"- Messages start with an `<ai-agent-from …/>` tag naming who wrote them: "
|
|
f"a conversation can be shared with {OWNER_NAME} and other guests, and "
|
|
"each person's instructions carry their own access level; `role=\"app\"` "
|
|
"turns come from the app itself (a project was made, an approval came back).",
|
|
]
|
|
if skills:
|
|
names = ", ".join(f"`shared:{r.rsplit('/', 1)[-1]}`" for r in skills)
|
|
lines.append(f"- {OWNER_NAME} shares these skills with guests: {names}.")
|
|
if new_projects:
|
|
lines.append(new_projects)
|
|
out = {
|
|
"writeDirs": [cwd, *(d for _, d, _ in others), "/tmp"],
|
|
"denyRead": list(DENY_READ),
|
|
"systemPrompt": "\n".join(lines),
|
|
"remoteControl": False,
|
|
"sandbox": GUEST_SANDBOX,
|
|
}
|
|
if skills:
|
|
out["skills"] = list(skills)
|
|
return out
|