Generalize Pi sandbox launcher
This commit is contained in:
@@ -20,7 +20,7 @@ The root `package.json` exposes the extensions and skills as a single Pi package
|
|||||||
|
|
||||||
## Nono sandbox
|
## Nono sandbox
|
||||||
|
|
||||||
See [`docs/nono.md`](docs/nono.md) for `pi-local` usage, profile settings, Internet tiers, multi-repository access, and the `nono-profiles` editing skill.
|
Use `pi-sbx` to launch Pi with a nono profile selected from the current directory. Manage routes with `pi-sbx-profile set <profile> [directory]`; omitting the directory assigns the current working directory. See [`docs/nono.md`](docs/nono.md) for setup, profile settings, Internet tiers, multi-repository access, and the `nono-profiles` editing skill.
|
||||||
|
|
||||||
## Install locally
|
## Install locally
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"defaultProfile": "pi-orca-local",
|
||||||
|
"defaultRuntime": {
|
||||||
|
"nodeExecutable": "/usr/bin/node",
|
||||||
|
"piAgentDirectory": "~/.pi/agent",
|
||||||
|
"piExecutable": "~/.bun/bin/pi"
|
||||||
|
},
|
||||||
|
"directories": {
|
||||||
|
"/srv/data/code/harness-tools": "pi-harness-tools",
|
||||||
|
"/srv/work": "pi-work"
|
||||||
|
},
|
||||||
|
"profileRuntimes": {
|
||||||
|
"pi-work": {
|
||||||
|
"nodeExecutable": "/home/jb/.local/share/fnm/node-versions/v22.23.2/installation/bin/node",
|
||||||
|
"piAgentDirectory": "~/.pi/profiles/cyone"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
-18
@@ -37,30 +37,39 @@ nono why --self --path /resolved/path --op readwrite
|
|||||||
|
|
||||||
A profile change affects the next sandbox launch. It cannot expand the permissions of the current process.
|
A profile change affects the next sandbox launch. It cannot expand the permissions of the current process.
|
||||||
|
|
||||||
## Orca Pi runner routing
|
## Directory-based Pi sandbox routing
|
||||||
|
|
||||||
Orca launches Pi through [`scripts/pi-orca.fish`](../scripts/pi-orca.fish). The runner resolves the canonical Git project through [`scripts/orca-nono-profile.py`](../scripts/orca-nono-profile.py), then selects the nono profile from [`profiles/orca/project-nono-profiles.json`](../profiles/orca/project-nono-profiles.json).
|
[`pi-sbx`](../scripts/pi-sbx.fish) launches Pi with a nono profile and runtime selected from the current directory. It works from Orca or any terminal. [`pi-sbx-profile`](../scripts/pi-sbx-profile.py) manages the trusted user configuration at `~/.config/pi-sbx/config.json`. Set `PI_SBX_CONFIG` to an absolute path to use a different configuration file. Both commands resolve `nono` from `PATH`.
|
||||||
|
|
||||||
The runner treats the runtime, sandbox, and Pi agent directory as one configuration:
|
One-time setup links the commands and systemd slice, then copies the example configuration:
|
||||||
|
|
||||||
| Route | Nono profile | Node runtime | Pi agent directory |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| Default | `pi-orca-local` | system Node 26 | `~/.pi/agent` |
|
|
||||||
| `/srv/work` descendants | `pi-work` | fnm Node 22.23.2 | `~/.pi/profiles/cyone` |
|
|
||||||
|
|
||||||
The separate Pi agent directories are required because native extension dependencies such as `better-sqlite3` are compiled for one Node ABI. Never point Node 22 and Node 26 runners at the same `PI_CODING_AGENT_DIR`.
|
|
||||||
|
|
||||||
Exact canonical-project mappings override root mappings. Root mappings use the longest matching ancestor of either the canonical Git root or the actual launch path. This keeps linked worktrees launched below `/srv/work` on the work stack even when their main checkout lives elsewhere, while `/srv/work-other` does not match `/srv/work`.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
orca-nono-profile show /srv/work/code/example
|
mkdir -p ~/.local/bin ~/.config/pi-sbx ~/.config/systemd/user
|
||||||
orca-nono-profile set pi-work /srv/work/code/example
|
ln -s /absolute/path/to/harness-tools/scripts/pi-sbx.fish ~/.local/bin/pi-sbx
|
||||||
orca-nono-profile unset /srv/work/code/example
|
ln -s /absolute/path/to/harness-tools/scripts/pi-sbx-profile.py ~/.local/bin/pi-sbx-profile
|
||||||
orca-nono-profile set-root pi-work /srv/work
|
ln -s /absolute/path/to/harness-tools/systemd/user/pi-agents.slice ~/.config/systemd/user/pi-agents.slice
|
||||||
orca-nono-profile unset-root /srv/work
|
cp /absolute/path/to/harness-tools/config/pi-sbx.example.json ~/.config/pi-sbx/config.json
|
||||||
|
chmod 600 ~/.config/pi-sbx/config.json
|
||||||
|
systemctl --user daemon-reload
|
||||||
```
|
```
|
||||||
|
|
||||||
After changing a route, Node runtime, or Pi profile, restart the Orca terminal. Existing Pi processes cannot switch runtimes or agent directories. The runner fails closed when its trusted resolver or resolved nono profile is unavailable; it never falls back from a work route to the default Node 26 state.
|
Directory keys must be canonical absolute physical paths. Directory mappings apply to the configured directory and all descendants. The longest matching directory wins; otherwise `defaultProfile` is used. Resolution uses the physical launch path and does not inspect Git metadata. A linked worktree therefore inherits the route for the directory where the worktree is located.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pi-sbx-profile show # explain the route for CWD
|
||||||
|
pi-sbx-profile set pi-work # assign CWD and descendants
|
||||||
|
pi-sbx-profile set pi-work /srv/work # assign an explicit directory
|
||||||
|
pi-sbx-profile unset # remove the CWD assignment
|
||||||
|
pi-sbx-profile resolve /srv/work/example # print the selected profile
|
||||||
|
pi-sbx-profile list # print the configuration
|
||||||
|
pi-sbx # launch Pi using the selected route
|
||||||
|
```
|
||||||
|
|
||||||
|
The configuration keeps the sandbox, Node runtime, Pi executable, and Pi agent directory together. Start from [`config/pi-sbx.example.json`](../config/pi-sbx.example.json). `defaultRuntime` applies to every profile; an entry in `profileRuntimes` overrides only the fields that differ for that profile.
|
||||||
|
|
||||||
|
Separate Pi agent directories are required when runtimes use different Node ABIs. Native dependencies such as `better-sqlite3` must not be shared between the Node 22 and Node 26 agent directories.
|
||||||
|
|
||||||
|
The launcher exports `PI_SBX_PROFILE` and, for Orca compatibility, `ORCA_NONO_PROFILE`. Existing `pi-orca` and `orca-nono-profile` commands remain compatibility entry points. Restart an existing terminal after changing its route, runtime, or agent directory because a running Pi process cannot switch them.
|
||||||
|
|
||||||
## Edit profiles with the skill
|
## Edit profiles with the skill
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"defaultProfile": "pi-orca-local",
|
|
||||||
"projectRoots": {
|
|
||||||
"/srv/work": "pi-work",
|
|
||||||
"/srv/data/code/harness-tools": "pi-harness-tools"
|
|
||||||
},
|
|
||||||
"projects": {}
|
|
||||||
}
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+16
-177
@@ -1,191 +1,30 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Manage trusted project-to-nono-profile mappings for Orca-launched Pi."""
|
"""Compatibility entry point for pi-sbx-profile."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import shutil
|
||||||
import tempfile
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
MAP_PATH = Path("/srv/data/code/harness-tools/profiles/orca/project-nono-profiles.json")
|
|
||||||
DEFAULT_PROFILE = "pi-orca-local"
|
|
||||||
|
|
||||||
|
|
||||||
def git_output(path: Path, *args: str) -> str | None:
|
ALIASES = {
|
||||||
result = subprocess.run(
|
"set-root": "set",
|
||||||
["git", "-C", str(path), *args],
|
"unset-root": "unset",
|
||||||
check=False,
|
}
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
return None
|
|
||||||
value = result.stdout.strip()
|
|
||||||
return value or None
|
|
||||||
|
|
||||||
|
|
||||||
def canonical_project_root(path: Path) -> Path:
|
def main() -> None:
|
||||||
candidate = path.expanduser().resolve()
|
executable = shutil.which("pi-sbx-profile")
|
||||||
common_dir = git_output(candidate, "rev-parse", "--path-format=absolute", "--git-common-dir")
|
if executable is None:
|
||||||
if common_dir:
|
executable = str(Path(__file__).resolve().with_name("pi-sbx-profile.py"))
|
||||||
common_path = Path(common_dir).resolve()
|
|
||||||
if common_path.name == ".git":
|
|
||||||
return common_path.parent
|
|
||||||
|
|
||||||
top_level = git_output(candidate, "rev-parse", "--show-toplevel")
|
arguments = sys.argv[1:]
|
||||||
if top_level:
|
if arguments:
|
||||||
return Path(top_level).resolve()
|
arguments[0] = ALIASES.get(arguments[0], arguments[0])
|
||||||
return candidate
|
os.execv(executable, [executable, *arguments])
|
||||||
|
|
||||||
|
|
||||||
def load_map() -> dict[str, Any]:
|
|
||||||
try:
|
|
||||||
contents = MAP_PATH.read_text(encoding="utf-8")
|
|
||||||
data = json.loads(contents)
|
|
||||||
except (OSError, json.JSONDecodeError) as error:
|
|
||||||
raise ValueError(f"cannot load profile map: {error}") from error
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
raise ValueError("profile map must be a JSON object")
|
|
||||||
projects = data.get("projects")
|
|
||||||
if not isinstance(projects, dict):
|
|
||||||
raise ValueError("profile map projects must be a JSON object")
|
|
||||||
project_roots = data.get("projectRoots", {})
|
|
||||||
if not isinstance(project_roots, dict):
|
|
||||||
raise ValueError("profile map projectRoots must be a JSON object")
|
|
||||||
for root, profile in project_roots.items():
|
|
||||||
if not isinstance(root, str) or not Path(root).is_absolute():
|
|
||||||
raise ValueError("projectRoots keys must be absolute paths")
|
|
||||||
if not isinstance(profile, str) or not profile:
|
|
||||||
raise ValueError("projectRoots values must be non-empty profile names")
|
|
||||||
default = data.get("defaultProfile", DEFAULT_PROFILE)
|
|
||||||
if not isinstance(default, str) or not default:
|
|
||||||
raise ValueError("defaultProfile must be a non-empty string")
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def save_map(data: dict[str, Any]) -> None:
|
|
||||||
MAP_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
descriptor, temporary_name = tempfile.mkstemp(prefix=".project-nono-profiles.", dir=MAP_PATH.parent)
|
|
||||||
temporary = Path(temporary_name)
|
|
||||||
try:
|
|
||||||
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
||||||
json.dump(data, handle, indent=2, sort_keys=True)
|
|
||||||
handle.write("\n")
|
|
||||||
temporary.chmod(0o600)
|
|
||||||
os.replace(temporary, MAP_PATH)
|
|
||||||
finally:
|
|
||||||
temporary.unlink(missing_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
def profile_exists(profile: str) -> bool:
|
|
||||||
result = subprocess.run(
|
|
||||||
["nono", "profile", "show", profile],
|
|
||||||
check=False,
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
)
|
|
||||||
return result.returncode == 0
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_profile(
|
|
||||||
data: dict[str, Any], project: Path, launch_path: Path | None = None
|
|
||||||
) -> tuple[str, str]:
|
|
||||||
projects = data["projects"]
|
|
||||||
mapped = projects.get(str(project))
|
|
||||||
if isinstance(mapped, str) and mapped:
|
|
||||||
return mapped, "project"
|
|
||||||
|
|
||||||
route_candidates = {project}
|
|
||||||
if launch_path is not None:
|
|
||||||
route_candidates.add(launch_path)
|
|
||||||
|
|
||||||
matching_roots: list[tuple[Path, str]] = []
|
|
||||||
for root_value, profile in data.get("projectRoots", {}).items():
|
|
||||||
root = Path(root_value)
|
|
||||||
if any(candidate == root or root in candidate.parents for candidate in route_candidates):
|
|
||||||
matching_roots.append((root, profile))
|
|
||||||
if matching_roots:
|
|
||||||
_root, profile = max(matching_roots, key=lambda match: len(match[0].parts))
|
|
||||||
return profile, "project-root"
|
|
||||||
|
|
||||||
return data.get("defaultProfile", DEFAULT_PROFILE), "default"
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
|
||||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
||||||
|
|
||||||
for command in ("resolve", "show", "unset", "unset-root"):
|
|
||||||
subparser = subparsers.add_parser(command)
|
|
||||||
subparser.add_argument("path", nargs="?", default=os.getcwd())
|
|
||||||
|
|
||||||
for command in ("set", "set-root"):
|
|
||||||
set_parser = subparsers.add_parser(command)
|
|
||||||
set_parser.add_argument("profile")
|
|
||||||
set_parser.add_argument("path", nargs="?", default=os.getcwd())
|
|
||||||
subparsers.add_parser("list")
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
try:
|
|
||||||
data = load_map()
|
|
||||||
except ValueError as error:
|
|
||||||
parser.error(str(error))
|
|
||||||
|
|
||||||
if args.command == "list":
|
|
||||||
print(json.dumps(data, indent=2, sort_keys=True))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
launch_path = Path(args.path).expanduser().resolve()
|
|
||||||
project = canonical_project_root(launch_path)
|
|
||||||
|
|
||||||
if args.command == "resolve":
|
|
||||||
profile, _source = resolve_profile(data, project, launch_path)
|
|
||||||
print(profile)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
if args.command == "show":
|
|
||||||
profile, source = resolve_profile(data, project, launch_path)
|
|
||||||
print(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"launchPath": str(launch_path),
|
|
||||||
"projectRoot": str(project),
|
|
||||||
"profile": profile,
|
|
||||||
"source": source,
|
|
||||||
},
|
|
||||||
indent=2,
|
|
||||||
sort_keys=True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
if args.command in ("set", "set-root"):
|
|
||||||
if not profile_exists(args.profile):
|
|
||||||
parser.error(f"nono profile does not exist: {args.profile}")
|
|
||||||
mapping_key = "projects" if args.command == "set" else "projectRoots"
|
|
||||||
mapping_path = project if args.command == "set" else launch_path
|
|
||||||
data.setdefault(mapping_key, {})[str(mapping_path)] = args.profile
|
|
||||||
save_map(data)
|
|
||||||
print(f"{mapping_path} -> {args.profile}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
if args.command in ("unset", "unset-root"):
|
|
||||||
mapping_key = "projects" if args.command == "unset" else "projectRoots"
|
|
||||||
mapping_path = project if args.command == "unset" else launch_path
|
|
||||||
removed = data.setdefault(mapping_key, {}).pop(str(mapping_path), None)
|
|
||||||
save_map(data)
|
|
||||||
if removed is None:
|
|
||||||
print(f"no mapping for {mapping_path}")
|
|
||||||
else:
|
|
||||||
print(f"removed {mapping_path} -> {removed}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
raise AssertionError(f"unhandled command: {args.command}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
raise SystemExit(main())
|
main()
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
function configure_pi_orca_runtime --argument-names profile
|
|
||||||
set -g pi_orca_node_executable /usr/bin/node
|
|
||||||
set -gx PI_CODING_AGENT_DIR /home/jb/.pi/agent
|
|
||||||
|
|
||||||
if test "$profile" = pi-work
|
|
||||||
set pi_orca_node_executable /home/jb/.local/share/fnm/node-versions/v22.23.2/installation/bin/node
|
|
||||||
set PI_CODING_AGENT_DIR /home/jb/.pi/profiles/cyone
|
|
||||||
end
|
|
||||||
|
|
||||||
# Keep Pi and its subprocesses on the same Node installation. Orca inherits
|
|
||||||
# fnm's PATH, which otherwise makes tool commands resolve a different Node.
|
|
||||||
set -l node_bin (path dirname "$pi_orca_node_executable")
|
|
||||||
set -gx PATH "$node_bin" $PATH
|
|
||||||
end
|
|
||||||
+9
-47
@@ -1,53 +1,15 @@
|
|||||||
#!/usr/bin/env fish
|
#!/usr/bin/env fish
|
||||||
|
|
||||||
# Select a nono profile from a trusted canonical-project map, then launch Pi in
|
# Compatibility entry point for existing Orca configuration.
|
||||||
# its own bounded cgroup. Unknown or invalid projects fail closed.
|
set -l launcher (command -s pi-sbx)
|
||||||
set -l resolver /home/jb/.local/bin/orca-nono-profile
|
if test -z "$launcher"
|
||||||
|
set -l script_directory (path dirname (status filename))
|
||||||
|
set launcher "$script_directory/pi-sbx.fish"
|
||||||
|
end
|
||||||
|
|
||||||
if not test -x "$resolver"
|
if not test -x "$launcher"
|
||||||
printf 'pi-orca: profile resolver is unavailable: %s\n' "$resolver" >&2
|
printf 'pi-orca: pi-sbx is unavailable: %s\n' "$launcher" >&2
|
||||||
exit 1
|
exit 1
|
||||||
end
|
end
|
||||||
|
|
||||||
set -l profile ("$resolver" resolve "$PWD" 2>/dev/null)
|
exec "$launcher" $argv
|
||||||
if test $status -ne 0; or test -z "$profile"
|
|
||||||
printf 'pi-orca: could not resolve a trusted profile for %s\n' "$PWD" >&2
|
|
||||||
exit 1
|
|
||||||
end
|
|
||||||
|
|
||||||
if not /usr/bin/nono profile show "$profile" >/dev/null 2>&1
|
|
||||||
printf 'pi-orca: resolved profile is unavailable: %s\n' "$profile" >&2
|
|
||||||
exit 1
|
|
||||||
end
|
|
||||||
|
|
||||||
source /srv/data/code/harness-tools/scripts/pi-orca-runtime.fish
|
|
||||||
configure_pi_orca_runtime "$profile"
|
|
||||||
|
|
||||||
set -l node_executable "$pi_orca_node_executable"
|
|
||||||
set -l pi_agent_dir "$PI_CODING_AGENT_DIR"
|
|
||||||
|
|
||||||
if not test -x "$node_executable"
|
|
||||||
printf 'pi-orca: Node runtime is unavailable: %s\n' "$node_executable" >&2
|
|
||||||
exit 1
|
|
||||||
end
|
|
||||||
if not test -d "$pi_agent_dir"
|
|
||||||
printf 'pi-orca: Pi agent directory is unavailable: %s\n' "$pi_agent_dir" >&2
|
|
||||||
exit 1
|
|
||||||
end
|
|
||||||
|
|
||||||
set -lx ORCA_NONO_PROFILE "$profile"
|
|
||||||
set -lx PI_CODING_AGENT_DIR "$pi_agent_dir"
|
|
||||||
|
|
||||||
exec /usr/bin/systemd-run \
|
|
||||||
--user \
|
|
||||||
--scope \
|
|
||||||
--collect \
|
|
||||||
--quiet \
|
|
||||||
--slice=orca-agents.slice \
|
|
||||||
--property=CPUQuota=1600% \
|
|
||||||
--property=MemoryHigh=48G \
|
|
||||||
--property=MemoryMax=64G \
|
|
||||||
--property=MemorySwapMax=8G \
|
|
||||||
--property=TasksMax=8192 \
|
|
||||||
--property=OOMPolicy=stop \
|
|
||||||
-- /usr/bin/nono run --profile "$profile" -- "$node_executable" /home/jb/.bun/bin/pi $argv
|
|
||||||
|
|||||||
Executable
+236
@@ -0,0 +1,236 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Manage directory-to-profile mappings for pi-sbx."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
DEFAULT_CONFIG_PATH = Path("~/.config/pi-sbx/config.json")
|
||||||
|
RUNTIME_FIELDS = ("nodeExecutable", "piExecutable", "piAgentDirectory")
|
||||||
|
|
||||||
|
|
||||||
|
def config_path() -> Path:
|
||||||
|
override = os.environ.get("PI_SBX_CONFIG")
|
||||||
|
if override:
|
||||||
|
selected_path = Path(override).expanduser()
|
||||||
|
if not selected_path.is_absolute():
|
||||||
|
raise ValueError("PI_SBX_CONFIG must be an absolute path or start with ~")
|
||||||
|
return selected_path
|
||||||
|
|
||||||
|
xdg_config_home = os.environ.get("XDG_CONFIG_HOME")
|
||||||
|
if xdg_config_home:
|
||||||
|
selected_directory = Path(xdg_config_home).expanduser()
|
||||||
|
if selected_directory.is_absolute():
|
||||||
|
return selected_directory / "pi-sbx" / "config.json"
|
||||||
|
|
||||||
|
return DEFAULT_CONFIG_PATH.expanduser()
|
||||||
|
|
||||||
|
|
||||||
|
def validate_runtime(runtime: Any, field_name: str, *, require_all: bool) -> None:
|
||||||
|
if not isinstance(runtime, dict):
|
||||||
|
raise ValueError(f"{field_name} must be a JSON object")
|
||||||
|
|
||||||
|
unknown_fields = set(runtime) - set(RUNTIME_FIELDS)
|
||||||
|
if unknown_fields:
|
||||||
|
unknown = ", ".join(sorted(unknown_fields))
|
||||||
|
raise ValueError(f"{field_name} contains unknown fields: {unknown}")
|
||||||
|
|
||||||
|
if require_all:
|
||||||
|
missing_fields = set(RUNTIME_FIELDS) - set(runtime)
|
||||||
|
if missing_fields:
|
||||||
|
missing = ", ".join(sorted(missing_fields))
|
||||||
|
raise ValueError(f"{field_name} is missing fields: {missing}")
|
||||||
|
|
||||||
|
for key, value in runtime.items():
|
||||||
|
if not isinstance(value, str) or not value:
|
||||||
|
raise ValueError(f"{field_name}.{key} must be a non-empty path")
|
||||||
|
if not Path(value).expanduser().is_absolute():
|
||||||
|
raise ValueError(f"{field_name}.{key} must be an absolute path or start with ~")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_config(data: Any) -> dict[str, Any]:
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError("configuration must be a JSON object")
|
||||||
|
|
||||||
|
default_profile = data.get("defaultProfile")
|
||||||
|
if not isinstance(default_profile, str) or not default_profile:
|
||||||
|
raise ValueError("defaultProfile must be a non-empty profile name")
|
||||||
|
|
||||||
|
directories = data.get("directories")
|
||||||
|
if not isinstance(directories, dict):
|
||||||
|
raise ValueError("directories must be a JSON object")
|
||||||
|
for directory, profile in directories.items():
|
||||||
|
if not isinstance(directory, str):
|
||||||
|
raise ValueError("directories keys must be canonical absolute paths")
|
||||||
|
configured_path = Path(directory).expanduser()
|
||||||
|
if not configured_path.is_absolute() or str(configured_path.resolve()) != directory:
|
||||||
|
raise ValueError("directories keys must be canonical absolute physical paths")
|
||||||
|
if not isinstance(profile, str) or not profile:
|
||||||
|
raise ValueError("directories values must be non-empty profile names")
|
||||||
|
|
||||||
|
validate_runtime(data.get("defaultRuntime"), "defaultRuntime", require_all=True)
|
||||||
|
|
||||||
|
profile_runtimes = data.get("profileRuntimes", {})
|
||||||
|
if not isinstance(profile_runtimes, dict):
|
||||||
|
raise ValueError("profileRuntimes must be a JSON object")
|
||||||
|
for profile, runtime in profile_runtimes.items():
|
||||||
|
if not isinstance(profile, str) or not profile:
|
||||||
|
raise ValueError("profileRuntimes keys must be non-empty profile names")
|
||||||
|
validate_runtime(runtime, f"profileRuntimes.{profile}", require_all=False)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path: Path | None = None) -> dict[str, Any]:
|
||||||
|
selected_path = path or config_path()
|
||||||
|
try:
|
||||||
|
contents = selected_path.read_text(encoding="utf-8")
|
||||||
|
data = json.loads(contents)
|
||||||
|
except (OSError, json.JSONDecodeError) as error:
|
||||||
|
raise ValueError(f"cannot load configuration from {selected_path}: {error}") from error
|
||||||
|
return validate_config(data)
|
||||||
|
|
||||||
|
|
||||||
|
def save_config(data: dict[str, Any], path: Path | None = None) -> None:
|
||||||
|
validate_config(data)
|
||||||
|
selected_path = path or config_path()
|
||||||
|
selected_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
descriptor, temporary_name = tempfile.mkstemp(prefix=".config.", dir=selected_path.parent)
|
||||||
|
temporary = Path(temporary_name)
|
||||||
|
try:
|
||||||
|
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
||||||
|
json.dump(data, handle, indent=2, sort_keys=True)
|
||||||
|
handle.write("\n")
|
||||||
|
temporary.chmod(0o600)
|
||||||
|
os.replace(temporary, selected_path)
|
||||||
|
finally:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_profile(data: dict[str, Any], path: Path) -> tuple[str, str, Path | None]:
|
||||||
|
launch_path = path.expanduser().resolve()
|
||||||
|
matches: list[tuple[Path, str]] = []
|
||||||
|
|
||||||
|
for directory_value, profile in data["directories"].items():
|
||||||
|
directory = Path(directory_value).expanduser().resolve()
|
||||||
|
if launch_path == directory or directory in launch_path.parents:
|
||||||
|
matches.append((directory, profile))
|
||||||
|
|
||||||
|
if matches:
|
||||||
|
directory, profile = max(matches, key=lambda match: len(match[0].parts))
|
||||||
|
return profile, "directory", directory
|
||||||
|
|
||||||
|
return data["defaultProfile"], "default", None
|
||||||
|
|
||||||
|
|
||||||
|
def runtime_for_profile(data: dict[str, Any], profile: str) -> dict[str, str]:
|
||||||
|
runtime = dict(data["defaultRuntime"])
|
||||||
|
runtime.update(data.get("profileRuntimes", {}).get(profile, {}))
|
||||||
|
return {key: str(Path(runtime[key]).expanduser()) for key in RUNTIME_FIELDS}
|
||||||
|
|
||||||
|
|
||||||
|
def resolved_configuration(data: dict[str, Any], path: Path) -> dict[str, Any]:
|
||||||
|
launch_path = path.expanduser().resolve()
|
||||||
|
profile, source, directory = resolve_profile(data, launch_path)
|
||||||
|
return {
|
||||||
|
"launchPath": str(launch_path),
|
||||||
|
"matchedDirectory": str(directory) if directory else None,
|
||||||
|
"profile": profile,
|
||||||
|
"runtime": runtime_for_profile(data, profile),
|
||||||
|
"source": source,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def profile_exists(profile: str) -> bool:
|
||||||
|
result = subprocess.run(
|
||||||
|
["nono", "profile", "show", profile],
|
||||||
|
check=False,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
return result.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def create_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
resolve_parser = subparsers.add_parser("resolve")
|
||||||
|
resolve_parser.add_argument("path", nargs="?", default=os.getcwd())
|
||||||
|
resolve_parser.add_argument(
|
||||||
|
"--format",
|
||||||
|
choices=("profile", "launcher", "json"),
|
||||||
|
default="profile",
|
||||||
|
)
|
||||||
|
|
||||||
|
show_parser = subparsers.add_parser("show")
|
||||||
|
show_parser.add_argument("path", nargs="?", default=os.getcwd())
|
||||||
|
|
||||||
|
set_parser = subparsers.add_parser("set")
|
||||||
|
set_parser.add_argument("profile")
|
||||||
|
set_parser.add_argument("path", nargs="?", default=os.getcwd())
|
||||||
|
|
||||||
|
unset_parser = subparsers.add_parser("unset")
|
||||||
|
unset_parser.add_argument("path", nargs="?", default=os.getcwd())
|
||||||
|
|
||||||
|
subparsers.add_parser("list")
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = create_parser()
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = load_config()
|
||||||
|
except ValueError as error:
|
||||||
|
parser.error(str(error))
|
||||||
|
|
||||||
|
if args.command == "list":
|
||||||
|
print(json.dumps(data, indent=2, sort_keys=True))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
path = Path(args.path).expanduser().resolve()
|
||||||
|
|
||||||
|
if args.command in ("resolve", "show"):
|
||||||
|
resolved = resolved_configuration(data, path)
|
||||||
|
if args.command == "show" or args.format == "json":
|
||||||
|
print(json.dumps(resolved, indent=2, sort_keys=True))
|
||||||
|
elif args.format == "launcher":
|
||||||
|
runtime = resolved["runtime"]
|
||||||
|
print(resolved["profile"])
|
||||||
|
print(runtime["nodeExecutable"])
|
||||||
|
print(runtime["piExecutable"])
|
||||||
|
print(runtime["piAgentDirectory"])
|
||||||
|
else:
|
||||||
|
print(resolved["profile"])
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if args.command == "set":
|
||||||
|
if not profile_exists(args.profile):
|
||||||
|
parser.error(f"nono profile does not exist: {args.profile}")
|
||||||
|
data["directories"][str(path)] = args.profile
|
||||||
|
save_config(data)
|
||||||
|
print(f"{path} -> {args.profile}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if args.command == "unset":
|
||||||
|
removed = data["directories"].pop(str(path), None)
|
||||||
|
save_config(data)
|
||||||
|
if removed is None:
|
||||||
|
print(f"no mapping for {path}")
|
||||||
|
else:
|
||||||
|
print(f"removed {path} -> {removed}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
raise AssertionError(f"unhandled command: {args.command}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Executable
+69
@@ -0,0 +1,69 @@
|
|||||||
|
#!/usr/bin/env fish
|
||||||
|
|
||||||
|
# Select a trusted nono profile and matching Pi runtime from the current
|
||||||
|
# directory, then launch Pi in a bounded systemd scope.
|
||||||
|
set -l resolver
|
||||||
|
if set -q PI_SBX_PROFILE_COMMAND
|
||||||
|
set resolver "$PI_SBX_PROFILE_COMMAND"
|
||||||
|
else
|
||||||
|
set resolver (command -s pi-sbx-profile)
|
||||||
|
end
|
||||||
|
|
||||||
|
if test -z "$resolver"; or not test -x "$resolver"
|
||||||
|
printf 'pi-sbx: profile resolver is unavailable: %s\n' "$resolver" >&2
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
|
||||||
|
set -l resolved ("$resolver" resolve "$PWD" --format launcher 2>/dev/null)
|
||||||
|
if test $status -ne 0; or test (count $resolved) -ne 4
|
||||||
|
printf 'pi-sbx: could not resolve a valid configuration for %s\n' "$PWD" >&2
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
|
||||||
|
set -l profile "$resolved[1]"
|
||||||
|
set -l node_executable "$resolved[2]"
|
||||||
|
set -l pi_executable "$resolved[3]"
|
||||||
|
set -l pi_agent_dir "$resolved[4]"
|
||||||
|
set -l nono_executable (command -s nono)
|
||||||
|
|
||||||
|
if test -z "$nono_executable"; or not test -x "$nono_executable"
|
||||||
|
printf 'pi-sbx: nono is unavailable on PATH\n' >&2
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
if not "$nono_executable" profile show "$profile" >/dev/null 2>&1
|
||||||
|
printf 'pi-sbx: resolved nono profile is unavailable: %s\n' "$profile" >&2
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
if not test -x "$node_executable"
|
||||||
|
printf 'pi-sbx: Node runtime is unavailable: %s\n' "$node_executable" >&2
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
if not test -f "$pi_executable"
|
||||||
|
printf 'pi-sbx: Pi executable is unavailable: %s\n' "$pi_executable" >&2
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
if not test -d "$pi_agent_dir"
|
||||||
|
printf 'pi-sbx: Pi agent directory is unavailable: %s\n' "$pi_agent_dir" >&2
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
|
||||||
|
set -l node_bin (path dirname "$node_executable")
|
||||||
|
set -gx PATH "$node_bin" $PATH
|
||||||
|
set -lx PI_SBX_PROFILE "$profile"
|
||||||
|
set -lx PI_CODING_AGENT_DIR "$pi_agent_dir"
|
||||||
|
# Keep Orca hook integrations working while callers migrate to PI_SBX_PROFILE.
|
||||||
|
set -lx ORCA_NONO_PROFILE "$profile"
|
||||||
|
|
||||||
|
exec /usr/bin/systemd-run \
|
||||||
|
--user \
|
||||||
|
--scope \
|
||||||
|
--collect \
|
||||||
|
--quiet \
|
||||||
|
--slice=pi-agents.slice \
|
||||||
|
--property=CPUQuota=1600% \
|
||||||
|
--property=MemoryHigh=48G \
|
||||||
|
--property=MemoryMax=64G \
|
||||||
|
--property=MemorySwapMax=8G \
|
||||||
|
--property=TasksMax=8192 \
|
||||||
|
--property=OOMPolicy=stop \
|
||||||
|
-- "$nono_executable" run --profile "$profile" -- "$node_executable" "$pi_executable" $argv
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import importlib.util
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
MODULE_PATH = Path(__file__).with_name("orca-nono-profile.py")
|
|
||||||
SPEC = importlib.util.spec_from_file_location("orca_nono_profile", MODULE_PATH)
|
|
||||||
if SPEC is None or SPEC.loader is None:
|
|
||||||
raise RuntimeError(f"Could not load runner resolver from {MODULE_PATH}")
|
|
||||||
MODULE = importlib.util.module_from_spec(SPEC)
|
|
||||||
SPEC.loader.exec_module(MODULE)
|
|
||||||
|
|
||||||
|
|
||||||
class ResolveProfileTests(unittest.TestCase):
|
|
||||||
def setUp(self) -> None:
|
|
||||||
self.mapping = {
|
|
||||||
"defaultProfile": "pi-orca-local",
|
|
||||||
"projectRoots": {
|
|
||||||
"/srv/work": "pi-work",
|
|
||||||
"/srv/work/special": "pi-special",
|
|
||||||
},
|
|
||||||
"projects": {
|
|
||||||
"/srv/work/special/override": "pi-override",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def test_exact_project_mapping_wins(self) -> None:
|
|
||||||
self.assertEqual(
|
|
||||||
MODULE.resolve_profile(self.mapping, Path("/srv/work/special/override")),
|
|
||||||
("pi-override", "project"),
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_longest_project_root_wins(self) -> None:
|
|
||||||
self.assertEqual(
|
|
||||||
MODULE.resolve_profile(self.mapping, Path("/srv/work/special/repository")),
|
|
||||||
("pi-special", "project-root"),
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_project_root_matches_descendants(self) -> None:
|
|
||||||
self.assertEqual(
|
|
||||||
MODULE.resolve_profile(self.mapping, Path("/srv/work/code/repository")),
|
|
||||||
("pi-work", "project-root"),
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_linked_worktree_uses_its_launch_path_root(self) -> None:
|
|
||||||
self.assertEqual(
|
|
||||||
MODULE.resolve_profile(
|
|
||||||
self.mapping,
|
|
||||||
Path("/home/jb/code/main-checkout"),
|
|
||||||
Path("/srv/work/worktrees/feature"),
|
|
||||||
),
|
|
||||||
("pi-work", "project-root"),
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_project_root_does_not_match_similar_prefix(self) -> None:
|
|
||||||
self.assertEqual(
|
|
||||||
MODULE.resolve_profile(self.mapping, Path("/srv/worker/repository")),
|
|
||||||
("pi-orca-local", "default"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
RUNTIME_SCRIPT = Path(__file__).with_name("pi-orca-runtime.fish")
|
|
||||||
SYSTEM_NODE = Path("/usr/bin/node")
|
|
||||||
WORK_NODE = Path(
|
|
||||||
"/home/jb/.local/share/fnm/node-versions/v22.23.2/installation/bin/node"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigurePiOrcaRuntimeTests(unittest.TestCase):
|
|
||||||
def configure_runtime(self, profile: str) -> list[str]:
|
|
||||||
environment = os.environ.copy()
|
|
||||||
environment["PATH"] = ":".join(
|
|
||||||
[str(WORK_NODE.parent), "/usr/local/bin", "/usr/bin"]
|
|
||||||
)
|
|
||||||
command = """
|
|
||||||
source $argv[1]
|
|
||||||
configure_pi_orca_runtime $argv[2]
|
|
||||||
printf '%s\\n' $pi_orca_node_executable $PI_CODING_AGENT_DIR $PATH[1] (command -v node)
|
|
||||||
"""
|
|
||||||
result = subprocess.run(
|
|
||||||
["/usr/bin/fish", "-c", command, str(RUNTIME_SCRIPT), profile],
|
|
||||||
check=True,
|
|
||||||
capture_output=True,
|
|
||||||
env=environment,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
return result.stdout.splitlines()
|
|
||||||
|
|
||||||
def test_default_profile_prioritizes_system_node(self) -> None:
|
|
||||||
self.assertEqual(
|
|
||||||
self.configure_runtime("pi-harness-tools"),
|
|
||||||
[
|
|
||||||
str(SYSTEM_NODE),
|
|
||||||
"/home/jb/.pi/agent",
|
|
||||||
str(SYSTEM_NODE.parent),
|
|
||||||
str(SYSTEM_NODE),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_work_profile_prioritizes_node_22(self) -> None:
|
|
||||||
self.assertEqual(
|
|
||||||
self.configure_runtime("pi-work"),
|
|
||||||
[
|
|
||||||
str(WORK_NODE),
|
|
||||||
"/home/jb/.pi/profiles/cyone",
|
|
||||||
str(WORK_NODE.parent),
|
|
||||||
str(WORK_NODE),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
LAUNCHER = Path(__file__).with_name("pi-sbx.fish")
|
||||||
|
|
||||||
|
|
||||||
|
class PiSbxLauncherTests(unittest.TestCase):
|
||||||
|
def run_launcher_with_resolver(self, output: str) -> subprocess.CompletedProcess[str]:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
resolver = Path(directory) / "resolver"
|
||||||
|
resolver.write_text(f"#!/bin/sh\nprintf '%s' '{output}'\n", encoding="utf-8")
|
||||||
|
resolver.chmod(0o755)
|
||||||
|
environment = os.environ.copy()
|
||||||
|
environment["PI_SBX_PROFILE_COMMAND"] = str(resolver)
|
||||||
|
return subprocess.run(
|
||||||
|
["/usr/bin/fish", str(LAUNCHER)],
|
||||||
|
env=environment,
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rejects_incomplete_launcher_configuration(self) -> None:
|
||||||
|
result = self.run_launcher_with_resolver("pi-local\n/usr/bin/node\n")
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 1)
|
||||||
|
self.assertIn("could not resolve a valid configuration", result.stderr)
|
||||||
|
|
||||||
|
def test_rejects_unavailable_profile(self) -> None:
|
||||||
|
result = self.run_launcher_with_resolver(
|
||||||
|
"profile-that-does-not-exist\n/usr/bin/node\n/home/jb/.bun/bin/pi\n/home/jb/.pi/agent\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 1)
|
||||||
|
self.assertIn("resolved nono profile is unavailable", result.stderr)
|
||||||
|
|
||||||
|
def test_uses_nono_from_path(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
temporary = Path(directory)
|
||||||
|
resolver = temporary / "resolver"
|
||||||
|
resolver.write_text(
|
||||||
|
"#!/bin/sh\nprintf '%b' 'pi-local\\n/missing/node\\n/missing/pi\\n/missing/agent\\n'\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
resolver.chmod(0o755)
|
||||||
|
nono = temporary / "nono"
|
||||||
|
nono.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
||||||
|
nono.chmod(0o755)
|
||||||
|
environment = os.environ.copy()
|
||||||
|
environment["PI_SBX_PROFILE_COMMAND"] = str(resolver)
|
||||||
|
environment["PATH"] = f"{temporary}:{environment['PATH']}"
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
["/usr/bin/fish", str(LAUNCHER)],
|
||||||
|
env=environment,
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 1)
|
||||||
|
self.assertIn("Node runtime is unavailable", result.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
MODULE_PATH = Path(__file__).with_name("pi-sbx-profile.py")
|
||||||
|
SPEC = importlib.util.spec_from_file_location("pi_sbx_profile", MODULE_PATH)
|
||||||
|
if SPEC is None or SPEC.loader is None:
|
||||||
|
raise RuntimeError(f"Could not load profile resolver from {MODULE_PATH}")
|
||||||
|
MODULE = importlib.util.module_from_spec(SPEC)
|
||||||
|
SPEC.loader.exec_module(MODULE)
|
||||||
|
|
||||||
|
|
||||||
|
class PiSbxProfileTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.config = {
|
||||||
|
"defaultProfile": "pi-local",
|
||||||
|
"directories": {
|
||||||
|
"/srv/work": "pi-work",
|
||||||
|
"/srv/work/special": "pi-special",
|
||||||
|
},
|
||||||
|
"defaultRuntime": {
|
||||||
|
"nodeExecutable": "/usr/bin/node",
|
||||||
|
"piExecutable": "~/.bun/bin/pi",
|
||||||
|
"piAgentDirectory": "~/.pi/agent",
|
||||||
|
},
|
||||||
|
"profileRuntimes": {
|
||||||
|
"pi-work": {
|
||||||
|
"nodeExecutable": "/opt/node-22/bin/node",
|
||||||
|
"piAgentDirectory": "~/.pi/profiles/work",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_longest_directory_match_wins(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
MODULE.resolve_profile(self.config, Path("/srv/work/special/repository")),
|
||||||
|
("pi-special", "directory", Path("/srv/work/special")),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_directory_matches_descendants(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
MODULE.resolve_profile(self.config, Path("/srv/work/code/repository")),
|
||||||
|
("pi-work", "directory", Path("/srv/work")),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_directory_does_not_match_similar_prefix(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
MODULE.resolve_profile(self.config, Path("/srv/worker/repository")),
|
||||||
|
("pi-local", "default", None),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_profile_runtime_overrides_defaults(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
MODULE.runtime_for_profile(self.config, "pi-work"),
|
||||||
|
{
|
||||||
|
"nodeExecutable": "/opt/node-22/bin/node",
|
||||||
|
"piExecutable": str(Path("~/.bun/bin/pi").expanduser()),
|
||||||
|
"piAgentDirectory": str(Path("~/.pi/profiles/work").expanduser()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_default_runtime_is_used_without_override(self) -> None:
|
||||||
|
runtime = MODULE.runtime_for_profile(self.config, "pi-special")
|
||||||
|
self.assertEqual(runtime["nodeExecutable"], "/usr/bin/node")
|
||||||
|
self.assertEqual(runtime["piAgentDirectory"], str(Path("~/.pi/agent").expanduser()))
|
||||||
|
|
||||||
|
def test_configuration_requires_complete_default_runtime(self) -> None:
|
||||||
|
del self.config["defaultRuntime"]["piExecutable"]
|
||||||
|
with self.assertRaisesRegex(ValueError, "missing fields: piExecutable"):
|
||||||
|
MODULE.validate_config(self.config)
|
||||||
|
|
||||||
|
def test_configuration_rejects_noncanonical_directory(self) -> None:
|
||||||
|
self.config["directories"] = {"~/work": "pi-local"}
|
||||||
|
with self.assertRaisesRegex(ValueError, "canonical absolute physical paths"):
|
||||||
|
MODULE.validate_config(self.config)
|
||||||
|
|
||||||
|
def test_relative_xdg_config_home_is_ignored(self) -> None:
|
||||||
|
original_xdg = os.environ.get("XDG_CONFIG_HOME")
|
||||||
|
original_override = os.environ.pop("PI_SBX_CONFIG", None)
|
||||||
|
os.environ["XDG_CONFIG_HOME"] = "repository-controlled"
|
||||||
|
try:
|
||||||
|
self.assertEqual(MODULE.config_path(), MODULE.DEFAULT_CONFIG_PATH.expanduser())
|
||||||
|
finally:
|
||||||
|
if original_xdg is None:
|
||||||
|
os.environ.pop("XDG_CONFIG_HOME", None)
|
||||||
|
else:
|
||||||
|
os.environ["XDG_CONFIG_HOME"] = original_xdg
|
||||||
|
if original_override is not None:
|
||||||
|
os.environ["PI_SBX_CONFIG"] = original_override
|
||||||
|
|
||||||
|
def test_relative_config_override_is_rejected(self) -> None:
|
||||||
|
original_override = os.environ.get("PI_SBX_CONFIG")
|
||||||
|
os.environ["PI_SBX_CONFIG"] = "config.json"
|
||||||
|
try:
|
||||||
|
with self.assertRaisesRegex(ValueError, "PI_SBX_CONFIG must be an absolute path"):
|
||||||
|
MODULE.config_path()
|
||||||
|
finally:
|
||||||
|
if original_override is None:
|
||||||
|
os.environ.pop("PI_SBX_CONFIG", None)
|
||||||
|
else:
|
||||||
|
os.environ["PI_SBX_CONFIG"] = original_override
|
||||||
|
|
||||||
|
def test_save_and_load_configuration(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "pi-sbx" / "config.json"
|
||||||
|
MODULE.save_config(self.config, path)
|
||||||
|
|
||||||
|
self.assertEqual(MODULE.load_config(path), self.config)
|
||||||
|
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
|
||||||
|
self.assertEqual(json.loads(path.read_text(encoding="utf-8")), self.config)
|
||||||
|
|
||||||
|
def test_set_without_path_assigns_current_directory(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
temporary = Path(directory)
|
||||||
|
config_path = temporary / "config.json"
|
||||||
|
workdir = temporary / "repository"
|
||||||
|
binary_directory = temporary / "bin"
|
||||||
|
workdir.mkdir()
|
||||||
|
binary_directory.mkdir()
|
||||||
|
MODULE.save_config(self.config, config_path)
|
||||||
|
|
||||||
|
nono = binary_directory / "nono"
|
||||||
|
nono.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
||||||
|
nono.chmod(0o755)
|
||||||
|
environment = os.environ.copy()
|
||||||
|
environment["PI_SBX_CONFIG"] = str(config_path)
|
||||||
|
environment["PATH"] = f"{binary_directory}:{environment['PATH']}"
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
[str(MODULE_PATH), "set", "pi-special"],
|
||||||
|
cwd=workdir,
|
||||||
|
env=environment,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result.stdout.strip(), f"{workdir} -> pi-special")
|
||||||
|
self.assertEqual(MODULE.load_config(config_path)["directories"][str(workdir)], "pi-special")
|
||||||
|
|
||||||
|
subprocess.run(
|
||||||
|
[str(MODULE_PATH), "unset"],
|
||||||
|
cwd=workdir,
|
||||||
|
env=environment,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
self.assertNotIn(str(workdir), MODULE.load_config(config_path)["directories"])
|
||||||
|
|
||||||
|
def test_legacy_set_root_command_assigns_current_directory(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
temporary = Path(directory)
|
||||||
|
config_path = temporary / "config.json"
|
||||||
|
workdir = temporary / "repository"
|
||||||
|
binary_directory = temporary / "bin"
|
||||||
|
workdir.mkdir()
|
||||||
|
binary_directory.mkdir()
|
||||||
|
MODULE.save_config(self.config, config_path)
|
||||||
|
|
||||||
|
nono = binary_directory / "nono"
|
||||||
|
nono.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
||||||
|
nono.chmod(0o755)
|
||||||
|
environment = os.environ.copy()
|
||||||
|
environment["PI_SBX_CONFIG"] = str(config_path)
|
||||||
|
environment["PATH"] = f"{binary_directory}:{environment['PATH']}"
|
||||||
|
|
||||||
|
subprocess.run(
|
||||||
|
[str(Path(__file__).with_name("orca-nono-profile.py")), "set-root", "pi-special"],
|
||||||
|
cwd=workdir,
|
||||||
|
env=environment,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(MODULE.load_config(config_path)["directories"][str(workdir)], "pi-special")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=Resource envelope for Orca-managed AI agent sessions
|
Description=Resource envelope for Pi agent sessions
|
||||||
|
|
||||||
[Slice]
|
[Slice]
|
||||||
CPUQuota=3200%
|
CPUQuota=3200%
|
||||||
Reference in New Issue
Block a user