diff --git a/.gitmodules b/.gitmodules index bc490a1..368cd16 100644 --- a/.gitmodules +++ b/.gitmodules @@ -19,3 +19,6 @@ [submodule "sysBenches/365Bench"] path = sysBenches/365Bench url = git@github.com:opensoft/365Bench.git +[submodule "devBenches/rustBench"] + path = devBenches/rustBench + url = git@github.com:opensoft/rustBench.git diff --git a/README.md b/README.md index c056903..f321158 100755 --- a/README.md +++ b/README.md @@ -235,7 +235,7 @@ The TUI Tools column includes editor, terminal, and local agent tooling: | Visual Studio Code | Windows/WSL winget, Linux/manual fallback | Dev Containers and WSL extension checks | | Warp Terminal | Windows/WSL winget, Linux/manual fallback | Windows terminal | | Wave Terminal | Windows/WSL winget, Linux/manual fallback | AI terminal | -| Pi Terminal | Windows npm from WSL, WSL/Linux npm fallback | `npm install -g --ignore-scripts @earendil-works/pi-coding-agent`; run `pi` then `/login` | +| Pi Terminal | Windows and WSL/Linux npm | `npm install -g --ignore-scripts @earendil-works/pi-coding-agent`; use isolated `ppi PROFILE` or standard `pi` | | AmneziaVPN | Windows/WSL winget | Amnezia/AmneziaWG client access | | 0dcloud VPN | local installer/manual + local patch | 0dcloud detection and MTU fix | diff --git a/apps/credential-manager/credential_manager.py b/apps/credential-manager/credential_manager.py index 38a88fb..ef9003a 100755 --- a/apps/credential-manager/credential_manager.py +++ b/apps/credential-manager/credential_manager.py @@ -32,6 +32,7 @@ class Account: status: str = "active" auth_mode: str = "browser" secret_env: str = "" + profile_path: str = "" PROVIDER_ALIASES = { @@ -93,6 +94,7 @@ def load_accounts(repo: Path) -> list[Account]: status=item.get("status", "active"), auth_mode=item.get("authMode", "browser"), secret_env=item.get("secretEnv", ""), + profile_path=item.get("profilePath", ""), ) accounts.append(account) seen.add((account.provider, account.name)) @@ -124,6 +126,7 @@ def load_accounts(repo: Path) -> list[Account]: status=item.get("status", "active"), auth_mode=item.get("authMode", "browser"), secret_env=item.get("secretEnv", ""), + profile_path=item.get("profilePath", ""), ) accounts.append(account) seen.add(identity) @@ -132,17 +135,25 @@ def load_accounts(repo: Path) -> list[Account]: def profile_home(account: Account) -> Path | None: if account.provider == "claude": - return HOME / ".claude-profiles/profiles" / account.name + return HOME / ".claude-profiles/profiles" / (account.profile_path or account.name) if account.provider == "chatgpt": - current = HOME / ".chatgpt-profiles/profiles" / account.name + current = HOME / ".chatgpt-profiles/profiles" / ( + account.profile_path or account.name + ) legacy = HOME / ".openai-profiles/profiles" / account.name return legacy if legacy.exists() and not current.exists() else current if account.provider == "grok": - return HOME / ".grok-profiles/profiles" / account.name + return HOME / ".grok-profiles/profiles" / ( + account.profile_path or account.name + ) if account.provider == "gemini": - return HOME / ".gemini-profiles/profiles" / account.name + return HOME / ".gemini-profiles/profiles" / ( + account.profile_path or account.name + ) if account.provider == "glm": - return HOME / ".glm-profiles/profiles" / account.name + return HOME / ".glm-profiles/profiles" / ( + account.profile_path or account.name + ) return None diff --git a/apps/credential-manager/test_claude_nested_profiles.py b/apps/credential-manager/test_claude_nested_profiles.py new file mode 100644 index 0000000..4c579d8 --- /dev/null +++ b/apps/credential-manager/test_claude_nested_profiles.py @@ -0,0 +1,112 @@ +import json +import os +import pathlib +import subprocess +import tempfile +import unittest + + +REPO = pathlib.Path(__file__).parents[2] + + +class ClaudeNestedProfilesTest(unittest.TestCase): + def test_setup_copies_flat_profile_and_launcher_uses_nested_path(self): + with tempfile.TemporaryDirectory(dir="/tmp") as temporary: + root = pathlib.Path(temporary) + home = root / "home" + config = home / ".config/workbenches" + config.mkdir(parents=True) + manifest = config / "claude-profiles.json" + manifest.write_text( + json.dumps( + { + "version": 1, + "profiles": [ + { + "name": "team-001", + "profilePath": "example-company/team/team-001", + "email": "team-001@example.com", + "family": "example-company", + "aliases": ["team001"], + } + ], + } + ) + ) + + flat = home / ".claude-profiles/profiles/team-001" + flat.mkdir(parents=True) + credential = {"testCredential": "preserved"} + (flat / ".credentials.json").write_text(json.dumps(credential)) + (flat / ".credentials.json").chmod(0o600) + + env = { + **os.environ, + "HOME": str(home), + "XDG_CONFIG_HOME": str(home / ".config"), + "CLAUDE_PROFILES_HOME": str(home / ".claude-profiles"), + } + subprocess.run( + [str(REPO / "scripts/setup-claude-profiles.sh"), "--manifest", str(manifest)], + env=env, + check=True, + capture_output=True, + text=True, + ) + + nested = home / ".claude-profiles/profiles/example-company/team/team-001" + self.assertTrue(flat.is_dir()) + self.assertTrue(nested.is_dir()) + self.assertEqual(json.loads((nested / ".credentials.json").read_text()), credential) + self.assertEqual( + json.loads((nested / ".profile.json").read_text())["profilePath"], + "example-company/team/team-001", + ) + self.assertEqual( + (nested / "projects").resolve(), + home / ".claude-profiles/state/example-company/projects", + ) + for category in ("team", "max", "xfactor"): + self.assertTrue( + (home / f".claude-profiles/profiles/example-company/{category}").is_dir() + ) + + retained_group = home / ".claude-profiles/profiles/team/team-001" + retained_group.mkdir(parents=True) + (retained_group / ".profile.json").write_text( + json.dumps( + { + "name": "team-001", + "profilePath": "team/team-001", + "email": "team-001@example.com", + "family": "example-company", + "aliases": ["team001"], + } + ) + ) + + capture = root / "capture" + fake_claude = root / "claude" + fake_claude.write_text( + '#!/bin/sh\nprintf "%s" "$CLAUDE_CONFIG_DIR" > "$CAPTURE"\n' + ) + fake_claude.chmod(0o755) + env.update({"CLAUDE_BIN": str(fake_claude), "CAPTURE": str(capture)}) + subprocess.run( + [str(REPO / "base-image/files/claude-profile"), "status", "team001"], + env=env, + check=True, + ) + self.assertEqual(capture.read_text(), str(nested)) + + env["CLAUDE_PROFILES_MANIFEST"] = str(root / "missing-manifest.json") + subprocess.run( + [str(REPO / "base-image/files/claude-profile"), "status", "team001"], + env=env, + check=True, + ) + self.assertEqual(capture.read_text(), str(nested)) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/credential-manager/test_generic_manager.py b/apps/credential-manager/test_generic_manager.py index a759d80..6d4e813 100644 --- a/apps/credential-manager/test_generic_manager.py +++ b/apps/credential-manager/test_generic_manager.py @@ -43,6 +43,34 @@ def test_legacy_provider_names_are_normalized(self): self.assertEqual(MANAGER.canonical_provider("gemini"), "gemini") self.assertEqual(MANAGER.canonical_provider("zai"), "glm") + def test_profile_home_uses_nested_manifest_path_for_managed_providers(self): + original_home = MANAGER.HOME + try: + with tempfile.TemporaryDirectory() as directory: + MANAGER.HOME = Path(directory) + cases = { + "claude": ".claude-profiles", + "chatgpt": ".chatgpt-profiles", + "grok": ".grok-profiles", + "gemini": ".gemini-profiles", + "glm": ".glm-profiles", + } + for provider, root in cases.items(): + account = MANAGER.Account( + provider=provider, + name="team-001", + email="user@company.example", + profile_path="opensoft/team/team-001", + ) + self.assertEqual( + MANAGER.profile_home(account), + Path(directory) + / root + / "profiles/opensoft/team/team-001", + ) + finally: + MANAGER.HOME = original_home + def test_direct_workstation_config_directory_is_supported(self): with tempfile.TemporaryDirectory() as directory: config = Path(directory) @@ -84,7 +112,7 @@ def test_claude_example_provides_work_and_personal_profiles(self): (REPO / "config/claude-profiles.example.json").read_text() ) profiles = {(profile["name"], profile["family"]) for profile in data["profiles"]} - self.assertIn(("work", "work"), profiles) + self.assertIn(("work", "company"), profiles) self.assertIn(("personal", "personal"), profiles) def test_codex_example_provides_work_and_personal_profiles(self): @@ -92,7 +120,7 @@ def test_codex_example_provides_work_and_personal_profiles(self): (REPO / "config/openai-profiles.example.json").read_text() ) profiles = {(profile["name"], profile["family"]) for profile in data["profiles"]} - self.assertIn(("work-chatgpt-1", "work"), profiles) + self.assertIn(("work-chatgpt-1", "company"), profiles) self.assertIn(("personal-chatgpt-1", "personal"), profiles) def test_codex_profile_setup_and_alias_isolate_codex_home(self): @@ -113,7 +141,8 @@ def test_codex_profile_setup_and_alias_isolate_codex_home(self): "version": 1, "profiles": [{ "name": "work-chatgpt-1", - "family": "work", + "profilePath": "example-company/team/work-chatgpt-1", + "family": "example-company", "email": "user@company.example", "aliases": ["work1"], }], @@ -149,7 +178,7 @@ def test_codex_profile_setup_and_alias_isolate_codex_home(self): text=True, ) - profile = home / ".chatgpt-profiles/profiles/work-chatgpt-1" + profile = home / ".chatgpt-profiles/profiles/example-company/team/work-chatgpt-1" profile_config = (profile / "config.toml").read_text() self.assertEqual((capture / "home").read_text().strip(), str(profile)) self.assertIn('forced_login_method = "chatgpt"', profile_config) @@ -160,6 +189,10 @@ def test_codex_profile_setup_and_alias_isolate_codex_home(self): ) self.assertEqual(profile.stat().st_mode & 0o777, 0o700) self.assertEqual((profile / ".profile.json").stat().st_mode & 0o777, 0o600) + for category in ("team", "max", "xfactor"): + self.assertTrue( + (home / f".chatgpt-profiles/profiles/example-company/{category}").is_dir() + ) def test_public_account_surface_has_no_private_identifiers(self): targets = [ @@ -179,9 +212,7 @@ def test_public_account_surface_has_no_private_identifiers(self): ] forbidden = ( "br" + "ett", - "open" + "soft", "far" + "heap", - "med" + "x", ) violations: list[str] = [] for target in targets: diff --git a/apps/credential-manager/test_pi_profiles.py b/apps/credential-manager/test_pi_profiles.py new file mode 100644 index 0000000..bbb2bb3 --- /dev/null +++ b/apps/credential-manager/test_pi_profiles.py @@ -0,0 +1,143 @@ +import json +import os +import pathlib +import subprocess +import tempfile +import unittest + + +REPO = pathlib.Path(__file__).parents[2] + + +class PiProfilesTest(unittest.TestCase): + def test_composition_setup_alias_and_isolation_are_idempotent(self): + with tempfile.TemporaryDirectory(dir="/tmp") as temporary: + root = pathlib.Path(temporary) + home = root / "home" + config = home / ".config/workbenches" + config.mkdir(parents=True) + profile = { + "name": "team-001", + "email": "team-001@example.com", + "family": "example-company", + "profilePath": "example-company/team/team-001", + "aliases": ["team001"], + } + for provider in ("claude", "openai", "gemini", "grok", "glm"): + (config / f"{provider}-profiles.json").write_text( + json.dumps({"version": 1, "profiles": [profile]}), encoding="utf-8" + ) + pi_manifest = config / "pi-profiles.json" + subprocess.run( + [ + str(REPO / "scripts/compose-pi-profiles.py"), + "--config-dir", str(config), "--output", str(pi_manifest), + ], check=True, capture_output=True, text=True, + ) + fake_pi = root / "pi" + fake_pi.write_text('#!/bin/sh\nprintf "%s" "$PI_CODING_AGENT_DIR" > "$CAPTURE"\n') + fake_pi.chmod(0o755) + claude_home = home / ".claude-profiles/profiles/example-company/team/team-001" + claude_home.mkdir(parents=True) + (claude_home / ".profile.json").write_text( + json.dumps(profile), encoding="utf-8" + ) + env = { + **os.environ, + "HOME": str(home), + "XDG_CONFIG_HOME": str(home / ".config"), + "PI_BIN": str(fake_pi), + "CAPTURE": str(root / "capture"), + } + setup = REPO / "scripts/setup-pi-profiles.sh" + for _ in range(2): + subprocess.run([str(setup), "--manifest", str(pi_manifest)], env=env, check=True, capture_output=True, text=True) + launcher = home / ".local/bin/ppi" + subprocess.run([str(launcher), "team001"], env=env, check=True) + expected = home / ".pi-profiles/profiles/example-company/team/team-001/agent" + self.assertEqual((root / "capture").read_text(), str(expected)) + self.assertEqual(expected.stat().st_mode & 0o777, 0o700) + self.assertEqual((expected / "settings.json").stat().st_mode & 0o777, 0o600) + self.assertFalse((expected / "auth.json").exists()) + self.assertEqual( + json.loads(pi_manifest.read_text())["profiles"][0]["providers"], + ["claude", "openai", "gemini", "grok", "glm"], + ) + settings = json.loads((expected / "settings.json").read_text()) + self.assertEqual(settings["defaultProvider"], "pi-claude-cli") + self.assertEqual(settings["defaultModel"], "claude-fable-5") + self.assertIn("npm:@ramarivera/pi-claude-cli@0.3.1", settings["packages"]) + for category in ("team", "max", "xfactor"): + self.assertTrue( + (home / f".pi-profiles/profiles/example-company/{category}").is_dir() + ) + self.assertTrue( + (home / ".pi-profiles/state/example-company/sessions").is_dir() + ) + + env_capture = root / "env-capture" + fake_pi.write_text( + '#!/bin/sh\nprintf "%s\n%s\n" "$PI_CODING_AGENT_DIR" "$CLAUDE_CONFIG_DIR" > "$ENV_CAPTURE"\n' + ) + env["ENV_CAPTURE"] = str(env_capture) + subprocess.run([str(launcher), "team001"], env=env, check=True) + self.assertEqual(env_capture.read_text().splitlines(), [str(expected), str(claude_home)]) + + def test_composition_rejects_cross_provider_identity_mismatch(self): + with tempfile.TemporaryDirectory(dir="/tmp") as temporary: + config = pathlib.Path(temporary) + base = {"name": "team-001", "email": "one@example.com", "family": "company", "aliases": []} + (config / "claude-profiles.json").write_text(json.dumps({"version": 1, "profiles": [base]})) + changed = {**base, "email": "two@example.com"} + (config / "openai-profiles.json").write_text(json.dumps({"version": 1, "profiles": [changed]})) + result = subprocess.run( + [str(REPO / "scripts/compose-pi-profiles.py"), "--config-dir", str(config), "--check"], + capture_output=True, text=True, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("identity mismatch", result.stderr) + + def test_pi_escrow_round_trip_uses_separate_harness_secret(self): + with tempfile.TemporaryDirectory(dir="/tmp") as temporary: + root = pathlib.Path(temporary) + repo = root / "registry" + (repo / ".git").mkdir(parents=True) + (repo / "ai").mkdir() + (repo / "ai/source.json").write_text(json.dumps({ + "profiles": {"claude": [{"name": "team-001"}]} + })) + home = root / "home" + auth = home / ".pi-profiles/profiles/team-001/agent/auth.json" + auth.parent.mkdir(parents=True) + expected = {"anthropic": {"type": "oauth", "access": "a", "refresh": "r", "expires": 123}} + auth.write_text(json.dumps(expected)) + auth.chmod(0o600) + identity = root / "recovery.agekey" + identity.write_text("test") + identity.chmod(0o600) + bin_dir = root / "bin" + bin_dir.mkdir() + sops = bin_dir / "sops" + sops.write_text( + "#!/bin/sh\n" + "last=''\nfor arg in \"$@\"; do last=$arg; done\ncat \"$last\"\n" + ) + sops.chmod(0o755) + env = {**os.environ, "HOME": str(home), "PATH": f"{bin_dir}:{os.environ['PATH']}"} + command = [ + str(REPO / "scripts/pi-credential-escrow"), + "backup", "--repo", str(repo), "--profile", "team-001", + "--identity-file", str(identity), + ] + subprocess.run(command, env=env, check=True, capture_output=True, text=True) + secret = repo / "ai/secrets/pi/team-001.auth.sops.yaml" + self.assertTrue(secret.exists()) + auth.unlink() + subprocess.run( + [command[0], "restore", *command[2:]], env=env, check=True, capture_output=True, text=True + ) + self.assertEqual(json.loads(auth.read_text()), expected) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/credential-manager/test_profile_onboarding.py b/apps/credential-manager/test_profile_onboarding.py index 9802db4..7c2feb7 100644 --- a/apps/credential-manager/test_profile_onboarding.py +++ b/apps/credential-manager/test_profile_onboarding.py @@ -36,6 +36,13 @@ def test_manual_company_and_personal_profiles_cover_selected_providers(self): "githubOrg": "example-company", "providers": ["all"], "registry": "manual", + }, + { + "name": "Second Company", + "email": "engineer@second.example", + "githubOrg": "second-company", + "providers": ["claude", "openai"], + "registry": "manual", } ], "personal": { @@ -53,14 +60,69 @@ def test_manual_company_and_personal_profiles_cover_selected_providers(self): result, output = self.run_onboarding(home, answers) self.assertEqual(result.returncode, 0, result.stderr) - expected = {"claude": 2, "openai": 2, "gemini": 1, "grok": 1, "glm": 1} + expected = {"claude": 3, "openai": 3, "gemini": 1, "grok": 1, "glm": 1} for provider, count in expected.items(): data = json.loads((output / f"{provider}-profiles.json").read_text()) self.assertEqual(len(data["profiles"]), count) + self.assertEqual( + data["families"], + ["example-company", "second-company", "personal"], + ) + company = next( + profile for profile in data["profiles"] + if profile["email"] == "engineer@example.com" + ) + self.assertEqual(company["family"], "example-company") + self.assertEqual( + company["profilePath"], + "example-company/xfactor/work-example-company", + ) + personal = [ + profile for profile in data["profiles"] + if profile["email"] == "person@example.net" + ] + if personal: + self.assertEqual(personal[0]["family"], "personal") + self.assertEqual( + personal[0]["profilePath"], + f"personal/{personal[0]['name']}", + ) state = output / "ai-profile-onboarding.json" self.assertEqual(state.stat().st_mode & 0o777, 0o600) self.assertIn("Existing standard provider credential homes were preserved", result.stdout) + setup = subprocess.run( + [str(REPO / "scripts/setup-ai-profiles.sh"), "--apply-existing"], + env={ + **os.environ, + "HOME": str(home), + "XDG_CONFIG_HOME": str(home / ".config"), + }, + text=True, + capture_output=True, + ) + self.assertEqual(setup.returncode, 0, setup.stderr) + roots = ( + ".claude-profiles", + ".chatgpt-profiles", + ".pi-profiles", + ".gemini-profiles", + ".grok-profiles", + ".glm-profiles", + ) + for root_name in roots: + root = home / root_name + self.assertTrue((root / "state/example-company").is_dir()) + self.assertTrue((root / "state/second-company").is_dir()) + self.assertTrue((root / "state/personal").is_dir()) + for company in ("example-company", "second-company"): + for category in ("team", "max", "xfactor"): + self.assertTrue( + (root / f"profiles/{company}/{category}").is_dir(), + f"{root_name} missing {company}/{category} scaffold", + ) + self.assertTrue((root / "profiles/personal").is_dir()) + def test_registry_sources_are_composed_with_user_grants(self): with tempfile.TemporaryDirectory(dir="/tmp") as temporary: home = pathlib.Path(temporary) @@ -138,6 +200,16 @@ def test_registry_sources_are_composed_with_user_grants(self): openai = json.loads((output / "openai-profiles.json").read_text()) self.assertEqual([item["name"] for item in claude["profiles"]], ["team-001"]) self.assertEqual([item["name"] for item in openai["profiles"]], ["personal-chatgpt"]) + self.assertEqual(claude["profiles"][0]["family"], "example-company") + self.assertEqual( + claude["profiles"][0]["profilePath"], + "example-company/team/team-001", + ) + self.assertEqual(openai["profiles"][0]["family"], "personal") + self.assertEqual( + openai["profiles"][0]["profilePath"], + "personal/personal-chatgpt", + ) def test_declined_consent_writes_nothing(self): with tempfile.TemporaryDirectory(dir="/tmp") as temporary: diff --git a/apps/credential-manager/test_provider_profiles.py b/apps/credential-manager/test_provider_profiles.py index 6ddfd71..54bbecd 100644 --- a/apps/credential-manager/test_provider_profiles.py +++ b/apps/credential-manager/test_provider_profiles.py @@ -37,8 +37,9 @@ def test_provider_launchers_isolate_state_and_resolve_aliases(self): "profiles": [ { "name": "team-001", + "profilePath": "example-company/team/team-001", "email": "team-001@example.com", - "family": "company", + "family": "example-company", "aliases": ["team001"], } ], @@ -72,15 +73,34 @@ def test_provider_launchers_isolate_state_and_resolve_aliases(self): capture_output=True, text=True, ) - profile = home / f".{provider}-profiles/profiles/team-001" + profile = home / f".{provider}-profiles/profiles/example-company/team/team-001" expected = profile if expected_env != "XDG_DATA_HOME" else profile / "xdg/data" self.assertEqual((root / f"{provider}.capture").read_text().strip(), str(expected)) + env[f"{provider.upper()}_PROFILES_MANIFEST"] = str(root / "missing.json") + subprocess.run( + [str(home / ".local/bin" / launcher), "team001"], + env=env, + check=True, + capture_output=True, + text=True, + ) + self.assertEqual((root / f"{provider}.capture").read_text().strip(), str(expected)) self.assertEqual(profile.stat().st_mode & 0o777, 0o700, provider) self.assertEqual( (profile / ".profile.json").stat().st_mode & 0o777, 0o600, provider, ) + provider_root = home / f".{provider}-profiles" + for category in ("team", "max", "xfactor"): + self.assertTrue( + (provider_root / f"profiles/example-company/{category}").is_dir(), + f"{provider} missing {category} scaffold", + ) + self.assertTrue( + (provider_root / "state/example-company").is_dir(), + f"{provider} missing company state", + ) if __name__ == "__main__": diff --git a/base-image/Dockerfile b/base-image/Dockerfile index 344df0c..56b4b4b 100644 --- a/base-image/Dockerfile +++ b/base-image/Dockerfile @@ -177,13 +177,16 @@ RUN --mount=type=cache,target=/root/.cache/uv \ COPY files/claude-profile /usr/local/bin/claude-profile COPY files/codex-profile /usr/local/bin/codex-profile COPY files/provider-profile /usr/local/bin/provider-profile +COPY files/pi-profile /usr/local/bin/pi-profile COPY files/claude-statusline-command.sh /usr/local/share/workbenches/claude/statusline-command.sh RUN chmod 0755 /usr/local/bin/claude-profile \ /usr/local/bin/codex-profile \ /usr/local/bin/provider-profile \ + /usr/local/bin/pi-profile \ /usr/local/share/workbenches/claude/statusline-command.sh \ && ln -sfn claude-profile /usr/local/bin/pclaude \ && ln -sfn codex-profile /usr/local/bin/pcodex \ + && ln -sfn pi-profile /usr/local/bin/ppi \ && ln -sfn provider-profile /usr/local/bin/gemini-profile \ && ln -sfn provider-profile /usr/local/bin/pgemini \ && ln -sfn provider-profile /usr/local/bin/grok-profile \ diff --git a/base-image/files/claude-profile b/base-image/files/claude-profile index 889ccba..ad42df8 100755 --- a/base-image/files/claude-profile +++ b/base-image/files/claude-profile @@ -68,7 +68,8 @@ if [[ "$claude_bin" == "/usr/local/bin/claude" ]]; then fi profile_metadata() { - local requested="$1" normalized row metadata candidate + local requested="$1" normalized row metadata candidate profile_path fallback_row + local preferred_row preferred_depth=-1 path_depth normalized="$(printf '%s' "$requested" | tr '[:upper:]' '[:lower:]')" if [[ -f "$manifest" ]]; then row="$(jq -r --arg name "$normalized" \ @@ -77,7 +78,7 @@ profile_metadata() { ((.name | ascii_downcase) == $name) or any(.aliases[]?; (ascii_downcase == $name)) ) - | [.name,.email,.family] | @tsv' \ + | [.name,.email,.family,(.profilePath // .name)] | @tsv' \ "$manifest" | head -n 1)" if [[ -n "$row" ]]; then printf '%s\n' "$row" @@ -85,18 +86,35 @@ profile_metadata() { fi fi - for metadata in "$base"/profiles/*/.profile.json; do - [[ -f "$metadata" ]] || continue + while IFS= read -r -d '' metadata; do candidate="$(jq -r --arg name "$normalized" ' if (((.name // "") | ascii_downcase) == $name) or any(.aliases[]?; (ascii_downcase == $name)) then "match" else empty end ' "$metadata")" if [[ "$candidate" == "match" ]]; then - jq -r '[.name,.email,.family] | @tsv' "$metadata" - return 0 + profile_path="${metadata#"$base/profiles/"}" + profile_path="${profile_path%/.profile.json}" + row="$(jq -r --arg path "$profile_path" '[.name,.email,.family,$path] | @tsv' "$metadata")" + if jq -e '(.profilePath // "") | length > 0' "$metadata" >/dev/null; then + path_depth="$(awk -F/ '{print NF}' <<<"$profile_path")" + if (( path_depth > preferred_depth )); then + preferred_row="$row" + preferred_depth="$path_depth" + fi + elif [[ -z "${fallback_row:-}" ]]; then + fallback_row="$row" + fi fi - done + done < <(find "$base/profiles" -mindepth 2 -type f -name .profile.json -print0) + if [[ -n "${preferred_row:-}" ]]; then + printf '%s\n' "$preferred_row" + return 0 + fi + if [[ -n "${fallback_row:-}" ]]; then + printf '%s\n' "$fallback_row" + return 0 + fi return 1 } @@ -113,18 +131,18 @@ case "$action" in if [[ -f "$manifest" ]]; then jq -r '.profiles[] | [.name, .family, .email, ((.aliases // []) | join(","))] | @tsv' "$manifest" fi - for metadata in "$base"/profiles/*/.profile.json; do - [[ -f "$metadata" ]] || continue + while IFS= read -r -d '' metadata; do jq -r '[.name,.family,.email,((.aliases // []) | join(","))] | @tsv' "$metadata" - done + done < <(find "$base/profiles" -mindepth 2 -type f -name .profile.json -print0) } | awk -F '\t' '!seen[tolower($1)]++' ;; login|status|run) profile="${1:?Usage: claude-profile $action PROFILE [arguments]}"; shift row="$(profile_metadata "$profile" || true)" [[ -n "$row" ]] || { echo "Unknown Claude profile: $profile" >&2; exit 2; } - IFS=$'\t' read -r profile email family <<<"$row" - config_dir="$base/profiles/$profile" + IFS=$'\t' read -r profile email family profile_path <<<"$row" + config_dir="$base/profiles/$profile_path" + [[ -d "$config_dir" ]] || { echo "Claude profile is not configured: $config_dir" >&2; exit 2; } [[ -n "$claude_bin" ]] || { echo "Claude CLI not found." >&2; exit 1; } configure_profile_runtime "$config_dir" case "$action" in diff --git a/base-image/files/codex-profile b/base-image/files/codex-profile index 0a346f6..f495f51 100755 --- a/base-image/files/codex-profile +++ b/base-image/files/codex-profile @@ -11,7 +11,8 @@ if [[ -z "$codex_bin" && -x "$HOME/.local/bin/codex" ]]; then fi profile_metadata() { - local requested="$1" normalized row metadata candidate + local requested="$1" normalized row metadata candidate profile_path fallback_row + local preferred_row preferred_depth=-1 path_depth normalized="$(printf '%s' "$requested" | tr '[:upper:]' '[:lower:]')" if [[ -f "$manifest" ]]; then row="$(jq -r --arg name "$normalized" \ @@ -20,7 +21,7 @@ profile_metadata() { ((.name | ascii_downcase) == $name) or any(.aliases[]?; (ascii_downcase == $name)) ) - | [.name,.email,.family] | @tsv' \ + | [.name,.email,.family,(.profilePath // .name)] | @tsv' \ "$manifest" | head -n 1)" if [[ -n "$row" ]]; then printf '%s\n' "$row" @@ -28,18 +29,35 @@ profile_metadata() { fi fi - for metadata in "$base"/profiles/*/.profile.json; do - [[ -f "$metadata" ]] || continue + while IFS= read -r -d '' metadata; do candidate="$(jq -r --arg name "$normalized" ' if (((.name // "") | ascii_downcase) == $name) or any(.aliases[]?; (ascii_downcase == $name)) then "match" else empty end ' "$metadata")" if [[ "$candidate" == "match" ]]; then - jq -r '[.name,.email,.family] | @tsv' "$metadata" - return 0 + profile_path="${metadata#"$base/profiles/"}" + profile_path="${profile_path%/.profile.json}" + row="$(jq -r --arg path "$profile_path" '[.name,.email,.family,$path] | @tsv' "$metadata")" + if jq -e '(.profilePath // "") | length > 0' "$metadata" >/dev/null; then + path_depth="$(awk -F/ '{print NF}' <<<"$profile_path")" + if (( path_depth > preferred_depth )); then + preferred_row="$row" + preferred_depth="$path_depth" + fi + elif [[ -z "${fallback_row:-}" ]]; then + fallback_row="$row" + fi fi - done + done < <(find "$base/profiles" -mindepth 2 -type f -name .profile.json -print0) + if [[ -n "${preferred_row:-}" ]]; then + printf '%s\n' "$preferred_row" + return 0 + fi + if [[ -n "${fallback_row:-}" ]]; then + printf '%s\n' "$fallback_row" + return 0 + fi return 1 } @@ -56,18 +74,17 @@ case "$action" in if [[ -f "$manifest" ]]; then jq -r '.profiles[] | [.name, .family, .email, ((.aliases // []) | join(","))] | @tsv' "$manifest" fi - for metadata in "$base"/profiles/*/.profile.json; do - [[ -f "$metadata" ]] || continue + while IFS= read -r -d '' metadata; do jq -r '[.name,.family,.email,((.aliases // []) | join(","))] | @tsv' "$metadata" - done + done < <(find "$base/profiles" -mindepth 2 -type f -name .profile.json -print0) } | awk -F '\t' '!seen[tolower($1)]++' ;; login|status|logout|run) profile="${1:?Usage: codex-profile $action PROFILE [arguments]}"; shift row="$(profile_metadata "$profile" || true)" [[ -n "$row" ]] || { echo "Unknown Codex profile: $profile" >&2; exit 2; } - IFS=$'\t' read -r profile email family <<<"$row" - config_dir="$base/profiles/$profile" + IFS=$'\t' read -r profile email family profile_path <<<"$row" + config_dir="$base/profiles/$profile_path" [[ -d "$config_dir" ]] || { echo "Codex profile is not configured: $config_dir" >&2; exit 2; } [[ -n "$codex_bin" ]] || { echo "Codex CLI not found." >&2; exit 1; } diff --git a/base-image/files/pi-profile b/base-image/files/pi-profile new file mode 100755 index 0000000..3217ca0 --- /dev/null +++ b/base-image/files/pi-profile @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +manifest="${PI_PROFILES_MANIFEST:-${XDG_CONFIG_HOME:-$HOME/.config}/workbenches/pi-profiles.json}" +base="${PI_PROFILES_HOME:-$HOME/.pi-profiles}" +[[ -d "$HOME/.local/bin" ]] && export PATH="$HOME/.local/bin:$PATH" +pi_bin="${PI_BIN:-$(command -v pi || true)}" +if [[ -z "$pi_bin" && -x "$HOME/.npm-global/bin/pi" ]]; then + pi_bin="$HOME/.npm-global/bin/pi" +fi +command -v jq >/dev/null 2>&1 || { echo "jq is required." >&2; exit 1; } + +profile_metadata() { + local requested normalized row metadata candidate profile_path fallback_row + local preferred_row preferred_depth=-1 path_depth + requested="$1" + normalized="$(printf '%s' "$requested" | tr '[:upper:]' '[:lower:]')" + if [[ -f "$manifest" ]]; then + row="$(jq -r --arg name "$normalized" ' + .profiles[] + | select(((.name | ascii_downcase) == $name) or any(.aliases[]?; ascii_downcase == $name)) + | [.name,.email,.family,((.providers // []) | join(",")),(.profilePath // .name)] | @tsv + ' "$manifest" | head -n 1)" + if [[ -n "$row" ]]; then printf '%s\n' "$row"; return 0; fi + fi + while IFS= read -r -d '' metadata; do + candidate="$(jq -r --arg name "$normalized" ' + if (((.name // "") | ascii_downcase) == $name) + or any(.aliases[]?; ascii_downcase == $name) + then "match" else empty end + ' "$metadata")" + if [[ "$candidate" == match ]]; then + profile_path="${metadata#"$base/profiles/"}" + profile_path="${profile_path%/.profile.json}" + row="$(jq -r --arg path "$profile_path" '[.name,.email,.family,((.providers // []) | join(",")),$path] | @tsv' "$metadata")" + if jq -e '(.profilePath // "") | length > 0' "$metadata" >/dev/null; then + path_depth="$(awk -F/ '{print NF}' <<<"$profile_path")" + if (( path_depth > preferred_depth )); then preferred_row="$row"; preferred_depth="$path_depth"; fi + elif [[ -z "${fallback_row:-}" ]]; then + fallback_row="$row" + fi + fi + done < <(find "$base/profiles" -mindepth 2 -type f -name .profile.json -print0) + [[ -n "${preferred_row:-}" ]] && { printf '%s\n' "$preferred_row"; return 0; } + [[ -n "${fallback_row:-}" ]] && { printf '%s\n' "$fallback_row"; return 0; } + return 1 +} + +action="${1:-list}" +if [[ "$action" =~ ^(list|login|status|logout|run)$ ]]; then + [[ $# -eq 0 ]] || shift +else + action=run +fi + +case "$action" in + list) + jq -r '.profiles[] | [.name,.family,.email,((.aliases // [])|join(",")),((.providers // [])|join(","))] | @tsv' "$manifest" + ;; + login|status|logout|run) + profile="${1:?Usage: pi-profile $action PROFILE [arguments]}"; shift + row="$(profile_metadata "$profile")" + [[ -n "$row" ]] || { echo "Unknown Pi profile: $profile" >&2; exit 2; } + IFS=$'\t' read -r profile email family providers profile_path <<<"$row" + agent_dir="$base/profiles/$profile_path/agent" + auth_file="$agent_dir/auth.json" + [[ -d "$agent_dir" ]] || { echo "Pi profile is not configured: $agent_dir" >&2; exit 2; } + export PI_CODING_AGENT_DIR="$agent_dir" + export PI_PROFILE_NAME="$profile" + export PI_PROFILE_EMAIL="$email" + export PI_PROFILE_FAMILY="$family" + claude_profiles_root="${CLAUDE_PROFILES_HOME:-$HOME/.claude-profiles}/profiles" + claude_config_dir="$claude_profiles_root/$profile" + claude_profile_depth=-1 + while IFS= read -r -d '' claude_profile_candidate; do + if [[ -f "$claude_profile_candidate/.profile.json" ]] \ + && jq -e --arg name "$profile" '.name == $name' "$claude_profile_candidate/.profile.json" >/dev/null; then + claude_profile_relative="${claude_profile_candidate#"$claude_profiles_root/"}" + candidate_depth="$(awk -F/ '{print NF}' <<<"$claude_profile_relative")" + if (( candidate_depth > claude_profile_depth )); then + claude_config_dir="$claude_profile_candidate" + claude_profile_depth="$candidate_depth" + fi + fi + done < <(find "$claude_profiles_root" -mindepth 1 -type d -name "$profile" -print0) + if [[ ",$providers," == *,claude,* && -d "$claude_config_dir" ]]; then + export CLAUDE_CONFIG_DIR="$claude_config_dir" + fi + case "$action" in + login) + [[ -n "$pi_bin" ]] || { echo "Pi CLI not found. Run workBenches tool setup first." >&2; exit 1; } + printf 'Starting Pi login for %s (%s). Use /login; expected account providers: %s.\n' "$profile" "$email" "$providers" >&2 + exec "$pi_bin" "$@" + ;; + status) + configured="" + if [[ -f "$auth_file" ]]; then + configured="$(jq -r 'if type == "object" then (keys | sort | join(",")) else error("invalid Pi auth file") end' "$auth_file")" + fi + claude_bin="$(command -v claude || true)" + if [[ -n ${CLAUDE_CONFIG_DIR:-} && -n "$claude_bin" ]] && "$claude_bin" auth status >/dev/null 2>&1; then + configured="${configured:+$configured,}pi-claude-cli(proxy-ready)" + fi + printf '%s\t%s\t%s\n' "$profile" "$email" "${configured:-not-authenticated}" + ;; + logout) + provider="${1:?Usage: pi-profile logout PROFILE PI_PROVIDER}"; shift + [[ -f "$auth_file" ]] || { echo "Pi profile is not authenticated: $profile" >&2; exit 1; } + tmp="$(mktemp "$agent_dir/.auth.XXXXXX.tmp")" + jq --arg provider "$provider" 'del(.[$provider])' "$auth_file" > "$tmp" + chmod 600 "$tmp" + mv -f "$tmp" "$auth_file" + printf 'Removed Pi provider credential: %s/%s\n' "$profile" "$provider" + ;; + run) + [[ -n "$pi_bin" ]] || { echo "Pi CLI not found. Run workBenches tool setup first." >&2; exit 1; } + exec "$pi_bin" "$@" + ;; + esac + ;; +esac diff --git a/base-image/files/provider-profile b/base-image/files/provider-profile index f0ad474..4e1b4f1 100755 --- a/base-image/files/provider-profile +++ b/base-image/files/provider-profile @@ -40,16 +40,47 @@ esac command -v jq >/dev/null 2>&1 || { echo "jq is required." >&2; exit 1; } profile_metadata() { - local requested="$1" normalized + local requested="$1" normalized row metadata candidate profile_path fallback_row + local preferred_row preferred_depth=-1 path_depth normalized="$(printf '%s' "$requested" | tr '[:upper:]' '[:lower:]')" - jq -r --arg name "$normalized" ' - .profiles[] - | select( - ((.name | ascii_downcase) == $name) - or any(.aliases[]?; (ascii_downcase == $name)) - ) - | [.name,.email,.family] | @tsv - ' "$manifest" | head -n 1 + if [[ -f "$manifest" ]]; then + row="$(jq -r --arg name "$normalized" ' + .profiles[] + | select( + ((.name | ascii_downcase) == $name) + or any(.aliases[]?; (ascii_downcase == $name)) + ) + | [.name,.email,.family,(.profilePath // .name)] | @tsv + ' "$manifest" | head -n 1)" + if [[ -n "$row" ]]; then + printf '%s\n' "$row" + return 0 + fi + fi + while IFS= read -r -d '' metadata; do + candidate="$(jq -r --arg name "$normalized" ' + if (((.name // "") | ascii_downcase) == $name) + or any(.aliases[]?; (ascii_downcase == $name)) + then "match" else empty end + ' "$metadata")" + if [[ "$candidate" == match ]]; then + profile_path="${metadata#"$base/profiles/"}" + profile_path="${profile_path%/.profile.json}" + row="$(jq -r --arg path "$profile_path" '[.name,.email,.family,$path] | @tsv' "$metadata")" + if jq -e '(.profilePath // "") | length > 0' "$metadata" >/dev/null; then + path_depth="$(awk -F/ '{print NF}' <<<"$profile_path")" + if (( path_depth > preferred_depth )); then + preferred_row="$row" + preferred_depth="$path_depth" + fi + elif [[ -z "${fallback_row:-}" ]]; then + fallback_row="$row" + fi + fi + done < <(find "$base/profiles" -mindepth 2 -type f -name .profile.json -print0 2>/dev/null) + [[ -n "${preferred_row:-}" ]] && { printf '%s\n' "$preferred_row"; return 0; } + [[ -n "${fallback_row:-}" ]] && { printf '%s\n' "$fallback_row"; return 0; } + return 1 } configure_environment() { @@ -84,8 +115,8 @@ case "$action" in profile_requested="${1:?Usage: $invoked_as $action PROFILE [arguments]}"; shift row="$(profile_metadata "$profile_requested")" [[ -n "$row" ]] || { echo "Unknown $label profile: $profile_requested" >&2; exit 2; } - IFS=$'\t' read -r profile email family <<<"$row" - profile_dir="$base/profiles/$profile" + IFS=$'\t' read -r profile email family profile_path <<<"$row" + profile_dir="$base/profiles/$profile_path" [[ -d "$profile_dir" ]] || { echo "$label profile is not configured: $profile_dir" >&2; exit 2; } [[ "$action" == path ]] && { printf '%s\n' "$profile_dir"; exit 0; } [[ -n "$cli" ]] || { echo "$label CLI not found." >&2; exit 1; } diff --git a/base-image/install-ai-clis.sh b/base-image/install-ai-clis.sh index 396b677..cb25338 100755 --- a/base-image/install-ai-clis.sh +++ b/base-image/install-ai-clis.sh @@ -9,6 +9,8 @@ # Claude Code goes to /usr/local/bin. # # Installs: +# - Herdr terminal workspace manager +# - Pi Coding Agent # - OpenCode (installed from the official npm platform package) # - oh-my-opencode plugin (installed from the published npm package) # Includes built-in agents: Sisyphus, oracle, librarian, explore, frontend, etc. @@ -39,6 +41,7 @@ UV_TOOL_INSTALL_TIMEOUT="${UV_TOOL_INSTALL_TIMEOUT:-3600}" # 60 minutes for Pyt INSTALL_ANTIGRAVITY_CLI="${INSTALL_ANTIGRAVITY_CLI:-0}" ANTIGRAVITY_INSTALL_URL="${ANTIGRAVITY_INSTALL_URL:-https://antigravity.google/cli/install.sh}" ANTIGRAVITY_INSTALL_SHA256="${ANTIGRAVITY_INSTALL_SHA256:-}" +HERDR_INSTALL_URL="${HERDR_INSTALL_URL:-https://herdr.dev/install.sh}" log_debug() { if [ "$DEBUG" = "1" ]; then @@ -210,6 +213,26 @@ if ! run_with_timeout "$NPM_INSTALL_TIMEOUT" "Gemini npm install" npm install -g log_error "Gemini CLI installation failed (continuing)" fi +log_info "Installing Pi Coding Agent..." +if ! run_with_timeout "$NPM_INSTALL_TIMEOUT" "Pi Coding Agent npm install" \ + npm install -g --ignore-scripts @earendil-works/pi-coding-agent; then + log_error "Pi Coding Agent installation failed (continuing)" +fi + +log_info "Installing Herdr terminal workspace manager..." +herdr_installer="$(mktemp)" +if run_with_timeout "$RELEASE_DOWNLOAD_TIMEOUT" "Herdr installer download" \ + curl -fsSL --retry 3 --connect-timeout 10 --max-time 60 \ + -o "$herdr_installer" "$HERDR_INSTALL_URL"; then + if ! run_with_timeout "$RELEASE_DOWNLOAD_TIMEOUT" "Herdr install" \ + env HERDR_INSTALL_DIR=/usr/local/bin sh "$herdr_installer"; then + log_error "Herdr installation failed (continuing)" + fi +else + log_error "Herdr installer download failed (continuing)" +fi +rm -f "$herdr_installer" + if [ "$INSTALL_ANTIGRAVITY_CLI" = "1" ] || [ "$INSTALL_ANTIGRAVITY_CLI" = "true" ]; then log_info "Installing Google Antigravity CLI..." if [ -z "$ANTIGRAVITY_INSTALL_SHA256" ]; then @@ -544,7 +567,7 @@ log_info "AI CLI Tools Installation Complete!" log_info "==========================================" log_info "" -required_clis=(claude codex gemini copilot opencode omo letta notebooklm nlm) +required_clis=(claude codex gemini pi herdr copilot opencode omo letta notebooklm nlm) missing_clis=() for cli in "${required_clis[@]}"; do if ! command -v "$cli" >/dev/null 2>&1; then @@ -565,6 +588,8 @@ log_info "Installed tools:" log_info " - Claude Code (claude) [native installer]" log_info " - OpenAI Codex (codex)" log_info " - Google Gemini (gemini)" +log_info " - Pi Coding Agent (pi)" +log_info " - Herdr terminal workspace manager (herdr)" if command -v agy >/dev/null 2>&1; then log_info " - Google Antigravity CLI (agy)" else diff --git a/config/claude-profiles.example.json b/config/claude-profiles.example.json index 876c66d..02896a7 100644 --- a/config/claude-profiles.example.json +++ b/config/claude-profiles.example.json @@ -1,7 +1,18 @@ { "version": 1, + "families": ["company", "personal"], "profiles": [ - { "name": "work", "family": "work", "email": "user@company.example" }, - { "name": "personal", "family": "personal", "email": "user@example.com" } + { + "name": "work", + "profilePath": "company/xfactor/work", + "family": "company", + "email": "user@company.example" + }, + { + "name": "personal", + "profilePath": "personal/personal", + "family": "personal", + "email": "user@example.com" + } ] } diff --git a/config/openai-profiles.example.json b/config/openai-profiles.example.json index abbc3d2..2cea5c1 100644 --- a/config/openai-profiles.example.json +++ b/config/openai-profiles.example.json @@ -1,14 +1,17 @@ { "version": 1, + "families": ["company", "personal"], "profiles": [ { "name": "work-chatgpt-1", - "family": "work", + "profilePath": "company/xfactor/work-chatgpt-1", + "family": "company", "email": "user@company.example", "aliases": ["work1"] }, { "name": "personal-chatgpt-1", + "profilePath": "personal/personal-chatgpt-1", "family": "personal", "email": "user@example.com", "aliases": ["personal1"] diff --git a/devBenches/README.md b/devBenches/README.md index 8c8a71a..8ebcb63 100755 --- a/devBenches/README.md +++ b/devBenches/README.md @@ -12,6 +12,7 @@ Each subfolder is a separate git repository containing a complete development en - **`javaBench/`** - Java development environment with DevContainer - **`phpBench/`** - PHP development environment with DevContainer - **`pyBench/`** - Python development environment with DevContainer +- **`rustBench/`** - Rust development, analysis, cross-compilation, and WebAssembly environment ## Layered Containers (Current Standard) diff --git a/devBenches/rustBench b/devBenches/rustBench new file mode 160000 index 0000000..8e7b4a1 --- /dev/null +++ b/devBenches/rustBench @@ -0,0 +1 @@ +Subproject commit 8e7b4a15f266c811e6f2e13ae967be43db8eee28 diff --git a/docker-compose.mounts.yml b/docker-compose.mounts.yml index f4d251b..68fbfef 100644 --- a/docker-compose.mounts.yml +++ b/docker-compose.mounts.yml @@ -13,8 +13,9 @@ x-standard-mounts: &standard-mounts - ../..:/workspace:cached - ~/projects:/projects:cached - # Zsh history persistence - - ${BENCH_NAME:-bench}-zshhistory:/home/${USER}/.zsh_history + # Zsh history persistence. Mount the named volume as a directory; Docker named + # volumes are directories and cannot safely target the ~/.zsh_history file. + - ${BENCH_NAME:-bench}-zshhistory:/home/${USER}/.workbenches-history # Shell configurations (readonly — host user's actual config) - ~/.zshrc:/home/${USER}/.zshrc:ro @@ -43,6 +44,7 @@ x-standard-mounts: &standard-mounts - ~/.agents:/home/${USER}/.agents:cached # Project Intelligence and local agent metadata - ~/.pi:/home/${USER}/.pi:cached + - ~/.pi-profiles:/home/${USER}/.pi-profiles:cached # Google Antigravity / legacy Gemini settings. Antigravity login tokens remain # in the host OS keyring and are not portable through this mount. # Omnigent (AI meta-harness, wraps Codex/Claude) @@ -77,6 +79,7 @@ services: # OpenAI Codex (@openai/codex via npm) → ~/.codex/ # Shared Agents (protocols and global skills) → ~/.agents/ # Project Intel (local agent metadata) → ~/.pi/ +# Pi profiles (isolated auth, settings, sessions) → ~/.pi-profiles/ # Antigravity (settings only; auth in host keyring) → ~/.gemini/ # Abacus AI (settings; API keys externally held) → ~/.abacusai/ # GitHub Copilot (@github/copilot npm) → ~/.copilot-cli/ diff --git a/docs/MOUNTS-README.md b/docs/MOUNTS-README.md index de19731..b2de690 100644 --- a/docs/MOUNTS-README.md +++ b/docs/MOUNTS-README.md @@ -20,9 +20,13 @@ Every bench must include these mounts. Copy this block into new bench `devcontai // Projects directory "source=${localEnv:HOME}/projects,target=/workspace/projects,type=bind", // Zsh history (named volume per bench) -"source={benchname}-zshhistory,target=/home/${localEnv:USER}/.zsh_history,type=volume", +"source={benchname}-zshhistory,target=/home/${localEnv:USER}/.workbenches-history,type=volume", ``` +Set `HISTFILE=/home/${localEnv:USER}/.workbenches-history/.zsh_history` +through the container environment or shell launcher. Docker named volumes are +directories, so they must not be mounted directly onto the `.zsh_history` file. + ### Shell Configuration (bind, readonly) Overrides the /etc/skel defaults from Layer 0 with the host user's actual shell config. @@ -56,6 +60,7 @@ AI/spec CLIs are installed in the appropriate base image and store credentials o // Project Intelligence and local agent metadata "source=${localEnv:HOME}/.pi,target=/home/${localEnv:USER}/.pi,type=bind,consistency=cached", +"source=${localEnv:HOME}/.pi-profiles,target=/home/${localEnv:USER}/.pi-profiles,type=bind,consistency=cached", // Claude Code (Anthropic) — native installer // Auth: ~/.claude/ (session, config), ~/.claude.json (legacy config) @@ -109,6 +114,7 @@ Reference mapping each installed AI/spec CLI to its credential path and mount ty - Shared agent workflow → host-managed files → `~/.agents/` → cached - Project Intelligence metadata → host-managed files → `~/.pi/` → cached +- Pi isolated profiles → host-managed files → `~/.pi-profiles/` → cached - Claude Code → native installer → `~/.claude/`, `~/.claude.json`, `~/.claude-profiles/` → cached - Claude profile launchers → `/usr/local/bin/claude-profile` and `/usr/local/bin/pclaude` in Layer 0; both resolve the mounted `~/.claude-profiles` tree - ChatGPT/Codex CLI → `~/.codex/`, `~/.chatgpt-profiles/` → cached diff --git a/docs/ai-credential-ownership.md b/docs/ai-credential-ownership.md index c3683dd..3513efd 100644 --- a/docs/ai-credential-ownership.md +++ b/docs/ai-credential-ownership.md @@ -137,7 +137,8 @@ exist. Before querying GitHub or writing profile metadata, it asks whether the user consents to work/personal profile setup. It then collects: - the personal GitHub username; -- company count, company name, company login email, and company GitHub org; +- company count, company login email (company name defaults to the email + domain's first label and is editable), and company GitHub org; - personal AI subscription emails and the providers used by each account; and - the personal GitHub user or org that owns the personal credential registry. @@ -177,3 +178,12 @@ An agent stack may bind one credential to several surfaces, but the encrypted credential has one owner and one source of truth. For example, a personal model credential can be materialized for `pclaude` on a workstation and injected into a personal VPS agent runtime without being copied into the Agents repository. + +For teams that want to stop distributing the age recovery identity to +workstations, the optional +[Keycloak-gated credential broker](keycloak-credential-broker.md) puts a shared +identity server and a Python broker in front of these same SOPS registries. It +gates the existing grants without replacing them: Keycloak proves identity, the +grant files remain authoritative for authorization, SOPS remains the at-rest +store, and the broker holds one age identity per tenant so no decryption key +reaches a workstation or agent. diff --git a/docs/claude-multi-account-profiles.md b/docs/claude-multi-account-profiles.md index 8f48ab8..9a2faa9 100644 --- a/docs/claude-multi-account-profiles.md +++ b/docs/claude-multi-account-profiles.md @@ -43,6 +43,9 @@ can start or verify each profile's isolated Claude login. The manifest contains email addresses but no credentials. Claude stores OAuth credentials inside each directory under `~/.claude-profiles/profiles/`. +An optional `profilePath`, such as `company-one/team/team-001` or +`company-one/max/max-001`, groups profile directories by company and account +class without changing the name accepted by `pclaude`. The installer marks the profile's CLI onboarding as complete because login is performed explicitly with `claude-profile login`; this prevents Claude's first-run wizard from starting a second, redundant browser login. @@ -60,10 +63,21 @@ The setup creates this per-user structure: ```text ~/.claude-profiles/ |-- profiles/ -| |-- work-acme/ # One company's login, settings, plugins, and cache +| |-- company-one/ +| | |-- team/ +| | | `-- team-001/ # One login, settings, plugins, and cache +| | |-- max/ +| | | `-- max-001/ # A separately authenticated Max login +| | `-- xfactor/ +| | `-- xfactor-001/ +| |-- company-two/ +| | |-- team/ +| | |-- max/ +| | `-- xfactor/ | `-- personal/ # Personal login, settings, plugins, and cache |-- state/ -| |-- work-acme/ # Company-only history, projects, plans, and tasks +| |-- company-one/ # Company-only history, projects, plans, and tasks +| |-- company-two/ | `-- personal/ # Personal-only history, projects, plans, and tasks `-- shared/ # Status panel, skills, agents, commands, and rules ``` @@ -76,6 +90,8 @@ and state migration rather than by renaming directories. All profiles share `skills`, `agents`, `commands`, and `rules`. Profiles in the same family share transcripts, prompt history, file history, plans, and tasks. +Use a unique family for each personal login when personal histories must remain +separate. Credentials, settings, plugins, caches, and daemon state remain per profile. Every profile receives the shared four-line Claude status panel. It reports the diff --git a/docs/codex-multi-account-profiles.md b/docs/codex-multi-account-profiles.md index c665af2..990dab0 100644 --- a/docs/codex-multi-account-profiles.md +++ b/docs/codex-multi-account-profiles.md @@ -1,9 +1,11 @@ # Codex multi-account profiles workBenches can launch Codex CLI with separate ChatGPT logins. Each account -receives its own `CODEX_HOME`, including its own file-backed `auth.json`, -sessions, logs, and caches. Profile names, aliases, families, and login emails -are inventory metadata only; OAuth credentials are never stored in a manifest. +receives its own `CODEX_HOME` and file-backed `auth.json`. Profiles in the same +trust family share portable conversation state, while logs, caches, databases, +and credentials remain profile-local. Profile names, aliases, families, and +login emails are inventory metadata only; OAuth credentials are never stored +in a manifest. ## Setup @@ -30,18 +32,28 @@ Profile homes are created under: ```text ~/.chatgpt-profiles/ -`-- profiles/ - |-- work-chatgpt-1/ - | |-- auth.json # Created by Codex login; treat like a password - | |-- config.toml - | `-- .profile.json - `-- personal-chatgpt-1/ +|-- profiles/ +| |-- work-chatgpt-1/ +| | |-- auth.json # Created by Codex login; treat like a password +| | |-- config.toml +| | |-- sessions -> ../../state/work/sessions +| | `-- history.jsonl -> ../../state/work/history.jsonl +| `-- personal-chatgpt-1/ +`-- state/ + `-- work/ # Portable history shared by the work family ``` The installer initializes each profile from the user's existing Codex configuration, then forces ChatGPT login with file credential storage. Shared -skills, prompts, policy, and global instructions link back to `~/.codex` while -credentials and runtime state stay isolated per profile. +skills, prompts, policy, and global instructions link back to `~/.codex`. +`sessions`, `archived_sessions`, `history.jsonl`, and `session_index.jsonl` +link to family state so another login in that family can resume the same work. +Credential files and SQLite runtime state remain isolated per profile. + +When existing profiles are adopted, setup merges their portable state into the +family directory without overwriting existing rollouts. The former local paths +are retained as `.pre-shared-state` recovery copies. Re-running setup repairs +the links without importing those recovery copies again. Codex's built-in `--profile` option is a configuration overlay within one `CODEX_HOME`; it does not isolate account credentials. Use `pcodex` when the @@ -49,3 +61,25 @@ login identity must change. Each ChatGPT identity still needs its own applicable product entitlement. A local profile does not create a ChatGPT Business seat or independent quota. + +## Codex Desktop through Multi-CLI + +On Windows, the current Desktop store at `%USERPROFILE%\.codex` is the shared +conversation source. Synchronize registry-owned Multi-CLI profiles with: + +```powershell +.\scripts\setup-multi-cli-codex-profiles.ps1 ` + -Manifest C:\path\to\openai-profiles.json +``` + +The script creates `MultiCliProfiles\codex\` homes for every +non-personal (company) family in the manifest by default, or only the +families passed via `-Family` (e.g. `-Family acme`). Their portable history +paths link to the live Desktop store, while `auth.json`, configuration, +caches, and databases remain inside each Multi-CLI profile. Multiple login +identities in the same company family can therefore open the same Desktop +conversations using different login tokens. Profiles with a `personal` family +are never linked. + +The operation is idempotent. It refuses to replace a real profile-local history +path or a link targeting another store; migrate that state explicitly first. diff --git a/docs/keycloak-credential-broker.md b/docs/keycloak-credential-broker.md new file mode 100644 index 0000000..7864ec8 --- /dev/null +++ b/docs/keycloak-credential-broker.md @@ -0,0 +1,458 @@ +# Keycloak-gated credential broker + +workBenches stores no secrets. Today the actual credential values live as SOPS +ciphertext inside private tenant and user registries, and +`scripts/provider-credential-escrow` restores them by reading a local clone with +the age recovery identity present **on the engineer's workstation**. This +document specifies an optional server-side alternative: a shared +[Keycloak](https://www.keycloak.org/) identity server plus a Python credential +broker that releases those same credentials to an authenticated caller, so the +age recovery identity never has to leave a controlled host. + +This design **gates** the existing SOPS registries. It does not replace them. +SOPS remains the at-rest storage format and the offline break-glass path. Read +[AI credential ownership and profile composition](ai-credential-ownership.md) +first; this document assumes that ownership model and extends it. + +## Why + +The single largest weakness in the current model is that restoring an escrowed +credential requires the tenant's age recovery identity to be readable on the +workstation performing the restore. Every workstation that restores a profile is +therefore a place a decryption key can leak from, and offboarding an engineer +means trusting that they scrub local key material. + +With a broker in front of SOPS: + +- The age recovery identity stays on the broker host and never reaches a + workstation or agent. +- Workstations and unattended agents only ever *authenticate*; they never hold a + decryption key. +- Onboarding and offboarding become Keycloak group membership changes plus the + existing grant files. No key distribution or scrubbing. +- Every credential release is authenticated, authorized against the existing + grants, and audited centrally. + +The caveats the current model already documents still hold. Releasing a +long-lived vendor token to a workstation or agent means that token now lives +there; the broker can revoke future *access* instantly by disabling a Keycloak +identity, but it cannot recall an already-materialized vendor token. A leaked +vendor secret still requires vendor-side rotation. + +## Locked decisions + +| Decision | Choice | Rationale | +|---|---|---| +| Broker role | Gate in front of SOPS | Reuses the existing registries, grants, and escrow validators; keeps SOPS as offline fallback. | +| Deployment | Shared org server | One Keycloak and one broker for the team; benches and agents authenticate over the network. | +| Authorization source | Existing grant files | `grants/users/.json` stays the single source of truth for *what*; Keycloak only proves *who*. | +| Decryption capability | One age identity per tenant | Preserves cryptographic separation between tenants instead of one org-wide key. | +| Broker language | Python | Reuses the `provider-credential-escrow` validators and `credential-manager` verification patterns directly. | +| Registry access | Live read-only git pull | One read-only deploy key per tenant; no new pipeline to maintain. | +| Keycloak install | Optional installer repo | Self-hosting is opt-in, so it follows the `Install-Wave-Terminal` pattern, not a forced submodule. | + +## Separation of concerns + +Each component does exactly one job: + +- **Keycloak — authentication.** *Who are you?* GitHub is configured as an + identity provider, and a mapper writes the GitHub login into a `github_login` + token claim. That claim is the key the broker uses to locate + `grants/users/.json`, so the github-centric grant model is + preserved. +- **Grant files — authorization.** *What may this identity use?* The existing + `grants/users/.json` patterns are unchanged and stay in the + tenant registry. +- **SOPS — at-rest storage.** Ciphertext, unchanged. +- **Broker — the runtime that binds the three** and enforces release. It holds + the per-tenant age identities, checks Keycloak identity against the grant + files, decrypts in memory, validates the plaintext shape, and returns the + credential over TLS. + +## Flow + +``` +┌── bench / VPS ──────────────┐ ┌── shared org server ─────────────────┐ +│ pclaude team001 │ │ │ +│ └─ resolver: │ │ Keycloak (realm: workbenches) │ +│ 1. device-code login ───┼──OIDC──▶ ├─ GitHub identity broker │ +│ (cache refresh tok │ │ ├─ groups: /tenants/* │ +│ 0600 in profile) │ │ └─ service-account clients (agents) │ +│ 2. POST credentials │ │ │ +│ Bearer ─┼──mTLS──▶ Broker service (Python) │ +│ {tenant,provider, │ │ ├─ validate token (aud-locked) │ +│ profile} │ │ ├─ read grants/users/.json │ +│ 3. writes plaintext ◀───┼────────┤ ├─ pull SOPS ciphertext (registry) │ +│ into profile home │ │ ├─ decrypt w/ tenant age identity │ +│ (same path as │ │ ├─ validate plaintext shape │ +│ escrow restore) │ │ ├─ audit-log the release │ +│ │ │ └─ return plaintext over TLS │ +└─────────────────────────────┘ └──────────────────────────────────────┘ +``` + +Step 3 writes to the exact path `provider-credential-escrow restore` writes +today, so the broker is a drop-in alternate restore source and the SOPS-direct +path remains available as break-glass. + +## Keycloak realm layout + +**Realm:** `workbenches` — a single realm, multi-tenant via groups. + +**Identity brokering:** GitHub is configured as an IdP with a mapper that writes +the GitHub login into a `github_login` claim. + +**Groups** express coarse "which tenant registries may I touch": + +``` +/tenants/opensoft +/tenants/farheap +``` + +A user belongs to one or more tenant groups. Fine-grained profile selection +stays in the grant files; Keycloak only asserts tenant membership. + +**Clients:** + +| Client | Type | Grant | Used by | +|---|---|---|---| +| `workbench-launcher` | public | device authorization + PKCE | the `p*` resolvers on workstations and benches | +| `workbench-broker` | bearer-only (resource server) | — | the broker API; tokens must carry its audience | +| `agent-` | confidential | `client_credentials` | one per unattended agent or VPS | + +**Audience:** a `broker-audience` client scope on `workbench-launcher` and every +`agent-*` client injects `aud: workbench-broker`. The broker rejects any token +missing its audience, so a token minted for another service cannot be replayed +against it. + +**Token claims the broker consumes:** `sub`; `github_login` for humans or +`agent_id` for service accounts; `groups` (mapped to tenants); a short `exp` +(around five minutes, refreshed by the launcher). + +## Broker API + +Base `https://broker..internal/v1`. Every request uses mTLS and an +`Authorization: Bearer ` header. On each request the +broker validates the signature (JWKS), issuer, `aud == workbench-broker`, and +`exp`, then resolves the caller identity (`github_login` or `agent_id`) and the +tenant set from `groups`. + +### `GET /v1/entitlements` + +Read-only; returns no secret values. Intersects the caller's tenant membership +with grant-file patterns across accessible tenant registries. This is the +broker-era equivalent of the loopback dashboard's profile-verification view. + +```json +{ + "entitlements": [ + { + "tenant": "opensoft", + "kind": "ai-provider", + "provider": "claude", + "profile": "team-001", + "credentialRef": "ai/secrets/claude/team-001.credentials.sops.yaml", + "escrowStatus": "escrowed" + }, + { + "tenant": "opensoft", + "kind": "cloud", + "provider": "aws", + "profile": "team-001", + "mode": "federated" + } + ] +} +``` + +### `POST /v1/credentials:fetch` + +The single credential-release path. + +```json +{ + "tenant": "opensoft", + "kind": "ai-provider", + "provider": "claude", + "profile": "team-001" +} +``` + +Broker sequence: resolve tenant → select the tenant age identity → read +`grants/users/.json` → confirm `profile` matches a granted pattern +for `provider` → resolve `credentialRef` from `source.json` (reusing the +existing path-traversal guard) → decrypt in memory → run the existing +`validate_plaintext` shape check → return. + +```json +{ + "leaseId": "ls_01J...", + "ttl": 900, + "materialization": { + "path": "profiles/team-001/.credentials.json", + "filename": ".credentials.json", + "mode": "0600" + }, + "credential": { "…": "opaque provider plaintext" } +} +``` + +The `materialization` block is exactly the current `profile_root` plus +`credential_name` mapping (`.credentials.json` for claude, `auth.json` for +codex, provider-specific homes for the rest), so the resolver writes it +atomically the same way escrow restore does. + +Errors: `403` identity, grant, or tenant denied; `404` no escrow present; `409` +malformed escrow. + +### `POST /v1/leases/:renew` and `DELETE /v1/leases/` + +TTL re-fetch and explicit drop. The `leaseId` is wired in from the first +version even if renew ships later. + +### `GET /v1/healthz` + +Unauthenticated liveness. + +### Cross-cutting + +Every fetch emits a structured audit record — +`{ts, sub, github_login|agent_id, tenant, kind, provider, profile, leaseId, +outcome}` — and never the value. Decryption happens in memory only; the +plaintext is returned in the TLS response body only. Requests are rate-limited +per identity. + +### The `kind` enum + +`ai-provider | cloud | git | mcp`. For `kind: cloud` with `mode: federated`, the +broker performs an STS or OIDC federation exchange (Keycloak to +AWS/GCP/Azure) and returns short-lived cloud credentials instead of releasing a +stored long-lived key — so those cloud secrets need not sit in SOPS at all. + +## Bench-side resolver + +A shared function the `p*` launchers call before materializing a profile: + +1. Canonicalize the requested name (`team001` → `team-001`) and take the + provider from the launcher. +2. Ensure a Keycloak session: read the cached refresh token (mode `0600`) from + the profile home; if missing or expired, run the device-code flow (print the + verification URL and code, poll), and cache the refresh token. +3. Exchange for an access token with `aud: workbench-broker`. +4. `POST /v1/credentials:fetch` and write `credential` to `materialization.path` + atomically (temp file plus `mv`, mode `0600`). +5. Record the lease and expiry; within the TTL on the next launch, skip the + fetch. +6. Break-glass fallback: if the broker is unreachable **and** a local recovery + identity is present, fall through to + `scripts/provider-credential-escrow restore`. + +## Machine identities + +Unattended agents set `WORKBENCH_AGENT_CLIENT_ID` and its secret — the only +material escrowed per agent, and small and rotatable. The resolver detects these +and uses the `client_credentials` grant instead of the device-code flow. The +broker authorizes service accounts against a parallel +`grants/agents/.json`, keeping the same grant-file model: + +```json +{ + "version": 1, + "agent": "nightly-refactor", + "tenant": "opensoft", + "profiles": { + "claude": ["team-001"], + "aws": ["team-001"] + } +} +``` + +This is the correct answer to the existing warning against using interactive +subscription OAuth for unattended automation: an agent gets only the credentials +its own grant allows, and disabling its Keycloak client revokes access +instantly. + +## Registry access + +The broker reads the ciphertext from the private tenant registries with a +**read-only deploy key per tenant**. It clones each registry once and `git +pull`s on a timer or on cache-miss, so it always sees the latest escrow. It is +safe for the ciphertext to reside on the broker host because it is encrypted and +only the per-tenant age identity — held only by the broker — can decrypt it. + +A pushed object-store mirror (a CI job in each tenant repo publishing +`ai/source.json`, `ai/grants/`, and `ai/secrets/` to S3/MinIO) is a supported +alternative that decouples the broker from git, at the cost of a pipeline to +maintain. Live git pull is the recommended starting point. + +## Deployment: the `Install-Keycloak` installer repo + +Self-hosting Keycloak is opt-in — a team using a shared org server should never +be forced to pull it — so it follows the `Install-Wave-Terminal` pattern rather +than becoming a submodule. `setup.sh` can clone `opensoft/Install-Keycloak` on +demand (sibling checkout preferred, else `~/.cache/workbenches/`, with env +overrides), best-effort. The installer repo is secret-free and contains: + +- `docker-compose.yml` — Keycloak plus Postgres, with TLS and healthchecks. +- `realm-workbenches.json` — an importable realm export: the `/tenants/*` + groups, the `workbench-launcher` and `workbench-broker` clients, the + `broker-audience` client scope and mapper, and a GitHub IdP stub with the + `github_login` mapper. Placeholders only; the GitHub OAuth application id and + secret and any client secrets are entered at bootstrap and never committed. +- `scripts/bootstrap.sh` — bring up the stack, import the realm, prompt for the + GitHub OAuth credentials, and print the device-flow client id and broker URL + to drop into workBenches config. + +The **broker service itself lives in workBenches** at `apps/credential-broker/`, +next to `apps/credential-manager/`, because it reuses the Python escrow code +directly. Only Keycloak, its compose file, and the realm export live in +`Install-Keycloak`, keeping the auth-server install separable from the +credential logic. + +## Reuse versus build + +Reused as-is from the current codebase: + +- the `validate_plaintext` provider shape validators; +- `credentialRef` resolution and the path-traversal guard; +- the `profile_root` and `credential_name` materialization mapping; +- `grants/users/*.json`; +- `source.json` discovery; +- the atomic-write pattern (temp file plus `mv`, mode `0600`). + +Built new: + +- the Python broker service (`apps/credential-broker/`); +- the Keycloak realm, client, and scope configuration (secret-free, shipped in + `Install-Keycloak`); +- the shared launcher resolver function; +- `grants/agents/*.json`. + +## Security model + +- The broker is a crown-jewel target. It requires mTLS, audience-locked tokens, + per-request audit, in-memory-only decryption, no value logging, and per-identity + rate limiting. +- A released vendor token lives wherever it is materialized. Mitigate with a + short materialization TTL plus re-fetch, and keep per-profile isolation so a + leak is scoped to one identity. +- Keycloak revokes *access* instantly by disabling an identity or client, but a + leaked vendor secret still requires vendor-side rotation, exactly as today. +- The shared-server model introduces a network dependency and requires benches + to trust the Keycloak realm URL and CA. The SOPS-direct restore path remains + the offline break-glass fallback. +- Every artifact shipped in the public repositories — the realm export, group, + client, and scope definitions, the broker code, and the resolver — is + secret-free. Only credential values remain in SOPS and are held in memory by + the broker. + +## Phased delivery + +The phases are sequenced so the riskiest assumptions are proven first: that the +existing escrow logic ports cleanly to Python, and that the full +authenticate → authorize → decrypt → materialize loop works end to end. Each +phase is independently shippable. + +Two implementation facts shape phase 0 and 1. First, `provider-credential-escrow` +is Bash (jq plus the `sops` CLI), so the broker does not import it; it +re-implements the same `validate_plaintext` shapes and `credentialRef` guard in +Python and shells out to the already-installed `sops` binary with +`SOPS_AGE_KEY_FILE` set per request. Second, one age key file per tenant is held +by the broker at mode `0600` and selected by the caller's tenant group. + +### Phase 0 — Broker core, no Keycloak + +Prove the SOPS-side plumbing in isolation. + +- Scaffold `apps/credential-broker/` (Python, FastAPI) with a shared + `escrow_core.py`: the per-provider validators, `credentialRef` resolution and + path-traversal guard, and the `profile_root`/`credential_name` → + `materialization` mapping, all ported from `provider-credential-escrow`. +- Registry-access module: read-only clone plus `git pull`, per-tenant age key, + `sops decrypt`. +- `POST /credentials:fetch` behind a dev bearer token with a hardcoded identity; + no OIDC yet. + +Exit criteria: against a fixture registry and test age key, fetching +`claude/team-001` returns a shape-valid credential; unit tests green. + +### Phase 1 — Vertical slice: real Keycloak, one tenant, `pclaude` only (MVP) + +The whole authenticate → release loop working for one engineer. + +- Stand up `Install-Keycloak`: compose (Keycloak plus Postgres), + `realm-workbenches.json` (`/tenants/opensoft`, the `workbench-launcher` + device + PKCE client, the `workbench-broker` bearer-only client, the + `broker-audience` scope and mapper, and the GitHub IdP stub), and + `bootstrap.sh`. +- Broker: OIDC validation (JWKS, issuer, `aud`, `exp`), identity and tenant + extraction, authorization against `grants/users/.json`, TLS, and + audit logging without values. +- Resolver wired into `pclaude` only: device-code flow, cached refresh token + (mode `0600`), atomic write, and the `provider-credential-escrow restore` + break-glass fallback. + +Exit criteria: an engineer runs `pclaude team001`, authenticates via GitHub +through Keycloak's device flow, and the profile materializes from the broker; +removing the grant yields `403`. + +### Phase 2 — Breadth: all providers and multi-tenant + +- Extend to `codex`/`openai`, `gemini`, `grok`, `glm`, and `pi`, porting each + provider's validator and materialization target. +- Add a second tenant (`/tenants/farheap`, its own age identity and deploy key) + and `GET /entitlements` intersecting membership with grants across accessible + tenants. +- Wire the resolver into the remaining `p*` launchers. + +Exit criteria: any engineer, any granted provider profile, across both tenants. + +### Phase 3 — Unattended agents + +- `agent-` confidential clients (`client_credentials`), + `grants/agents/.json`, and resolver detection of + `WORKBENCH_AGENT_CLIENT_ID` and its secret to skip the device flow. +- Short materialization TTL plus re-fetch for agents. + +Exit criteria: a headless agent fetches only its granted credentials, and +disabling its Keycloak client revokes access instantly. + +### Phase 4 — Broaden cred kinds: `git`, `mcp`, then federated `cloud` + +- Generalize the `secrets/` tree to typed entries; `kind: git` and `kind: mcp` + reuse the same fetch path. +- `kind: cloud` with `mode: federated`: Keycloak-to-AWS/GCP/Azure OIDC + workload-identity federation, where the broker performs the STS exchange and + returns short-lived credentials, storing no long-lived cloud key in SOPS. + +Exit criteria: an AI in a bench pulls git and MCP tokens; cloud is served by +federation where the provider supports it. + +### Phase 5 — Hardening and operability + +- Lease renewal and active revocation endpoints, per-identity rate limiting, + audit shipping to a central log, metrics and health, rotation and break-glass + runbooks, and a threat-model review. +- Optional: a broker-served read-only entitlements view that replaces + `credential_manager.py`'s local inspection for shared-server deployments. + +The minimum viable milestone is the end of phase 1 — the smallest change that +removes the age recovery identity from a workstation. Phases 2 through 4 add +breadth; phase 5 is production readiness, though minimal TLS and audit are +already required in phase 1. + +## Resolved decisions + +- Lease semantics: the first version issues opaque TTLs only; tracked leases and + active revocation arrive in phase 5. A released vendor token cannot be + recalled regardless, so tracked leases mainly serve audit and forced + re-authentication, which are hardening concerns. The `leaseId` is returned + from the first version so the contract does not change later. +- Federated cloud credentials are not in the first version; they arrive in phase + 4. The first version covers the SOPS-backed `ai-provider` kinds, which add no + new trust relationships. + +## Open questions + +- Whether the broker should also serve the read-only `credential-manager` + dashboard's verification view directly, replacing its local-only inspection + for shared-server deployments (targeted for phase 5). diff --git a/docs/multi-provider-profiles.md b/docs/multi-provider-profiles.md index 32753a1..4b601d8 100644 --- a/docs/multi-provider-profiles.md +++ b/docs/multi-provider-profiles.md @@ -12,9 +12,11 @@ Run the onboarding directly with: ./scripts/setup-ai-profiles.sh --interactive ``` -The flow asks for consent, work companies, company emails and GitHub orgs, -personal subscription emails, and the personal GitHub owner. It searches each -owner's accessible repositories for a credential registry containing +The flow asks for consent, then for each work company an email and GitHub org +(the company name defaults to the email domain's first label, e.g. +`you@acme.com` suggests `acme`, and can be edited), personal subscription +emails, and the personal GitHub owner. It searches each owner's accessible +repositories for a credential registry containing `ai/source.json`. When none is found, the user may enter a registry URL or create local manual profile metadata. @@ -29,21 +31,50 @@ The standard launchers are: |---|---|---| | Claude | `pclaude` | `CLAUDE_CONFIG_DIR` | | ChatGPT/Codex | `pcodex` | `CODEX_HOME` | +| Pi harness | `ppi` | `PI_CODING_AGENT_DIR` | | Gemini | `pgemini` | `GEMINI_CLI_HOME` | | Grok | `pgrok` | `GROK_HOME` | | Z.AI GLM through OpenCode | `pglm` or `pzai` | profile-specific XDG directories | +Every managed provider root supports the same company-first layout: + +```text +profiles/ +|-- company-one/ +| |-- team/ +| |-- max/ +| `-- xfactor/ +|-- company-two/ +| |-- team/ +| |-- max/ +| `-- xfactor/ +`-- personal/ +``` + +`setup.sh` derives `company-one` and `company-two` by slugging the company +names entered during onboarding. Where a harness has portable conversation +state, its state root contains one directory for each entered company plus +`personal`. Authentication, caches, provider databases, installation identity, +and settings remain profile-local. + For example, `team001` resolves to the canonical `team-001` profile for every provider: ```bash pclaude team001 pcodex team001 +ppi team001 pgemini team001 pgrok team001 pglm team001 ``` +Pi profiles are derived from the same canonical identities and may contain +several Pi-specific provider logins. Use `ppi login team001`, then `/login` +inside Pi and select the provider. Pi OAuth credentials are harness-specific; +workBenches never copies Claude Code or Codex CLI tokens into Pi implicitly. +See [Pi multi-account profiles](pi-multi-account-profiles.md). + Login and status operations use the same pattern: ```bash diff --git a/docs/newBench.md b/docs/newBench.md index ad0547f..28af6f2 100644 --- a/docs/newBench.md +++ b/docs/newBench.md @@ -121,7 +121,8 @@ Copy the complete template below — do not remove any standard mount: } }, "containerEnv": { - "SHELL": "/bin/zsh" + "SHELL": "/bin/zsh", + "HISTFILE": "/home/${localEnv:USER}/.workbenches-history/.zsh_history" }, "remoteUser": "${localEnv:USER}", "updateRemoteUserUID": false, @@ -134,7 +135,7 @@ Copy the complete template below — do not remove any standard mount: // ============================================= // Workspace & history - "source={namebench}-zshhistory,target=/home/${localEnv:USER}/.zsh_history,type=volume", + "source={namebench}-zshhistory,target=/home/${localEnv:USER}/.workbenches-history,type=volume", "source=${localEnv:HOME}/projects,target=/workspace/projects,type=bind", // Shell configurations (bind, readonly) @@ -153,6 +154,7 @@ Copy the complete template below — do not remove any standard mount: "source=${localEnv:HOME}/.agents,target=/home/${localEnv:USER}/.agents,type=bind,consistency=cached", // Project Intelligence and local agent metadata "source=${localEnv:HOME}/.pi,target=/home/${localEnv:USER}/.pi,type=bind,consistency=cached", + "source=${localEnv:HOME}/.pi-profiles,target=/home/${localEnv:USER}/.pi-profiles,type=bind,consistency=cached", // Claude (Anthropic) — native installer "source=${localEnv:HOME}/.claude,target=/home/${localEnv:USER}/.claude,type=bind,consistency=cached", "source=${localEnv:HOME}/.claude.json,target=/home/${localEnv:USER}/.claude.json,type=bind,consistency=cached", diff --git a/docs/pi-multi-account-profiles.md b/docs/pi-multi-account-profiles.md new file mode 100644 index 0000000..fabc2d8 --- /dev/null +++ b/docs/pi-multi-account-profiles.md @@ -0,0 +1,82 @@ +# Pi multi-account profiles + +workBenches isolates Pi Coding Agent state by canonical AI identity. The same +name and alias used by `pclaude`, `pcodex`, `pgemini`, `pgrok`, and `pglm` is +accepted by `ppi`. + +```bash +ppi list +ppi login team001 +ppi status team001 +ppi team001 +``` + +`ppi team001` resolves to `team-001` and sets: + +```text +PI_CODING_AGENT_DIR=~/.pi-profiles/profiles/team-001/agent +``` + +Pi stores that profile's settings, sessions, and `auth.json` beneath the +isolated directory. The standard `~/.pi/agent` home remains untouched. + +## Authentication + +Run `ppi login PROFILE`, enter `/login` inside Pi, choose a provider, and +authenticate with the email printed by the launcher. A single canonical Pi +profile can hold several Pi provider records when they belong to the same +identity. + +Pi OAuth credentials are not interchangeable with Claude Code or Codex CLI +credentials. Setup never copies them automatically. This matters especially +for subscription products: support and billing in a third-party harness may +differ from the first-party CLI. + +Claude-backed profiles deliberately use the pinned +`@ramarivera/pi-claude-cli@0.3.1` proxy extension. `ppi` exports the matching +profile's `CLAUDE_CONFIG_DIR`; the extension then spawns `claude -p` and uses +that Claude Code profile's Pro/Max subscription. It does not copy the Claude +OAuth token into Pi. These profiles default to +`pi-claude-cli/claude-fable-5`. + +Profile setup also records the working npm executable in Pi's `npmCommand` +setting so package installation does not accidentally use a broken or shadowed +npm shim. + +`ppi status PROFILE` prints the profile, expected email, and configured Pi +provider names without exposing secrets. Remove only one Pi provider with: + +```bash +ppi logout team001 openai-codex +``` + +## Setup and containers + +`scripts/setup-ai-profiles.sh --apply-existing` derives +`~/.config/workbenches/pi-profiles.json` from the five provider manifests and +runs the idempotent Pi setup. Existing launcher fallback profiles are included +so a still-usable local profile is not silently dropped while its registry +metadata is being reconciled. Wave/devBench containers mount +`~/.pi-profiles` and install the same `ppi` launcher. + +Use WSL/Linux Pi as the canonical runtime for workBenches and devBench work. +Native Windows Pi keeps a separate home unless a Windows-specific profile +launcher is deliberately installed. + +## Encrypted escrow + +After a Pi-specific login is verified, explicitly back it up to the owning +private registry: + +```bash +scripts/pi-credential-escrow backup \ + --repo /path/to/Tenant-Credentials \ + --profile team-001 \ + --identity-file ~/.config/workbenches/tenant-recovery.agekey +``` + +The command validates Pi's auth shape, encrypts to +`ai/secrets/pi/PROFILE.auth.sops.yaml`, decrypts it again, and compares a +canonical JSON hash without printing credentials. Use `check` or `restore` +with the same arguments. Restore preserves an existing local credential unless +`--force` is supplied. diff --git a/docs/setup-script-ui.md b/docs/setup-script-ui.md index 3ed55e2..6d4f01b 100644 --- a/docs/setup-script-ui.md +++ b/docs/setup-script-ui.md @@ -350,7 +350,7 @@ personal subscription identities, GitHub ownership, and registry selection. The discovery sequence is: 1. Ask for the personal GitHub username. -2. Ask how many companies use the workstation. +2. Ask how many companies the user works for using this workstation. 3. For each company, ask its name, the user's company email, AI providers, and GitHub organization. 4. Ask for each personal AI subscription email and its providers. @@ -359,7 +359,11 @@ The discovery sequence is: verify that `ai/source.json` exists. 7. Let the user select a result, enter a URL/local path, or fill profile metadata manually. -8. Compose and install Claude, Codex, Gemini, Grok, and GLM profiles. +8. Slug each entered company name and use it as that company's profile and + shared-state root. +9. Create `team`, `max`, and `xfactor` profile groups for every company, plus + the separate `personal` group. +10. Compose and install Claude, Codex, Pi, Gemini, Grok, and GLM profiles. No password, OAuth token, API key, or decrypted SOPS value is requested. Existing standard CLI logins are detected and preserved; their OAuth caches diff --git a/scripts/compose-pi-profiles.py b/scripts/compose-pi-profiles.py new file mode 100755 index 0000000..5ab8ecc --- /dev/null +++ b/scripts/compose-pi-profiles.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Compose Pi harness profiles from canonical provider manifests.""" + +from __future__ import annotations + +import argparse +import json +import os +import pathlib +import tempfile + + +PROVIDERS = ("claude", "openai", "gemini", "grok", "glm") +IDENTITY_FIELDS = ("email", "family", "aliases", "profilePath") + + +def atomic_json(path: pathlib.Path, payload: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8") as stream: + json.dump(payload, stream, indent=2) + stream.write("\n") + os.chmod(temporary, 0o600) + os.replace(temporary, path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +def add_profile(profiles: dict[str, dict], provider: str, raw: dict, source: pathlib.Path) -> None: + if not isinstance(raw, dict) or any(not raw.get(field) for field in ("name", "email", "family")): + raise ValueError(f"invalid {provider} profile in {source}") + normalized = { + "email": raw["email"], + "family": raw["family"], + "aliases": sorted(raw.get("aliases") or []), + "profilePath": raw.get("profilePath", raw["name"]), + } + existing = profiles.get(raw["name"]) + if existing is None: + profiles[raw["name"]] = {"name": raw["name"], **normalized, "providers": [provider]} + return + if any(existing[field] != normalized[field] for field in IDENTITY_FIELDS): + raise ValueError(f"provider identity mismatch for Pi profile {raw['name']}") + if provider not in existing["providers"]: + existing["providers"].append(provider) + + +def compose(config_dir: pathlib.Path, profile_roots: dict[str, pathlib.Path] | None = None) -> list[dict]: + profiles: dict[str, dict] = {} + for provider in PROVIDERS: + manifest = config_dir / f"{provider}-profiles.json" + if not manifest.exists(): + continue + payload = json.loads(manifest.read_text(encoding="utf-8")) + if payload.get("version") != 1 or not isinstance(payload.get("profiles"), list): + raise ValueError(f"invalid profile manifest: {manifest}") + for raw in payload["profiles"]: + add_profile(profiles, provider, raw, manifest) + for provider, root in (profile_roots or {}).items(): + if provider not in PROVIDERS or not root.exists(): + continue + selected: dict[str, tuple[int, pathlib.Path, dict]] = {} + for metadata in sorted(root.rglob(".profile.json")): + raw = json.loads(metadata.read_text(encoding="utf-8")) + relative_profile = metadata.parent.relative_to(root) + depth = len(relative_profile.parts) + score = depth + (100 if raw.get("profilePath") else 0) + current = selected.get(raw.get("name", "")) + if current is None or score > current[0]: + selected[raw.get("name", "")] = (score, metadata, raw) + for _, metadata, raw in selected.values(): + add_profile(profiles, provider, raw, metadata) + return [profiles[name] for name in sorted(profiles)] + + +def compose_families(config_dir: pathlib.Path, profiles: list[dict]) -> list[str]: + families = {profile["family"] for profile in profiles} + for provider in PROVIDERS: + manifest = config_dir / f"{provider}-profiles.json" + if not manifest.exists(): + continue + payload = json.loads(manifest.read_text(encoding="utf-8")) + declared = payload.get("families", []) + if not isinstance(declared, list) or any( + not isinstance(family, str) or not family for family in declared + ): + raise ValueError(f"invalid family inventory: {manifest}") + families.update(declared) + return sorted(families) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--config-dir", type=pathlib.Path, required=True) + parser.add_argument("--output", type=pathlib.Path) + parser.add_argument("--profile-root", action="append", default=[], metavar="PROVIDER=PATH") + parser.add_argument("--check", action="store_true") + args = parser.parse_args() + try: + profile_roots = {} + for value in args.profile_root: + provider, separator, path = value.partition("=") + if not separator or provider not in PROVIDERS: + raise ValueError(f"invalid --profile-root: {value}") + profile_roots[provider] = pathlib.Path(path).expanduser().resolve() + result = compose(args.config_dir.expanduser().resolve(), profile_roots) + families = compose_families(args.config_dir.expanduser().resolve(), result) + if not args.check: + if args.output is None: + parser.error("--output is required unless --check is used") + atomic_json( + args.output.expanduser().resolve(), + {"version": 1, "families": families, "profiles": result}, + ) + print(f"Pi profile composition valid: profiles={len(result)}") + return 0 + except (OSError, json.JSONDecodeError, ValueError) as exc: + parser.exit(1, f"error: {exc}\n") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/consolidate-claude-state.py b/scripts/consolidate-claude-state.py new file mode 100755 index 0000000..9e6e179 --- /dev/null +++ b/scripts/consolidate-claude-state.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +"""Consolidate legacy OpenSoft Claude state families without losing data.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import shutil +import tempfile +from datetime import datetime, timezone +from pathlib import Path + + +SOURCE_FAMILIES = ("opensoft-team", "opensoft-max", "xfactory") +STATE_LINKS = ("history.jsonl", "projects", "file-history", "plans", "tasks", "todos") + + +def digest(path: Path) -> str: + value = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + value.update(chunk) + return value.hexdigest() + + +def atomic_text(path: Path, content: str, mode: int = 0o600) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + stream.write(content) + stream.flush() + os.fsync(stream.fileno()) + os.chmod(temporary, mode) + os.replace(temporary, path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +def history_records(path: Path) -> list[tuple[str, str]]: + records = [] + for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + if not line.strip(): + continue + try: + value = json.loads(line) + except json.JSONDecodeError as error: + raise ValueError(f"invalid JSONL at {path}:{number}: {error}") from error + canonical = json.dumps(value, sort_keys=True, separators=(",", ":")) + records.append((canonical, line)) + return records + + +def merge_history(target: Path, sources: list[Path]) -> tuple[int, int]: + existing = history_records(target) + seen = {canonical for canonical, _ in existing} + lines = [line for _, line in existing] + added = 0 + for source in sources: + for canonical, line in history_records(source): + if canonical not in seen: + seen.add(canonical) + lines.append(line) + added += 1 + atomic_text(target, "".join(f"{line}\n" for line in lines)) + return len(lines), added + + +def merge_memory(target: Path, source: Path, family: str) -> bool: + source_hash = digest(source) + marker = f"" + target_text = target.read_text(encoding="utf-8") + if marker in target_text: + return False + source_text = source.read_text(encoding="utf-8") + combined = f"{target_text.rstrip()}\n\n{marker}\n\n{source_text.lstrip()}" + if not combined.endswith("\n"): + combined += "\n" + atomic_text(target, combined, target.stat().st_mode & 0o777) + return True + + +def merge_tree(source_root: Path, target_root: Path, family: str, report: dict) -> None: + for source in sorted(source_root.rglob("*")): + relative = source.relative_to(source_root) + if relative == Path("history.jsonl"): + continue + target = target_root / relative + if source.is_dir(): + target.mkdir(parents=True, exist_ok=True) + continue + target.parent.mkdir(parents=True, exist_ok=True) + if not target.exists(): + try: + os.link(source, target) + report["hardlinked"] += 1 + except OSError: + shutil.copy2(source, target) + report["copied"] += 1 + continue + if digest(source) == digest(target): + report["identical"] += 1 + continue + if relative.name == "MEMORY.md" and relative.parent.name == "memory": + if merge_memory(target, source, family): + report["memory_files_merged"] += 1 + continue + conflict = target.with_name(f"{target.name}.migrated-from-{family}-{digest(source)[:12]}") + if not conflict.exists(): + shutil.copy2(source, conflict) + report["conflicts_preserved"].append(str(conflict)) + + +def repoint_profiles(profile_root: Path, state_root: Path, report: dict) -> None: + target_state = state_root / "opensoft" + for metadata in profile_root.rglob(".profile.json"): + try: + profile = json.loads(metadata.read_text(encoding="utf-8")) + except json.JSONDecodeError as error: + raise ValueError(f"invalid profile metadata: {metadata}: {error}") from error + if not str(profile.get("email", "")).lower().endswith("@opensoft.one"): + continue + if profile.get("family") != "opensoft": + profile["family"] = "opensoft" + atomic_text(metadata, json.dumps(profile, indent=2) + "\n", 0o600) + report["metadata_updated"] += 1 + profile_dir = metadata.parent + for name in STATE_LINKS: + link = profile_dir / name + if not link.is_symlink(): + continue + destination = target_state / name + relative = os.path.relpath(destination, profile_dir) + temporary = profile_dir / f".{name}.state-migration" + temporary.symlink_to(relative) + os.replace(temporary, link) + report["links_repointed"] += 1 + + +def backup_collisions(state_root: Path, backup_root: Path) -> None: + target_root = state_root / "opensoft" + collision_root = backup_root / "opensoft-collision-targets" + for source_family in SOURCE_FAMILIES: + source_root = state_root / source_family + for source in source_root.rglob("*"): + if not source.is_file(): + continue + relative = source.relative_to(source_root) + target = target_root / relative + if target.is_file() and digest(source) != digest(target): + backup = collision_root / relative + if not backup.exists(): + backup.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(target, backup) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base", type=Path, default=Path.home() / ".claude-profiles") + parser.add_argument("--apply", action="store_true") + args = parser.parse_args() + base = args.base.expanduser().resolve() + state_root = base / "state" + profile_root = base / "profiles" + target_root = state_root / "opensoft" + sources = [state_root / family for family in SOURCE_FAMILIES] + missing = [str(path) for path in [target_root, *sources] if not path.is_dir()] + if missing: + parser.error(f"missing state directories: {', '.join(missing)}") + if not args.apply: + print("Ready to consolidate:", ", ".join(SOURCE_FAMILIES), "-> opensoft") + return 0 + + timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + backup_root = base / "migration-backups" / f"state-consolidation-{timestamp}" + backup_root.mkdir(parents=True) + report = { + "timestamp": timestamp, + "sources": list(SOURCE_FAMILIES), + "hardlinked": 0, + "copied": 0, + "identical": 0, + "memory_files_merged": 0, + "conflicts_preserved": [], + "metadata_updated": 0, + "links_repointed": 0, + } + + backup_collisions(state_root, backup_root) + history_paths = [source / "history.jsonl" for source in sources] + total, added = merge_history(target_root / "history.jsonl", history_paths) + report["history_records"] = total + report["history_records_added"] = added + for family, source in zip(SOURCE_FAMILIES, sources): + merge_tree(source, target_root, family, report) + + repoint_profiles(profile_root, state_root, report) + total, added_after_cutover = merge_history(target_root / "history.jsonl", history_paths) + report["history_records"] = total + report["history_records_added_after_cutover"] = added_after_cutover + + retired = backup_root / "retired-state" + retired.mkdir() + for source in sources: + os.replace(source, retired / source.name) + atomic_text(backup_root / "report.json", json.dumps(report, indent=2) + "\n") + print(json.dumps(report, indent=2)) + print(f"Retired source state: {retired}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/consolidate-codex-state.py b/scripts/consolidate-codex-state.py new file mode 100755 index 0000000..0eac17f --- /dev/null +++ b/scripts/consolidate-codex-state.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +"""Consolidate portable Codex profile history into company state families.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import shutil +import tempfile +from datetime import datetime, timezone +from pathlib import Path + + +DIRECTORIES = ("sessions", "archived_sessions") +JSONL_FILES = ("history.jsonl", "session_index.jsonl") +FAMILIES = ("opensoft", "medx", "personal") + + +def family_for(profile: dict) -> str: + email = str(profile.get("email", "")).lower() + name = str(profile.get("name", "")).lower() + if email.endswith("@opensoft.one"): + return "opensoft" + if "medx" in name or "medx" in email: + return "medx" + return "personal" + + +def hash_file(path: Path) -> str: + value = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + value.update(chunk) + return value.hexdigest() + + +def atomic_text(path: Path, content: str, mode: int = 0o600) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + stream.write(content) + stream.flush() + os.fsync(stream.fileno()) + os.chmod(temporary, mode) + os.replace(temporary, path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +def jsonl_records(path: Path) -> list[tuple[str, str]]: + if not path.is_file(): + return [] + records = [] + for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + if not line.strip(): + continue + try: + value = json.loads(line) + except json.JSONDecodeError as error: + raise ValueError(f"invalid JSONL at {path}:{number}: {error}") from error + canonical = json.dumps(value, sort_keys=True, separators=(",", ":")) + records.append((canonical, line)) + return records + + +def replace_with_link(source: Path, destination: Path, backup: Path) -> None: + if source.is_symlink(): + source.unlink() + elif source.exists(): + backup.parent.mkdir(parents=True, exist_ok=True) + os.replace(source, backup) + relative = os.path.relpath(destination, source.parent) + temporary = source.parent / f".{source.name}.state-migration" + temporary.symlink_to(relative) + os.replace(temporary, source) + + +def merge_directories( + profiles: list[tuple[Path, dict]], state_root: Path, backup_root: Path, report: dict +) -> None: + for profile_dir, profile in profiles: + family = family_for(profile) + for name in DIRECTORIES: + source_root = profile_dir / name + target_root = state_root / family / name + target_root.mkdir(parents=True, exist_ok=True) + if source_root.is_dir() and not source_root.is_symlink(): + for source in sorted(source_root.rglob("*")): + if not source.is_file(): + continue + relative = source.relative_to(source_root) + target = target_root / relative + target.parent.mkdir(parents=True, exist_ok=True) + if not target.exists(): + try: + os.link(source, target) + report["sessions_hardlinked"] += 1 + except OSError: + shutil.copy2(source, target) + report["sessions_copied"] += 1 + elif hash_file(source) == hash_file(target): + report["sessions_identical"] += 1 + else: + conflict = target.with_name( + f"{target.name}.migrated-{profile['name']}-{hash_file(source)[:12]}" + ) + if not conflict.exists(): + shutil.copy2(source, conflict) + report["session_conflicts"].append(str(conflict)) + backup = backup_root / "profile-state" / profile_dir.relative_to(state_root.parent / "profiles") / name + replace_with_link(source_root, target_root, backup) + report["links_repointed"] += 1 + + +def merge_jsonl( + profiles: list[tuple[Path, dict]], state_root: Path, backup_root: Path, report: dict +) -> None: + for family in FAMILIES: + family_profiles = [(path, profile) for path, profile in profiles if family_for(profile) == family] + for name in JSONL_FILES: + target = state_root / family / name + target.parent.mkdir(parents=True, exist_ok=True) + sources = [path / name for path, _ in family_profiles] + seen: set[str] = set() + lines: list[str] = [] + for source in [target, *sources]: + if source.is_symlink(): + continue + for canonical, line in jsonl_records(source): + if canonical not in seen: + seen.add(canonical) + lines.append(line) + atomic_text(target, "".join(f"{line}\n" for line in lines)) + report[f"{family}_{name}_records"] = len(lines) + for profile_dir, _ in family_profiles: + source = profile_dir / name + backup = ( + backup_root + / "profile-state" + / profile_dir.relative_to(state_root.parent / "profiles") + / name + ) + replace_with_link(source, target, backup) + report["links_repointed"] += 1 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base", type=Path, default=Path.home() / ".chatgpt-profiles") + parser.add_argument("--apply", action="store_true") + args = parser.parse_args() + base = args.base.expanduser().resolve() + profile_root = base / "profiles" + state_root = base / "state" + profiles = [] + for metadata in sorted(profile_root.rglob(".profile.json")): + profile = json.loads(metadata.read_text(encoding="utf-8")) + profiles.append((metadata.parent, profile)) + if not profiles: + parser.error(f"no Codex profiles found under {profile_root}") + if not args.apply: + print(f"Ready to consolidate {len(profiles)} Codex profile directories") + return 0 + + timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + backup_root = base / "migration-backups" / f"state-consolidation-{timestamp}" + backup_root.mkdir(parents=True) + for family in FAMILIES: + family_root = state_root / family + for name in DIRECTORIES: + (family_root / name).mkdir(parents=True, exist_ok=True) + for name in JSONL_FILES: + path = family_root / name + if not path.exists(): + atomic_text(path, "") + + report = { + "timestamp": timestamp, + "profiles": len(profiles), + "sessions_hardlinked": 0, + "sessions_copied": 0, + "sessions_identical": 0, + "session_conflicts": [], + "links_repointed": 0, + "metadata_updated": 0, + } + merge_directories(profiles, state_root, backup_root, report) + merge_jsonl(profiles, state_root, backup_root, report) + for profile_dir, profile in profiles: + family = family_for(profile) + if profile.get("family") != family: + profile["family"] = family + atomic_text(profile_dir / ".profile.json", json.dumps(profile, indent=2) + "\n") + report["metadata_updated"] += 1 + atomic_text(backup_root / "report.json", json.dumps(report, indent=2) + "\n") + print(json.dumps(report, indent=2)) + print(f"Migration backup: {backup_root}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/interactive-setup.sh b/scripts/interactive-setup.sh index d0a8f89..097ae54 100755 --- a/scripts/interactive-setup.sh +++ b/scripts/interactive-setup.sh @@ -273,7 +273,11 @@ check_component_status() { ;; pi_cli) if command -v pi &>/dev/null; then - [[ -f "$HOME/.pi/agent/auth.json" ]] && echo "installed" || echo "needs creds" + if [[ -f "$HOME/.pi/agent/auth.json" ]] || find "$HOME/.pi-profiles/profiles" -mindepth 3 -maxdepth 3 -name auth.json -print -quit 2>/dev/null | grep -q .; then + echo "installed" + else + echo "needs creds" + fi else echo "not installed" fi @@ -1894,6 +1898,9 @@ process_selections() { if [ "$is_installed" = true ]; then echo -e " ${GREEN}✓ Pi Coding Agent is installed${NC}" ((success_count++)) + elif [[ -x /usr/bin/npm ]] && /usr/bin/npm install -g --ignore-scripts @earendil-works/pi-coding-agent; then + echo -e " ${GREEN}✓ Pi Coding Agent installed${NC}" + ((success_count++)) elif command -v npm &>/dev/null && npm install -g --ignore-scripts @earendil-works/pi-coding-agent; then echo -e " ${GREEN}✓ Pi Coding Agent installed${NC}" ((success_count++)) @@ -1901,7 +1908,7 @@ process_selections() { echo -e " ${RED}✗ Failed to install Pi Coding Agent${NC}" ((fail_count++)) fi - command -v pi &>/dev/null && items_needing_creds+=("pi") + { command -v pi &>/dev/null || [[ -x "$HOME/.npm-global/bin/pi" ]]; } && items_needing_creds+=("pi") ;; spec_kit) @@ -2386,13 +2393,14 @@ process_selections() { pi) echo -e "${CYAN}▶ Setting up Pi Coding Agent authentication...${NC}" - echo -e "${DIM}Pi uses /login inside its interactive terminal.${NC}" - read -p "Launch Pi now? [Y/n]: " launch_pi + echo -e "${DIM}Pi credentials are isolated by canonical profile through ppi.${NC}" + read -p "Launch a Pi profile login now? [Y/n]: " launch_pi if [[ ! $launch_pi =~ ^[Nn] ]]; then - echo -e "${YELLOW}Run /login, choose a provider, then exit Pi when finished.${NC}" - pi + read -r -p "Pi profile name or alias: " pi_profile + echo -e "${YELLOW}Run /login, choose a provider, verify the matching identity, then exit Pi.${NC}" + ppi login "$pi_profile" else - echo -e "${YELLOW}Skipped. Run 'pi' and enter /login anytime.${NC}" + echo -e "${YELLOW}Skipped. Run 'ppi login PROFILE' anytime.${NC}" fi echo "" ;; diff --git a/scripts/onboard-ai-profiles.py b/scripts/onboard-ai-profiles.py index d288f03..72f6431 100755 --- a/scripts/onboard-ai-profiles.py +++ b/scripts/onboard-ai-profiles.py @@ -75,6 +75,11 @@ def slugify(value: str) -> str: return slug or "account" +def company_name_from_email(email: str) -> str: + domain = email.rsplit("@", 1)[-1] + return domain.split(".")[0] + + def parse_providers(value: str | list[str] | None) -> list[str]: if value is None or value == "" or value == "all": return list(PROVIDERS) @@ -310,15 +315,15 @@ def interactive_answers() -> dict[str, Any]: if not github_user: raise RuntimeError("a personal GitHub username is required") - company_count = ask_count("How many companies do you use this workstation for?") + company_count = ask_count("How many companies do you work for using this workstation?") companies: list[dict[str, Any]] = [] for index in range(1, company_count + 1): print(f"\nCompany {index}") - name = ask("Company name") + email = ask_email("Company login email") + name = ask("Company name", company_name_from_email(email)) if not name: raise RuntimeError("company name is required") - email = ask_email(f"Your login email at {name}") - org = ask(f"GitHub organization for {name}") + org = ask(f"GitHub organization for {name}", name) if not org: raise RuntimeError("company GitHub organization is required") providers = parse_providers( @@ -361,11 +366,18 @@ def normalize_answers(payload: dict[str, Any]) -> dict[str, Any]: personal = payload.get("personal", {}) if not isinstance(companies, list) or not isinstance(personal, dict): raise ValueError("companies and personal must be valid objects") + company_slugs: set[str] = set() for company in companies: if not all(str(company.get(field, "")).strip() for field in ("name", "email", "githubOrg")): raise ValueError("each company requires name, email, and githubOrg") if not EMAIL_RE.match(company["email"]): raise ValueError(f"invalid company email: {company['email']}") + company_slug = slugify(company["name"]) + if company_slug == "personal": + raise ValueError("company name cannot use the reserved personal profile scope") + if company_slug in company_slugs: + raise ValueError(f"company names produce the same profile scope: {company_slug}") + company_slugs.add(company_slug) company["providers"] = parse_providers(company.get("providers")) company["registry"] = company.get("registry") or "manual" accounts = personal.get("accounts", []) @@ -392,6 +404,7 @@ def add_manual_profile( preferred_name: str, email: str, family: str, + profile_path: str, workspace: str, aliases: list[str], ) -> None: @@ -416,6 +429,7 @@ def add_manual_profile( "name": name, "email": email, "family": family, + "profilePath": f"{profile_path}/{name}", "aliases": provider_aliases, "workspace": workspace, "status": "active", @@ -424,6 +438,38 @@ def add_manual_profile( ) +def company_profile_category(profile: dict[str, Any]) -> str: + path_parts = str(profile.get("profilePath", "")).lower().split("/") + for category in ("team", "max", "xfactor"): + if category in path_parts: + return category + name = str(profile["name"]).lower() + if name == "team" or name.startswith("team-"): + return "team" + if name == "max" or name.startswith("max-"): + return "max" + return "xfactor" + + +def apply_scope_layout( + output: dict[str, list[dict[str, Any]]], + scoped: dict[str, list[dict[str, Any]]], + family: str, + workspace: str, +) -> None: + for provider, profiles in scoped.items(): + by_name = {profile["name"]: profile for profile in output[provider]} + for source_profile in profiles: + target = by_name[source_profile["name"]] + target["family"] = family + target["workspace"] = workspace + if family == "personal": + target["profilePath"] = f"personal/{target['name']}" + else: + category = company_profile_category(source_profile) + target["profilePath"] = f"{family}/{category}/{target['name']}" + + def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--answers", type=pathlib.Path, help="Use a JSON answer file instead of prompting") @@ -447,6 +493,7 @@ def main() -> int: return 0 source_paths: list[pathlib.Path] = [] + company_sources: list[tuple[dict[str, Any], pathlib.Path]] = [] selected: dict[str, Any] = {} for company in answers["companies"]: registry = company["registry"] @@ -454,6 +501,8 @@ def main() -> int: selected[f"company:{company['githubOrg']}"] = registry if source and source not in source_paths: source_paths.append(source) + if source: + company_sources.append((company, source)) personal_registry = answers["personal"]["registry"] personal_source = materialize_registry(personal_registry, args.registry_root) selected[f"personal:{answers['personal']['githubOrg']}"] = personal_registry @@ -463,6 +512,30 @@ def main() -> int: output = COMPOSER.compose([str(path) for path in source_paths], answers["githubUser"]) if source_paths else { provider: [] for provider in PROVIDERS } + assigned: dict[tuple[str, str], str] = {} + for company, source in company_sources: + family = slugify(company["name"]) + scoped = COMPOSER.compose([str(source)], answers["githubUser"]) + for provider, profiles in scoped.items(): + for profile in profiles: + key = (provider, profile["name"]) + previous = assigned.get(key) + if previous and previous != family: + raise RuntimeError( + f"{provider} profile {profile['name']} is assigned to multiple companies" + ) + assigned[key] = family + apply_scope_layout(output, scoped, family, company["name"]) + if personal_source: + scoped = COMPOSER.compose([str(personal_source)], answers["githubUser"]) + for provider, profiles in scoped.items(): + for profile in profiles: + key = (provider, profile["name"]) + if key in assigned: + raise RuntimeError( + f"{provider} profile {profile['name']} is assigned to company and personal registries" + ) + apply_scope_layout(output, scoped, "personal", "personal") for company in answers["companies"]: if not registry_is_manual(company["registry"]): @@ -473,7 +546,8 @@ def main() -> int: company["providers"], f"work-{slug}", company["email"], - f"work-{slug}", + slug, + f"{slug}/xfactor", company["name"], [slug], ) @@ -486,12 +560,18 @@ def main() -> int: account["email"], "personal", "personal", + "personal", [f"personal-{slugify(account['email'].split('@')[0])}"], ) args.output_dir.mkdir(parents=True, exist_ok=True) + families = [slugify(company["name"]) for company in answers["companies"]] + families.append("personal") for provider, filename in COMPOSER.PROVIDERS.items(): - COMPOSER.atomic_json(args.output_dir / filename, {"version": 1, "profiles": output[provider]}) + COMPOSER.atomic_json( + args.output_dir / filename, + {"version": 1, "families": families, "profiles": output[provider]}, + ) if source_paths and not any(output.values()): print( "Warning: registries were found, but none granted profiles to the supplied GitHub username.", diff --git a/scripts/pi-credential-escrow b/scripts/pi-credential-escrow new file mode 100755 index 0000000..1a04dc2 --- /dev/null +++ b/scripts/pi-credential-escrow @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +action="${1:-}"; [[ -n "$action" ]] && shift +repo_dir=""; profile=""; identity_file=""; force=false +usage() { + echo "Usage: pi-credential-escrow {backup|restore|check} --repo PATH --profile NAME --identity-file PATH [--force]" +} +while [[ $# -gt 0 ]]; do + case "$1" in + --repo) repo_dir="$2"; shift 2 ;; + --profile) profile="$2"; shift 2 ;; + --identity-file) identity_file="$2"; shift 2 ;; + --force) force=true; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; + esac +done + +[[ "$action" =~ ^(backup|restore|check)$ ]] || { usage >&2; exit 2; } +[[ -d "$repo_dir/.git" && -f "$repo_dir/ai/source.json" ]] || { echo "Invalid credential registry: $repo_dir" >&2; exit 1; } +[[ -n "$profile" ]] || { echo "--profile is required" >&2; exit 2; } +[[ -r "$identity_file" ]] || { echo "Recovery identity is not readable" >&2; exit 1; } +[[ "$(stat -c '%a' "$identity_file" 2>/dev/null || stat -f '%Lp' "$identity_file")" == 600 ]] || { echo "Recovery identity must have mode 0600" >&2; exit 1; } +command -v jq >/dev/null && command -v sops >/dev/null || { echo "jq and sops are required" >&2; exit 1; } + +jq -e --arg profile "$profile" '[.profiles[]?[]? | select(.name == $profile)] | length > 0' "$repo_dir/ai/source.json" >/dev/null || { + echo "Unknown registry profile: $profile" >&2; exit 1; +} + +local_file="${PI_PROFILES_HOME:-$HOME/.pi-profiles}/profiles/$profile/agent/auth.json" +credential_ref="ai/secrets/pi/$profile.auth.sops.yaml" +secret_file="$repo_dir/$credential_ref" + +validate_plaintext() { + jq -e ' + type == "object" and length > 0 and + all(to_entries[]; + (.key | type == "string" and length > 0) and + (.value | type == "object") and + ((.value.type == "api_key" and (.value.key | type == "string" and length > 0)) or + (.value.type == "oauth" and (.value.access | type == "string" and length > 0) and + (.value.refresh | type == "string" and length > 0) and (.value.expires | type == "number"))) + ) + ' "$1" >/dev/null +} +json_hash() { jq -S -c . "$1" | sha256sum | awk '{print $1}'; } +export SOPS_AGE_KEY_FILE="$identity_file" +mkdir -p "$(dirname "$secret_file")" + +if [[ "$action" == backup ]]; then + [[ -f "$local_file" ]] || { echo "No local Pi credential: $profile" >&2; exit 1; } + validate_plaintext "$local_file" || { echo "Unexpected Pi credential format: $profile" >&2; exit 1; } + [[ ! -e "$secret_file" || "$force" == true ]] || { echo "Pi escrow exists; use --force to rotate: $profile" >&2; exit 1; } + encrypted="$(mktemp "$(dirname "$secret_file")/.credential.XXXXXX.tmp")" + decrypted="$(mktemp)" + trap 'rm -f "${encrypted:-}" "${decrypted:-}"' EXIT + (cd "$repo_dir" && sops encrypt --filename-override "$credential_ref" --input-type json --output-type yaml "$local_file") > "$encrypted" + sops decrypt --input-type yaml --output-type json "$encrypted" > "$decrypted" + validate_plaintext "$decrypted" || { echo "Pi encrypted round trip failed: $profile" >&2; exit 1; } + [[ "$(json_hash "$local_file")" == "$(json_hash "$decrypted")" ]] || { echo "Pi encrypted round-trip hash mismatch: $profile" >&2; exit 1; } + chmod 600 "$encrypted"; mv -f "$encrypted" "$secret_file"; encrypted="" + echo "Backed up encrypted Pi credentials: $profile" + exit 0 +fi + +[[ -f "$secret_file" ]] || { echo "Pi escrow unavailable: $profile" >&2; exit 1; } +decrypted="$(mktemp)"; trap 'rm -f "${decrypted:-}"' EXIT +sops decrypt --input-type yaml --output-type json "$secret_file" > "$decrypted" +validate_plaintext "$decrypted" || { echo "Invalid decrypted Pi credential: $profile" >&2; exit 1; } +if [[ "$action" == check ]]; then + echo "Verified encrypted Pi credentials: $profile" +elif [[ -e "$local_file" && "$force" == false ]]; then + echo "Preserved existing Pi credentials: $profile" +else + mkdir -p "$(dirname "$local_file")"; chmod 700 "$(dirname "$local_file")" + target="$(mktemp "$(dirname "$local_file")/.auth.XXXXXX.tmp")" + chmod 600 "$target"; cp "$decrypted" "$target"; mv -f "$target" "$local_file" + echo "Restored Pi credentials: $profile" +fi diff --git a/scripts/pi-profile b/scripts/pi-profile new file mode 120000 index 0000000..2ec395c --- /dev/null +++ b/scripts/pi-profile @@ -0,0 +1 @@ +../base-image/files/pi-profile \ No newline at end of file diff --git a/scripts/provider-credential-escrow b/scripts/provider-credential-escrow index 769d556..fb6643f 100755 --- a/scripts/provider-credential-escrow +++ b/scripts/provider-credential-escrow @@ -50,6 +50,28 @@ case "$provider" in openai) profile_root="${CODEX_PROFILES_HOME:-$HOME/.chatgpt-profiles}/profiles"; credential_name="auth.json" ;; esac +provider_profile_dir() { + local name="$1" candidate selected="" selected_depth=-1 relative depth + if [[ "$provider" == claude || "$provider" == openai ]]; then + while IFS= read -r -d '' candidate; do + [[ -f "$candidate/.profile.json" ]] || continue + if jq -e --arg name "$name" '.name == $name' "$candidate/.profile.json" >/dev/null; then + relative="${candidate#"$profile_root/"}" + depth="$(awk -F/ '{print NF}' <<<"$relative")" + if (( depth > selected_depth )); then + selected="$candidate" + selected_depth="$depth" + fi + fi + done < <(find "$profile_root" -mindepth 1 -type d -name "$name" -print0) + if [[ -n "$selected" ]]; then + printf '%s\n' "$selected" + return + fi + fi + printf '%s\n' "$profile_root/$name" +} + validate_plaintext() { local value=$1 case "$provider" in @@ -80,7 +102,7 @@ for profile in "${profiles[@]}"; do echo "Error: invalid credentialRef for $provider/$profile" >&2; exit 1; } secret_file="$repo_dir/$credential_ref" - local_file="$profile_root/$profile/$credential_name" + local_file="$(provider_profile_dir "$profile")/$credential_name" mkdir -p "$(dirname "$secret_file")" if [[ "$action" == backup ]]; then diff --git a/scripts/setup-ai-profiles.sh b/scripts/setup-ai-profiles.sh index f92950e..de4ef73 100755 --- a/scripts/setup-ai-profiles.sh +++ b/scripts/setup-ai-profiles.sh @@ -39,6 +39,24 @@ for provider in gemini grok glm; do fi done +if ls "$config_dir"/{claude,openai,gemini,grok,glm}-profiles.json >/dev/null 2>&1; then + pi_profile_roots=( + --profile-root "claude=${CLAUDE_PROFILES_HOME:-$HOME/.claude-profiles}/profiles" + --profile-root "openai=${CODEX_PROFILES_HOME:-$HOME/.chatgpt-profiles}/profiles" + --profile-root "gemini=${GEMINI_PROFILES_HOME:-$HOME/.gemini-profiles}/profiles" + --profile-root "grok=${GROK_PROFILES_HOME:-$HOME/.grok-profiles}/profiles" + --profile-root "glm=${GLM_PROFILES_HOME:-$HOME/.glm-profiles}/profiles" + ) + python3 "$repo_dir/scripts/compose-pi-profiles.py" \ + --config-dir "$config_dir" \ + "${pi_profile_roots[@]}" \ + --output "$config_dir/pi-profiles.json" +fi +if [[ -f "$config_dir/pi-profiles.json" ]]; then + "$repo_dir/scripts/setup-pi-profiles.sh" --manifest "$config_dir/pi-profiles.json" + applied=true +fi + if [[ "$applied" == true ]]; then echo "AI profile setup complete. Provider credentials remain isolated and require their own login." else diff --git a/scripts/setup-claude-profiles.sh b/scripts/setup-claude-profiles.sh index fe0e0f1..7c6b2d4 100755 --- a/scripts/setup-claude-profiles.sh +++ b/scripts/setup-claude-profiles.sh @@ -60,10 +60,10 @@ if [[ "$interactive" == true ]]; then personal_email=$(prompt_email "Personal Claude login email") jq -n --arg email "$personal_email" '{ version: 1, - profiles: [{name: "personal", family: "personal", email: $email}] + profiles: [{name: "personal", profilePath: "personal/personal", family: "personal", email: $email}] }' > "$tmp" - read -r -p "Do you use this workstation for work for one or more companies? [y/N]: " uses_work /dev/null; then profile_name="$profile_name-$index" fi - jq --arg name "$profile_name" --arg family "$profile_name" \ + jq --arg name "$profile_name" --arg family "$company_slug" \ + --arg profile_path "$company_slug/xfactor/$profile_name" \ --arg email "$company_email" --arg workspace "$company_name" ' .profiles += [{ name: $name, + profilePath: $profile_path, family: $family, email: $email, workspace: $workspace @@ -104,12 +106,20 @@ fi jq -e ' .version == 1 and (.profiles | type == "array") + and ((.families // []) | type == "array") + and all(.families[]?; type == "string" and test("^[a-z0-9][a-z0-9-]*$")) and all(.profiles[]; (.name | length) > 0 - and (.family | length) > 0 + and (.family | test("^[a-z0-9][a-z0-9-]*$")) and (.email | length) > 0 and ((.aliases // []) | type == "array") and all(.aliases[]?; type == "string" and length > 0) + and ((.profilePath // .name) | + type == "string" + and length > 0 + and (startswith("/") | not) + and (split("/") | all(.[]; length > 0 and . != "." and . != "..")) + ) ) ' "$manifest" >/dev/null @@ -127,23 +137,38 @@ fi mkdir -p "$base/shared" "$base/state" "$base/profiles" for item in skills agents commands rules; do mkdir -p "$base/shared/$item"; done +while IFS= read -r family; do + mkdir -p "$base/state/$family" + if [[ "$family" == personal ]]; then + mkdir -p "$base/profiles/personal" + else + mkdir -p "$base/profiles/$family"/{team,max,xfactor} + fi +done < <(jq -r '[(.families[]?), .profiles[].family] | unique[]' "$manifest") install -m 0755 "$repo_dir/base-image/files/claude-statusline-command.sh" \ "$base/shared/statusline-command.sh" link_path() { local target="$1" link="$2" + local relative_target + relative_target="$(realpath -m --relative-to="$(dirname "$link")" "$target")" if [[ -L "$link" ]]; then - ln -sfn "$target" "$link" + ln -sfn "$relative_target" "$link" elif [[ -e "$link" ]]; then echo "Preserving existing path (migration required): $link" >&2 else - ln -s "$target" "$link" + ln -s "$relative_target" "$link" fi } -while IFS=$'\t' read -r name family; do - profile_dir="$base/profiles/$name" +while IFS=$'\t' read -r name family profile_path; do + profile_dir="$base/profiles/$profile_path" + legacy_profile_dir="$base/profiles/$name" state_dir="$base/state/$family" + if [[ "$profile_path" != "$name" && -d "$legacy_profile_dir" && ! -e "$profile_dir" ]]; then + mkdir -p "$(dirname "$profile_dir")" + cp -a "$legacy_profile_dir" "$profile_dir" + fi mkdir -p "$profile_dir" "$state_dir" metadata="$profile_dir/.claude.json" if [[ ! -e "$metadata" ]]; then @@ -154,17 +179,19 @@ while IFS=$'\t' read -r name family; do email="$(jq -r --arg name "$name" '.profiles[] | select(.name == $name) | .email' "$manifest")" aliases="$(jq -c --arg name "$name" '.profiles[] | select(.name == $name) | (.aliases // [])' "$manifest")" profile_info_tmp="$(mktemp "$profile_dir/.profile.XXXXXX.tmp")" - jq -n --arg name "$name" --arg family "$family" --arg email "$email" --argjson aliases "$aliases" \ - '{name: $name, family: $family, email: $email, aliases: $aliases}' > "$profile_info_tmp" + jq -n --arg name "$name" --arg family "$family" --arg email "$email" \ + --arg profile_path "$profile_path" --argjson aliases "$aliases" \ + '{name: $name, profilePath: $profile_path, family: $family, email: $email, aliases: $aliases}' \ + > "$profile_info_tmp" chmod 600 "$profile_info_tmp" mv -f "$profile_info_tmp" "$profile_info" for item in projects file-history plans tasks todos; do mkdir -p "$state_dir/$item"; done touch "$state_dir/history.jsonl" chmod 600 "$state_dir/history.jsonl" - for item in skills agents commands rules; do link_path "../../shared/$item" "$profile_dir/$item"; done - link_path "../../shared/statusline-command.sh" "$profile_dir/statusline-command.sh" - for item in projects file-history plans tasks todos; do link_path "../../state/$family/$item" "$profile_dir/$item"; done - link_path "../../state/$family/history.jsonl" "$profile_dir/history.jsonl" + for item in skills agents commands rules; do link_path "$base/shared/$item" "$profile_dir/$item"; done + link_path "$base/shared/statusline-command.sh" "$profile_dir/statusline-command.sh" + for item in projects file-history plans tasks todos; do link_path "$state_dir/$item" "$profile_dir/$item"; done + link_path "$state_dir/history.jsonl" "$profile_dir/history.jsonl" settings="$profile_dir/settings.json" settings_tmp="$(mktemp "$profile_dir/.settings.XXXXXX.tmp")" @@ -193,7 +220,7 @@ while IFS=$'\t' read -r name family; do fi chmod 600 "$settings_tmp" mv -f "$settings_tmp" "$settings" -done < <(jq -r '.profiles[] | [.name, .family] | @tsv' "$manifest") +done < <(jq -r '.profiles[] | [.name, .family, (.profilePath // .name)] | @tsv' "$manifest") # Profiles retained from older manifests remain launchable through their local # metadata. Keep their startup model aligned with the active manifest profiles. @@ -206,7 +233,7 @@ while IFS= read -r -d '' settings; do jq --arg model 'claude-fable-5' '.model = $model' "$settings" > "$settings_tmp" chmod 600 "$settings_tmp" mv -f "$settings_tmp" "$settings" -done < <(find "$base/profiles" -mindepth 2 -maxdepth 2 -type f -name settings.json -print0) +done < <(find "$base/profiles" -mindepth 2 -type f -name settings.json -print0) mkdir -p "$HOME/.local/bin" ln -sfn "$repo_dir/scripts/claude-profile" "$HOME/.local/bin/claude-profile" diff --git a/scripts/setup-codex-profiles.sh b/scripts/setup-codex-profiles.sh index ffad731..268bd64 100755 --- a/scripts/setup-codex-profiles.sh +++ b/scripts/setup-codex-profiles.sh @@ -12,8 +12,9 @@ usage() { cat <<'EOF' Usage: setup-codex-profiles.sh [--manifest PATH] -Creates isolated ChatGPT credential profiles for Codex CLI. The manifest -stores profile names and login emails, never OAuth credentials or API keys. +Creates isolated ChatGPT credential profiles for Codex CLI with conversation +history shared per family. The manifest stores profile names and login emails, +never OAuth credentials or API keys. EOF } @@ -38,12 +39,20 @@ fi jq -e ' .version == 1 and (.profiles | type == "array") + and ((.families // []) | type == "array") + and all(.families[]?; type == "string" and test("^[a-z0-9][a-z0-9-]*$")) and all(.profiles[]; (.name | length) > 0 - and (.family | length) > 0 + and (.family | test("^[a-z0-9][a-z0-9-]*$")) and (.email | length) > 0 and ((.aliases // []) | type == "array") and all(.aliases[]?; type == "string" and length > 0) + and ((.profilePath // .name) | + type == "string" + and length > 0 + and (startswith("/") | not) + and (split("/") | all(.[]; length > 0 and . != "." and . != "..")) + ) ) ' "$manifest" >/dev/null @@ -56,17 +65,84 @@ if [[ "$(realpath -m "$manifest")" != "$(realpath -m "$default_manifest")" ]]; t fi fi -mkdir -p "$base/profiles" -chmod 700 "$base" "$base/profiles" +mkdir -p "$base/profiles" "$base/state" +while IFS= read -r family; do + mkdir -p "$base/state/$family" + if [[ "$family" == personal ]]; then + mkdir -p "$base/profiles/personal" + else + mkdir -p "$base/profiles/$family"/{team,max,xfactor} + fi +done < <(jq -r '[(.families[]?), .profiles[].family] | unique[]' "$manifest") +chmod 700 "$base" "$base/profiles" "$base/state" link_path() { local target="$1" link="$2" + local relative_target + relative_target="$(realpath -m --relative-to="$(dirname "$link")" "$target")" if [[ -L "$link" ]]; then - ln -sfn "$target" "$link" + ln -sfn "$relative_target" "$link" elif [[ -e "$link" ]]; then echo "Preserving existing path: $link" >&2 else - ln -s "$target" "$link" + ln -s "$relative_target" "$link" + fi +} + +next_backup_path() { + local path="$1" candidate suffix=1 + candidate="${path}.pre-shared-state" + while [[ -e "$candidate" || -L "$candidate" ]]; do + candidate="${path}.pre-shared-state.$suffix" + suffix=$((suffix + 1)) + done + printf '%s\n' "$candidate" +} + +share_state_directory() { + local profile_path="$1" state_path="$2" target="$3" backup + target="$(realpath -m --relative-to="$(dirname "$profile_path")" "$target")" + mkdir -p "$state_path" + chmod 700 "$state_path" + if [[ -L "$profile_path" ]]; then + ln -sfn "$target" "$profile_path" + elif [[ -d "$profile_path" ]]; then + # Session rollout names contain UUIDs. Never overwrite an existing shared + # rollout during migration, and retain the original tree as a recovery copy. + cp -a -n "$profile_path/." "$state_path/" + backup="$(next_backup_path "$profile_path")" + mv "$profile_path" "$backup" + ln -s "$target" "$profile_path" + elif [[ -e "$profile_path" ]]; then + echo "Cannot share Codex state directory over non-directory: $profile_path" >&2 + return 1 + else + ln -s "$target" "$profile_path" + fi +} + +share_state_file() { + local profile_path="$1" state_path="$2" target="$3" backup + target="$(realpath -m --relative-to="$(dirname "$profile_path")" "$target")" + mkdir -p "$(dirname "$state_path")" + touch "$state_path" + chmod 600 "$state_path" + if [[ -L "$profile_path" ]]; then + ln -sfn "$target" "$profile_path" + elif [[ -f "$profile_path" ]]; then + # Prompt history and the portable session index are append-only JSONL. + # Preserve every existing line while keeping a recovery copy. + if [[ -s "$profile_path" ]]; then + cat "$profile_path" >> "$state_path" + fi + backup="$(next_backup_path "$profile_path")" + mv "$profile_path" "$backup" + ln -s "$target" "$profile_path" + elif [[ -e "$profile_path" ]]; then + echo "Cannot share Codex state file over unsupported path: $profile_path" >&2 + return 1 + else + ln -s "$target" "$profile_path" fi } @@ -178,17 +254,27 @@ PY mv -f "$tmp" "$config" } -while IFS=$'\t' read -r name family; do - profile_dir="$base/profiles/$name" +while IFS=$'\t' read -r name family profile_path; do + profile_dir="$base/profiles/$profile_path" + legacy_profile_dir="$base/profiles/$name" + state_dir="$base/state/$family" + if [[ "$profile_path" != "$name" && -d "$legacy_profile_dir" && ! -e "$profile_dir" ]]; then + mkdir -p "$(dirname "$profile_dir")" + cp -a "$legacy_profile_dir" "$profile_dir" + fi mkdir -p "$profile_dir" chmod 700 "$profile_dir" + mkdir -p "$state_dir" + chmod 700 "$state_dir" email="$(jq -r --arg name "$name" '.profiles[] | select(.name == $name) | .email' "$manifest")" aliases="$(jq -c --arg name "$name" '.profiles[] | select(.name == $name) | (.aliases // [])' "$manifest")" profile_info="$profile_dir/.profile.json" profile_info_tmp="$(mktemp "$profile_dir/.profile.XXXXXX.tmp")" - jq -n --arg name "$name" --arg family "$family" --arg email "$email" --argjson aliases "$aliases" \ - '{name: $name, family: $family, email: $email, aliases: $aliases}' > "$profile_info_tmp" + jq -n --arg name "$name" --arg family "$family" --arg email "$email" \ + --arg profile_path "$profile_path" --argjson aliases "$aliases" \ + '{name: $name, profilePath: $profile_path, family: $family, email: $email, aliases: $aliases}' \ + > "$profile_info_tmp" chmod 600 "$profile_info_tmp" mv -f "$profile_info_tmp" "$profile_info" @@ -204,13 +290,22 @@ while IFS=$'\t' read -r name family; do configure_auth_storage "$settings" configure_tui_status_line "$settings" + for item in sessions archived_sessions; do + share_state_directory \ + "$profile_dir/$item" "$state_dir/$item" "$state_dir/$item" + done + for item in history.jsonl session_index.jsonl; do + share_state_file \ + "$profile_dir/$item" "$state_dir/$item" "$state_dir/$item" + done + for item in skills prompts policy; do - [[ -e "$HOME/.codex/$item" ]] && link_path "../../../.codex/$item" "$profile_dir/$item" + [[ -e "$HOME/.codex/$item" ]] && link_path "$HOME/.codex/$item" "$profile_dir/$item" done for item in AGENTS.md tmux.conf; do - [[ -e "$HOME/.codex/$item" ]] && link_path "../../../.codex/$item" "$profile_dir/$item" + [[ -e "$HOME/.codex/$item" ]] && link_path "$HOME/.codex/$item" "$profile_dir/$item" done -done < <(jq -r '.profiles[] | [.name, .family] | @tsv' "$manifest") +done < <(jq -r '.profiles[] | [.name, .family, (.profilePath // .name)] | @tsv' "$manifest") mkdir -p "$HOME/.local/bin" ln -sfn "$repo_dir/scripts/codex-profile" "$HOME/.local/bin/codex-profile" diff --git a/scripts/setup-multi-cli-codex-profiles.ps1 b/scripts/setup-multi-cli-codex-profiles.ps1 new file mode 100644 index 0000000..05360a5 --- /dev/null +++ b/scripts/setup-multi-cli-codex-profiles.ps1 @@ -0,0 +1,103 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$Manifest, + + # Families to sync from the shared Desktop store. Defaults to every + # non-personal family in the manifest (all work/company profiles); + # personal profiles always remain isolated. Pass one or more family + # names to restrict syncing to specific companies. + [string[]]$Family = @(), + + [string]$MultiCliHome = (Join-Path $env:USERPROFILE "MultiCliProfiles"), + + [string]$SharedCodexHome = (Join-Path $env:USERPROFILE ".codex") +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +if (-not (Test-Path -LiteralPath $Manifest -PathType Leaf)) { + throw "OpenAI profile manifest not found: $Manifest" +} +if (-not (Test-Path -LiteralPath $SharedCodexHome -PathType Container)) { + throw "Desktop Codex home not found: $SharedCodexHome" +} + +$profiles = (Get-Content -LiteralPath $Manifest -Raw | ConvertFrom-Json).profiles +$codexProfiles = Join-Path $MultiCliHome "codex" +New-Item -ItemType Directory -Path $codexProfiles -Force | Out-Null + +function Test-ReparsePoint { + param([string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { return $false } + return [bool]((Get-Item -LiteralPath $Path -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) +} + +function Set-SharedDirectoryLink { + param([string]$ProfileDir, [string]$Name) + $source = Join-Path $SharedCodexHome $Name + $link = Join-Path $ProfileDir $Name + New-Item -ItemType Directory -Path $source -Force | Out-Null + + if (Test-ReparsePoint $link) { + $actual = (Get-Item -LiteralPath $link -Force).Target + if ($actual -eq $source) { return } + throw "Existing link targets another location: $link -> $actual" + } + if (Test-Path -LiteralPath $link) { + throw "Existing profile history must be migrated before linking: $link" + } + New-Item -ItemType Junction -Path $link -Target $source | Out-Null +} + +function Set-SharedFileLink { + param([string]$ProfileDir, [string]$Name) + $source = Join-Path $SharedCodexHome $Name + $link = Join-Path $ProfileDir $Name + if (-not (Test-Path -LiteralPath $source)) { + New-Item -ItemType File -Path $source -Force | Out-Null + } + + if (Test-Path -LiteralPath $link) { + $item = Get-Item -LiteralPath $link -Force + if ($item.LinkType -eq "HardLink" -and @($item.Target) -contains $source) { return } + throw "Existing profile history must be migrated before linking: $link" + } + # NTFS hard links require no elevation and keep both paths on the same + # underlying append-only JSONL file. + New-Item -ItemType HardLink -Path $link -Target $source | Out-Null +} + +foreach ($profile in $profiles) { + # Company/work identities share Desktop conversation history within their + # family. Every personal profile has a unique family and remains fully + # isolated. + if ($Family.Count -gt 0) { + if ($Family -notcontains $profile.family) { continue } + } elseif ($profile.family -eq "personal") { + continue + } + + $profileDir = Join-Path $codexProfiles $profile.name + New-Item -ItemType Directory -Path $profileDir -Force | Out-Null + foreach ($name in @("sessions", "archived_sessions")) { + Set-SharedDirectoryLink -ProfileDir $profileDir -Name $name + } + foreach ($name in @("history.jsonl", "session_index.jsonl")) { + Set-SharedFileLink -ProfileDir $profileDir -Name $name + } + + $metadata = [ordered]@{ + name = $profile.name + email = $profile.email + family = $profile.family + aliases = @($profile.aliases) + managedBy = "workBenches" + } | ConvertTo-Json -Depth 4 + Set-Content -LiteralPath (Join-Path $profileDir ".profile.json") -Value $metadata -Encoding utf8 +} + +Write-Host "Multi-CLI Codex profiles synchronized under $codexProfiles" +Write-Host "Synced company profiles share history from $SharedCodexHome" +Write-Host "Credential files were not copied or linked." diff --git a/scripts/setup-pi-profiles.sh b/scripts/setup-pi-profiles.sh new file mode 100755 index 0000000..abffa3b --- /dev/null +++ b/scripts/setup-pi-profiles.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/workbenches" +manifest="${PI_PROFILES_MANIFEST:-$config_dir/pi-profiles.json}" +base="${PI_PROFILES_HOME:-$HOME/.pi-profiles}" + +usage() { echo "Usage: setup-pi-profiles.sh [--manifest PATH]"; } +while [[ $# -gt 0 ]]; do + case "$1" in + --manifest) manifest="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; + esac +done + +command -v jq >/dev/null 2>&1 || { echo "jq is required." >&2; exit 1; } +npm_command="$(command -v npm || true)" +[[ -x /usr/bin/npm ]] && npm_command=/usr/bin/npm +jq -e ' + .version == 1 + and (.profiles | type == "array") + and ((.families // []) | type == "array") + and all(.families[]?; type == "string" and test("^[a-z0-9][a-z0-9-]*$")) + and all(.profiles[]; + (.name|length)>0 and (.email|length)>0 + and (.family | test("^[a-z0-9][a-z0-9-]*$")) + and ((.providers//[])|type=="array") + and ((.profilePath // .name) | + type == "string" and length > 0 and (startswith("/") | not) + and (split("/") | all(.[]; length > 0 and . != "." and . != "..")) + ) + ) +' "$manifest" >/dev/null + +mkdir -p "$base/profiles" "$base/state" \ + "$base/shared/skills" "$base/shared/prompts" "$base/shared/extensions" "$base/shared/themes" +while IFS= read -r family; do + mkdir -p "$base/state/$family/sessions" + if [[ "$family" == personal ]]; then + mkdir -p "$base/profiles/personal" + else + mkdir -p "$base/profiles/$family"/{team,max,xfactor} + fi +done < <(jq -r '[(.families[]?), .profiles[].family] | unique[]' "$manifest") +chmod 700 "$base" "$base/profiles" "$base/shared" + +link_path() { + local target="$1" link="$2" + local relative_target + relative_target="$(realpath -m --relative-to="$(dirname "$link")" "$target")" + if [[ -L "$link" || ! -e "$link" ]]; then ln -sfn "$relative_target" "$link"; fi +} + +while IFS=$'\t' read -r name email family profile_path aliases providers; do + profile_dir="$base/profiles/$profile_path" + legacy_profile_dir="$base/profiles/$name" + if [[ "$profile_path" != "$name" && -d "$legacy_profile_dir" && ! -e "$profile_dir" ]]; then + mkdir -p "$(dirname "$profile_dir")" + cp -a "$legacy_profile_dir" "$profile_dir" + fi + agent_dir="$profile_dir/agent" + mkdir -p "$agent_dir" + chmod 700 "$profile_dir" "$agent_dir" + tmp="$(mktemp "$profile_dir/.profile.XXXXXX.tmp")" + jq -n --arg name "$name" --arg email "$email" --arg family "$family" \ + --arg profile_path "$profile_path" --argjson aliases "$aliases" --argjson providers "$providers" \ + '{name:$name,profilePath:$profile_path,email:$email,family:$family,aliases:$aliases,providers:$providers}' > "$tmp" + chmod 600 "$tmp" + mv -f "$tmp" "$profile_dir/.profile.json" + if [[ ! -e "$agent_dir/settings.json" ]]; then + printf '%s\n' '{"enableInstallTelemetry":false}' > "$agent_dir/settings.json" + chmod 600 "$agent_dir/settings.json" + fi + if [[ -n "$npm_command" ]]; then + settings_tmp="$(mktemp "$agent_dir/.settings.XXXXXX.tmp")" + jq --arg npm "$npm_command" '.npmCommand = [$npm]' "$agent_dir/settings.json" > "$settings_tmp" + chmod 600 "$settings_tmp" + mv -f "$settings_tmp" "$agent_dir/settings.json" + fi + if jq -e 'index("claude") != null' <<<"$providers" >/dev/null; then + settings_tmp="$(mktemp "$agent_dir/.settings.XXXXXX.tmp")" + jq ' + .packages = (((.packages // []) | map(select(. != "npm:pi-claude-cli" and . != "npm:@ramarivera/pi-claude-cli" and . != "npm:@ramarivera/pi-claude-cli@0.3.1"))) + ["npm:@ramarivera/pi-claude-cli@0.3.1"]) + | .defaultProvider = "pi-claude-cli" + | .defaultModel = "claude-fable-5" + ' "$agent_dir/settings.json" > "$settings_tmp" + chmod 600 "$settings_tmp" + mv -f "$settings_tmp" "$agent_dir/settings.json" + fi + for item in skills prompts extensions themes; do link_path "$base/shared/$item" "$agent_dir/$item"; done + [[ -e "$base/shared/AGENTS.md" ]] && link_path "$base/shared/AGENTS.md" "$agent_dir/AGENTS.md" + state_dir="$base/state/$family/sessions" + if [[ -d "$agent_dir/sessions" && ! -L "$agent_dir/sessions" ]]; then + cp -a --update=none "$agent_dir/sessions/." "$state_dir/" + mv "$agent_dir/sessions" "$agent_dir/sessions.pre-shared-state" + fi + link_path "$state_dir" "$agent_dir/sessions" +done < <(jq -r '.profiles[] | [.name,.email,.family,(.profilePath // .name),((.aliases//[])|tojson),((.providers//[])|tojson)] | @tsv' "$manifest") + +mkdir -p "$HOME/.local/bin" +if ! command -v pi >/dev/null 2>&1 && [[ -x "$HOME/.npm-global/bin/pi" ]]; then + ln -sfn "$HOME/.npm-global/bin/pi" "$HOME/.local/bin/pi" +fi +ln -sfn "$repo_dir/scripts/pi-profile" "$HOME/.local/bin/pi-profile" +ln -sfn "$repo_dir/scripts/pi-profile" "$HOME/.local/bin/ppi" +echo "Pi profiles configured under $base" +echo "Run: ppi list" +echo "Then: ppi login PROFILE" diff --git a/scripts/setup-provider-profiles.sh b/scripts/setup-provider-profiles.sh index 6f2cf27..bd578dd 100755 --- a/scripts/setup-provider-profiles.sh +++ b/scripts/setup-provider-profiles.sh @@ -48,30 +48,53 @@ command -v jq >/dev/null 2>&1 || { echo "jq is required." >&2; exit 1; } jq -e ' .version == 1 and (.profiles | type == "array") + and ((.families // []) | type == "array") + and all(.families[]?; type == "string" and test("^[a-z0-9][a-z0-9-]*$")) and all(.profiles[]; (.name | type == "string" and length > 0) and (.email | type == "string" and length > 0) - and (.family | type == "string" and length > 0) + and (.family | type == "string" and test("^[a-z0-9][a-z0-9-]*$")) and ((.aliases // []) | type == "array") + and ((.profilePath // .name) | + type == "string" + and length > 0 + and (startswith("/") | not) + and (split("/") | all(.[]; length > 0 and . != "." and . != "..")) + ) ) ' "$manifest" >/dev/null -mkdir -p "$base/profiles" "$HOME/.local/bin" +mkdir -p "$base/profiles" "$base/state" "$HOME/.local/bin" +while IFS= read -r family; do + mkdir -p "$base/state/$family" + if [[ "$family" == personal ]]; then + mkdir -p "$base/profiles/personal" + else + mkdir -p "$base/profiles/$family"/{team,max,xfactor} + fi +done < <(jq -r '[(.families[]?), .profiles[].family] | unique[]' "$manifest") chmod 700 "$base" "$base/profiles" -while IFS=$'\t' read -r name email family aliases; do - profile_dir="$base/profiles/$name" +while IFS=$'\t' read -r name email family profile_path aliases; do + profile_dir="$base/profiles/$profile_path" + legacy_profile_dir="$base/profiles/$name" + if [[ "$profile_path" != "$name" && -d "$legacy_profile_dir" && ! -e "$profile_dir" ]]; then + mkdir -p "$(dirname "$profile_dir")" + cp -a "$legacy_profile_dir" "$profile_dir" + fi mkdir -p "$profile_dir" chmod 700 "$profile_dir" metadata_tmp="$(mktemp "$profile_dir/.profile.XXXXXX.tmp")" - jq -n --arg name "$name" --arg email "$email" --arg family "$family" --argjson aliases "$aliases" \ - '{name: $name, email: $email, family: $family, aliases: $aliases}' > "$metadata_tmp" + jq -n --arg name "$name" --arg email "$email" --arg family "$family" \ + --arg profile_path "$profile_path" --argjson aliases "$aliases" \ + '{name: $name, profilePath: $profile_path, email: $email, family: $family, aliases: $aliases}' \ + > "$metadata_tmp" chmod 600 "$metadata_tmp" mv -f "$metadata_tmp" "$profile_dir/.profile.json" case "$provider" in gemini) mkdir -p "$profile_dir/.gemini" ;; glm) mkdir -p "$profile_dir/xdg"/{config,data,cache,state} ;; esac -done < <(jq -r '.profiles[] | [.name,.email,.family,((.aliases // []) | tojson)] | @tsv' "$manifest") +done < <(jq -r '.profiles[] | [.name,.email,.family,(.profilePath // .name),((.aliases // []) | tojson)] | @tsv' "$manifest") for launcher in "${launchers[@]}"; do ln -sfn "$repo_dir/base-image/files/provider-profile" "$HOME/.local/bin/$launcher" diff --git a/scripts/setup-ui/src/utils/installers.ts b/scripts/setup-ui/src/utils/installers.ts index 88da381..f9b48e1 100644 --- a/scripts/setup-ui/src/utils/installers.ts +++ b/scripts/setup-ui/src/utils/installers.ts @@ -377,7 +377,7 @@ export async function processSelections( console.log(' - OpenCode CLI: Run `opencode auth login`'); break; case 'pi_cli': - console.log(' - Pi Coding Agent: Run `pi`, then enter `/login`'); + console.log(' - Pi Coding Agent: Run `ppi login PROFILE`, then enter `/login`'); break; case 'warp': console.log(' - Warp Terminal: Sign in at https://app.warp.dev/login'); diff --git a/scripts/setup-windows-tools.sh b/scripts/setup-windows-tools.sh index 4ce909d..312eb25 100755 --- a/scripts/setup-windows-tools.sh +++ b/scripts/setup-windows-tools.sh @@ -138,29 +138,36 @@ install_wave() { install_pi_terminal() { echo "Checking Pi Terminal..." + local windows_pi=false if windows_command_exists "pi" || windows_env_file_exists "APPDATA" "npm\\pi.cmd"; then echo " ✓ Pi Terminal is installed for Windows" - return 0 + windows_pi=true fi - if windows_command_exists "npm"; then + if [ "$windows_pi" = false ] && windows_command_exists "npm"; then echo " Installing Pi Terminal with Windows npm..." if run_powershell "npm install -g --ignore-scripts $(ps_quote "$PI_NPM_PACKAGE")"; then echo " ✓ Pi Terminal installed for Windows" - echo " Run 'pi' from a project, then use /login to configure a provider." - return 0 + windows_pi=true fi fi - if command -v npm >/dev/null 2>&1; then - echo " Windows npm was not available; installing Pi Terminal in WSL..." - if npm install -g --ignore-scripts "$PI_NPM_PACKAGE"; then - echo " ✓ Pi Terminal installed in WSL" - echo " Run 'pi' from a project, then use /login to configure a provider." + if command -v pi >/dev/null 2>&1; then + echo " ✓ Pi Terminal is installed for WSL/Linux" + return 0 + elif command -v npm >/dev/null 2>&1; then + echo " Installing Pi Terminal in WSL/Linux for ppi and devBench use..." + wsl_npm="$(command -v npm)" + [[ -x /usr/bin/npm ]] && wsl_npm=/usr/bin/npm + if "$wsl_npm" install -g --ignore-scripts "$PI_NPM_PACKAGE"; then + echo " ✓ Pi Terminal installed for WSL/Linux" + echo " Run 'ppi login PROFILE' to configure an isolated provider login." return 0 fi fi + [ "$windows_pi" = true ] && return 0 + echo " ✗ Node.js/npm not found in Windows or WSL" echo " Install Node.js 22+ on Windows, then rerun this setup." return 1 diff --git a/scripts/wave-container-shell.sh b/scripts/wave-container-shell.sh index fab9605..fbf242c 100755 --- a/scripts/wave-container-shell.sh +++ b/scripts/wave-container-shell.sh @@ -73,6 +73,8 @@ done workbenches_root="${workbenches_root%/}" resolve_bench_defaults +container_history_dir="/home/${container_user}/.workbenches-history" +container_history_file="${container_history_dir}/.zsh_history" if [[ ! -d "$workbenches_root" ]]; then echo "workBenches root does not exist: $workbenches_root" >&2 @@ -126,6 +128,7 @@ ensure_host_sources() { "$home_dir/.omnigent" \ "$home_dir/.agents" \ "$home_dir/.pi" \ + "$home_dir/.pi-profiles" \ "$home_dir/.config/sonarqube" \ "$home_dir/.gemini" \ "$home_dir/.grok" \ @@ -152,7 +155,7 @@ services: $container: volumes: - ${home_dir}/projects:/workspace/projects:cached - - ${history_volume}:/home/${container_user}/.zsh_history + - ${history_volume}:${container_history_dir} - ${home_dir}/.zshrc:/home/${container_user}/.zshrc:ro - ${home_dir}/.oh-my-zsh:/home/${container_user}/.oh-my-zsh:ro - ${home_dir}/.p10k.zsh:/home/${container_user}/.p10k.zsh:ro @@ -174,6 +177,7 @@ services: - ${home_dir}/.omnigent:/home/${container_user}/.omnigent:cached - ${home_dir}/.agents:/home/${container_user}/.agents:cached - ${home_dir}/.pi:/home/${container_user}/.pi:cached + - ${home_dir}/.pi-profiles:/home/${container_user}/.pi-profiles:cached - ${home_dir}/.config/sonarqube:/home/${container_user}/.config/sonarqube:ro - ${home_dir}/.gemini:/home/${container_user}/.gemini:cached - ${home_dir}/.grok:/home/${container_user}/.grok:ro @@ -232,11 +236,13 @@ container_missing_required_mounts() { local required_mounts=() required_mounts=( "/workspace/projects" + "$container_history_dir" "/home/${container_user}/.zshrc" "/home/${container_user}/.oh-my-zsh" "/home/${container_user}/.p10k.zsh" "/home/${container_user}/.claude-profiles" "/home/${container_user}/.chatgpt-profiles" + "/home/${container_user}/.pi-profiles" "/home/${container_user}/.gemini-profiles" "/home/${container_user}/.grok-profiles" "/home/${container_user}/.glm-profiles" @@ -281,10 +287,16 @@ if [[ "$(docker container inspect -f '{{.State.Running}}' "$container")" != "tru docker start "$container" >/dev/null fi +ensure_container_history() { + docker exec --user root "$container" sh -c \ + "mkdir -p '$container_history_dir' && touch '$container_history_file' && chown -R '${container_user}:${container_user}' '$container_history_dir'" +} + install_ai_profile_launchers() { local claude_launcher="$workbenches_root/base-image/files/claude-profile" local codex_launcher="$workbenches_root/base-image/files/codex-profile" local provider_launcher="$workbenches_root/base-image/files/provider-profile" + local pi_launcher="$workbenches_root/base-image/files/pi-profile" [[ -f "$claude_launcher" ]] || return 0 docker cp "$claude_launcher" "$container:/usr/local/bin/claude-profile" @@ -303,17 +315,23 @@ install_ai_profile_launchers() { ln -sfn provider-profile "/usr/local/bin/$name" done' fi + if [[ -f "$pi_launcher" ]]; then + docker cp "$pi_launcher" "$container:/usr/local/bin/pi-profile" + docker exec --user root "$container" sh -c \ + 'chmod 0755 /usr/local/bin/pi-profile && ln -sfn pi-profile /usr/local/bin/ppi' + fi docker exec --user root "$container" sh -c \ "mkdir -p '/home/${container_user}/.local/bin' && chown '${container_user}:${container_user}' '/home/${container_user}/.local' '/home/${container_user}/.local/bin'" docker exec --user "$container_user" "$container" sh -c \ 'if [ ! -e "$HOME/.local/bin/claude" ]; then ln -s /usr/local/bin/claude "$HOME/.local/bin/claude"; fi' } +ensure_container_history install_ai_profile_launchers if [[ "$check_only" == true ]]; then - docker exec --user "$container_user" --workdir "$workdir" "$container" "$shell_path" -lc \ - 'printf "%s\n" "wave-container-shell-ok"; whoami; pwd; command -v claude-profile; command -v pclaude; command -v codex-profile; command -v pcodex; command -v pgemini; command -v pgrok; command -v pglm; test -d "$HOME/.claude-profiles"; test -d "$HOME/.chatgpt-profiles"; test -d "$HOME/.gemini-profiles"; test -d "$HOME/.grok-profiles"; test -d "$HOME/.glm-profiles"' + docker exec --user "$container_user" --env "HISTFILE=$container_history_file" --workdir "$workdir" "$container" "$shell_path" -lc \ + 'printf "%s\n" "wave-container-shell-ok"; whoami; pwd; test "$HISTFILE" = "$HOME/.workbenches-history/.zsh_history"; command -v claude-profile; command -v pclaude; command -v codex-profile; command -v pcodex; command -v ppi; command -v pgemini; command -v pgrok; command -v pglm; test -d "$HOME/.claude-profiles"; test -d "$HOME/.chatgpt-profiles"; test -d "$HOME/.pi-profiles"; test -d "$HOME/.gemini-profiles"; test -d "$HOME/.grok-profiles"; test -d "$HOME/.glm-profiles"' exit 0 fi @@ -349,6 +367,7 @@ exec docker exec "${tty_args[@]}" \ --env "COLORTERM=$color_term" \ --env "CLICOLOR=1" \ --env "FORCE_COLOR=1" \ + --env "HISTFILE=$container_history_file" \ --user "$container_user" \ --workdir "$workdir" \ "$container" \