diff --git a/README.md b/README.md index c4b2aff..6442db3 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,7 @@ Run `curl -sfL install.lowkey.run | bash` — the installer walks you through ** | **NemoClaw** *(experimental)* | OpenClaw in NVIDIA OpenShell sandbox — Landlock + seccomp + netns isolation, Bedrock via bedrockify. `personal_assistant` profile only. | t4g.xlarge required | 80GB | | **Kiro CLI** *(experimental)* | AWS agentic IDE terminal client with MCP server support. Uses own cloud inference (not Bedrock). Requires interactive SSO login after deploy. | t4g.medium sufficient | None needed (set to 0) | | **Codex CLI** *(experimental)* | OpenAI's Codex coding agent — uses OpenAI API directly (no Bedrock). Configured as builder agent (danger-full-access). **After install completes**, SSM into the instance and run `codex login`. | t4g.medium sufficient | None needed (set to 0) | +| **Troika** *(experimental)* | Three-harness coding machine — OpenClaw/Hermes + Claude Code + Codex CLI, all via Amazon Bedrock. Pick a daily driver to auto-launch on SSM login; switch with `agents driver `. Builder-only. Requires Anthropic + Bedrock Mantle model access. | t4g.xlarge required | 80GB | The installer discovers packs dynamically and asks which to deploy. Experimental packs are clearly marked. @@ -203,6 +204,7 @@ Lowkey uses a **pack-based architecture** for deploying different AI agent runti | `nemoclaw` | Agent *(experimental)* | NemoClaw — OpenClaw inside an [NVIDIA OpenShell](https://github.com/NVIDIA/OpenShell) sandbox with Landlock, seccomp, and network namespace isolation. Inference routed through bedrockify on the host (no NVIDIA API key needed). **Only compatible with `personal_assistant` profile** — the sandbox blocks all AWS API access. Requires Docker + t4g.xlarge. | | `kiro-cli` | Agent *(experimental)* | [Kiro CLI](https://kiro.dev/docs/cli) — AWS agentic IDE terminal client with MCP server support. Uses its own cloud inference (no Bedrock/bedrockify). Pre-installs AWS MCP servers (terraform, ecs, eks, core, docs). **Requires interactive SSO login after deploy:** `kiro-cli login --use-device-flow`. | | `codex-cli` | Agent *(experimental)* | [Codex CLI](https://github.com/openai/codex) — OpenAI's coding agent. Connects directly to OpenAI's API (no Bedrock/bedrockify). Configured as builder agent (danger-full-access, never prompts). **Post-install step**: SSM into the instance and run `codex login` (interactive) — auth is intentionally deferred until after deploy. | +| `troika` | Agent *(experimental)* | Three-harness coding machine: OpenClaw/Hermes + Claude Code + Codex CLI on one instance, all via Bedrock. Pick a **daily driver** that auto-launches on SSM login; switch anytime with `agents driver `. Builder-only. Requires Anthropic model access + Bedrock Mantle access (`openai.gpt-5.5`). | | `roundhouse` | Agent | [Roundhouse](https://github.com/inceptionstack/roundhouse) — Pi-based AI coding agent with Telegram channel. Uses native Bedrock SDK (no bedrockify). Requires `TelegramBotTokenSecret` and `TelegramUser` parameters. | ### How It Works diff --git a/TRIFECTA-PLAN.md b/TROIKA-PLAN.md similarity index 55% rename from TRIFECTA-PLAN.md rename to TROIKA-PLAN.md index facfa2a..bd5f73f 100644 --- a/TRIFECTA-PLAN.md +++ b/TROIKA-PLAN.md @@ -1,6 +1,6 @@ -# Trifecta Pack — Technical Plan +# Troika Pack — Technical Plan -New installer menu option **`trifecta`**: installs **OpenClaw + Claude Code + Codex CLI** +New installer menu option **`troika`**: installs **OpenClaw + Claude Code + Codex CLI** on ONE EC2 instance, all inference via **Amazon Bedrock** (no OpenAI/Anthropic API keys). At install time the user picks a **daily driver** — the agent whose TUI auto-launches when they SSM into the box. Goal: a multi-harness coding + cross-review experience @@ -10,9 +10,9 @@ when they SSM into the box. Goal: a multi-harness coding + cross-review experien New entry: - trifecta: + troika: type: agent - description: "Trifecta — OpenClaw + Claude Code + Codex CLI on one instance, all via Bedrock" + description: "Troika — OpenClaw + Claude Code + Codex CLI on one instance, all via Bedrock" deps: - bedrockify # explicit: bootstrap dep resolution is SINGLE-LEVEL (verify — see risks) - openclaw @@ -28,21 +28,21 @@ New entry: experimental: true # new; promote later requires_openai_key: false # codex runs via Bedrock — NO OpenAI key needed compatible_profiles: - - builder # Trifecta is ALWAYS builder profile + - builder # Troika is ALWAYS builder profile Ordering note: deps run in listed order (bedrockify -> openclaw -> claude-code -> -codex-cli), then trifecta's own install.sh runs LAST (it wires cross-agent config). +codex-cli), then troika's own install.sh runs LAST (it wires cross-agent config). -## 2. New pack: packs/trifecta/ +## 2. New pack: packs/troika/ - packs/trifecta/ + packs/troika/ manifest.yaml install.sh resources/shell-profile.sh test.sh ### manifest.yaml -- name: trifecta, type: agent, deps as in registry +- name: troika, type: agent, deps as in registry - params: - `daily-driver` (openclaw | claude-code | codex-cli; default openclaw) - `model` (Bedrock id for Claude-family agents; default us.anthropic.claude-sonnet-4-6) @@ -61,7 +61,7 @@ codex-cli), then trifecta's own install.sh runs LAST (it wires cross-agent confi `AWS_REGION` exported in .bashrc (bootstrap already exports AWS_DEFAULT_REGION; Codex reads AWS_REGION — export BOTH). 3. **Claude Code -> Bedrock**: claude-code pack already sets CLAUDE_CODE_USE_BEDROCK=1 + - ANTHROPIC_MODEL. Trifecta only overrides model if user changed `model` param. + ANTHROPIC_MODEL. Troika only overrides model if user changed `model` param. 4. Install `agents` helper (small bash script to /usr/local/bin/agents): - `agents` -> status: which of the 3 are installed + current daily driver - `agents driver ` -> switch daily driver (rewrites the config file) @@ -70,7 +70,7 @@ codex-cli), then trifecta's own install.sh runs LAST (it wires cross-agent confi 5. Write SSM parameter `/loki/daily-driver` (String) for observability. ### resources/shell-profile.sh -- PACK_BANNER_NAME="Trifecta", emoji, PACK_BANNER_COMMANDS lists: openclaw tui / claude / +- PACK_BANNER_NAME="Troika", emoji, PACK_BANNER_COMMANDS lists: openclaw tui / claude / codex / agents driver - PACK_ALIASES: aliases for all three + `agents` @@ -79,9 +79,9 @@ codex-cli), then trifecta's own install.sh runs LAST (it wires cross-agent confi Current flow: `/etc/profile.d/loki.sh` auto-execs `sudo -iu ec2-user` for ssm-user; ec2-user .bashrc prints the pack banner. -Trifecta appends an auto-launch block to ec2-user `.bashrc` (AFTER the banner block): +Troika appends an auto-launch block to ec2-user `.bashrc` (AFTER the banner block): - # Trifecta daily-driver auto-launch + # Troika daily-driver auto-launch if [[ $- == *i* ]] && [[ -t 0 ]] && [[ -z "$LOKI_TUI_LAUNCHED" ]] \ && [[ -z "$LOKI_NO_TUI" ]] && [[ -f ~/.config/lowkey/daily-driver ]]; then export LOKI_TUI_LAUNCHED=1 @@ -107,53 +107,53 @@ Safety properties (MUST hold): 1. Pack menu: registry-driven, so the new registry entry appears automatically — VERIFY menu is built from registry (it is: jq over registry.json, L~1759). -2. `pack_default_model()`: add `trifecta) echo "us.anthropic.claude-sonnet-4-6"`. -3. New config question (only when PACK_NAME=trifecta), gum choose: +2. `pack_default_model()`: add `troika) echo "us.anthropic.claude-sonnet-4-6"`. +3. New config question (only when PACK_NAME=troika), gum choose: "Which agent is your daily driver? (auto-launches when you SSM in)" -> openclaw (default) | claude-code | codex-cli. Preselect flag: `--daily-driver `. -3b. **Profile lock**: trifecta is builder-only. Installer must skip the profile +3b. **Profile lock**: troika is builder-only. Installer must skip the profile question (auto-set builder) or fail fast on `--profile != builder`, mirroring existing `compatible_profiles` handling for roundhouse. 4. Param plumbing: append `DailyDriver` to PARAM_CFN_NAMES + PARAM_VALUES - (empty/openclaw for non-trifecta packs). NOTE: arrays must stay in sync + (empty/openclaw for non-troika packs). NOTE: arrays must stay in sync (validated at runtime; CFN-only — installer assumes CloudFormation is the sole deploy method). -5. `requires_openai_key` logic: trifecta must NOT trigger the OpenAI key prompt that +5. `requires_openai_key` logic: troika must NOT trigger the OpenAI key prompt that codex-cli standalone uses (registry flag false handles it — verify installer checks the flag per-pack, not per-installed-component). -6. Review summary: show `Daily driver ` line when pack=trifecta. +6. Review summary: show `Daily driver ` line when pack=troika. ## 5. CloudFormation template (deploy/cloudformation/template.yaml) -- `PackName` AllowedValues += `trifecta`. +- `PackName` AllowedValues += `troika`. - New param `DailyDriver` (String, default `openclaw`, AllowedValues [openclaw, claude-code, codex-cli]; harmless/ignored for other packs). - UserData: pass `--daily-driver ${DailyDriver}` to bootstrap.sh pack args (bootstrap forwards unknown `--key value` pairs to pack install.sh via pack_config — verify the generic passthrough, L~127). - Instance sizing: template must honor registry instance_type/data_volume for - trifecta (t4g.xlarge / 80GB). Check how InstanceType default interacts with + troika (t4g.xlarge / 80GB). Check how InstanceType default interacts with pack selection in the installer (installer reads registry -> sets param). ## 6. bootstrap.sh (deploy/bootstrap.sh) - Dep resolution: `registry_get_deps` is single-level (reads only the requested - pack's deps list). Trifecta lists bedrockify explicitly, so no transitive + pack's deps list). Troika lists bedrockify explicitly, so no transitive resolution needed — but ADD a dedupe guard so bedrockify isn't run twice - (openclaw's dep + trifecta's explicit dep) — check existing behavior first; + (openclaw's dep + troika's explicit dep) — check existing behavior first; if steps array dedupes already, document it. -- Shell profile phase: uses packs/trifecta/resources/shell-profile.sh (banner +- Shell profile phase: uses packs/troika/resources/shell-profile.sh (banner covers all three agents). The SSM banner vars (_SSM_BANNER_*) come from the same file — verify. - Post-install claude_code legacy flag: registry has `claude_code: false` for - trifecta because claude-code is a real dep pack; ensure Phase 3 doesn't + troika because claude-code is a real dep pack; ensure Phase 3 doesn't double-install it. ## 7. Preflight (install.sh — interacts with Bedrock access check) -The new `check_bedrock_access` probes Anthropic Sonnet 4.6. For trifecta, Codex +The new `check_bedrock_access` probes Anthropic Sonnet 4.6. For troika, Codex uses Bedrock Mantle model `openai.gpt-5.5` — access is granted separately from -Anthropic models. Amendment: when pack=trifecta, ALSO probe the Codex model. +Anthropic models. Amendment: when pack=troika, ALSO probe the Codex model. Mantle models may not respond to `bedrock-runtime converse` (they use the OpenAI-compatible Responses API path) — needs a live test; if converse doesn't work, fall back to `aws bedrock list-foundation-models` grep for `openai.` ids @@ -161,19 +161,19 @@ work, fall back to `aws bedrock list-foundation-models` grep for `openai.` ids ## 8. Tests -- packs/trifecta/test.sh: contract test (manifest parses, install.sh shellcheck-clean, +- packs/troika/test.sh: contract test (manifest parses, install.sh shellcheck-clean, daily-driver validation rejects bad values, .bashrc block is idempotent on re-run). -- tests/test-registry-parser.sh: add trifecta expectations (deps order, instance_type, +- tests/test-registry-parser.sh: add troika expectations (deps order, instance_type, default_model, requires_openai_key=false). -- tests/test-pack-contracts.sh: auto-discovers packs/* — verify it picks up trifecta +- tests/test-pack-contracts.sh: auto-discovers packs/* — verify it picks up troika and passes (install.sh executable, shebang, common.sh, done marker). -- packs/test-packs.sh: include trifecta in the matrix. +- packs/test-packs.sh: include troika in the matrix. - Idempotency: re-running bootstrap must not append duplicate .bashrc blocks - (use a sentinel marker like `# --- trifecta-autolaunch ---` + grep guard). + (use a sentinel marker like `# --- troika-autolaunch ---` + grep guard). ## 9. Docs -- docs/agents/trifecta.mdx (what it is, daily-driver switching, review workflow) +- docs/agents/troika.mdx (what it is, daily-driver switching, review workflow) - docs/agents/overview.mdx + docs.json nav + README pack table + wiki page update ## 10. Risks / open questions @@ -186,23 +186,103 @@ work, fall back to `aws bedrock list-foundation-models` grep for `openai.` ids use may throttle — document; optional preflight quota listing. 4. **Codex model access**: openai.gpt-5.5 on Bedrock is region-limited; default region us-east-1 verified working. Add region note to docs. -5. **Dep double-run**: bedrockify listed as dep of both trifecta and openclaw — +5. **Dep double-run**: bedrockify listed as dep of both troika and openclaw — MUST verify bootstrap dedupes or make install.sh idempotent (it is a systemd unit write — likely safe, but verify). 6. **claude-code haiku fast-path**: ANTHROPIC_DEFAULT_HAIKU_MODEL default has a versioned id — leave as pack default. 7. **kiro-cli style `requires_*` flags**: confirm installer treats unknown/false - flags as no-op for trifecta. + flags as no-op for troika. ## 11. Implementation order -1. Registry entries + packs/trifecta skeleton + tests (no installer changes yet). -2. Codex-on-Bedrock config in trifecta install.sh + live verify on a scratch deploy. +1. Registry entries + packs/troika skeleton + tests (no installer changes yet). +2. Codex-on-Bedrock config in troika install.sh + live verify on a scratch deploy. 3. Installer menu/question/params + CFN param + bootstrap passthrough. 4. Daily-driver autolaunch + agents helper + shell profile. 5. Preflight Codex-model probe amendment. 6. Docs + full test matrix + scratch-account end-to-end install. +## 12a. Owner decision (2026-07-12): selectable first horse + +The first horse (the OpenClaw-family agent) is **selectable**, not hardcoded: +new param `primary` (openclaw | hermes; default openclaw). +Full title: "Troika — mixture of harnesses: OpenClaw/Hermes ++ Claude Code + Codex CLI, all via Bedrock". No "OpenClaw" brand prefix on the +pack name/title — the first horse is selectable, we don't commit to it. +Owner decision 2026-07-12 12:25: roundhouse is EXCLUDED from the primary +options for now (its Telegram-credential requirement was a review blocker); +may be added later — see point 9. + +Design implications (implement in Phase 3 alongside installer wiring) — +amended after troika review 2026-07-12 (Claude Opus + Codex, arbitrated): +1. **Dynamic dep resolution**: registry deps stay `bedrockify, openclaw, + claude-code, codex-cli` as the DEFAULT; bootstrap substitutes the `openclaw` + dep with the selected primary. CRITICAL: substitution must apply to BOTH dep + reads — step counting (bootstrap.sh:343) AND install dispatch (bootstrap.sh:614) + — and no-op cleanly when primary=openclaw. Add `--primary` parsing + PACK_CONFIG + key (bootstrap.sh:242) alongside §12.1's `--daily-driver`/`--codex-model`. +2. **daily-driver values**: valid set on a given box = {installed primary, + claude-code, codex-cli, none}; DEFAULT must track the selected primary (not + hardcoded openclaw). THREE hardcoded sites in packs/troika/install.sh to widen: + VALID_DRIVERS, the autolaunch `case`, and the `agents` helper status loop. +3. **Launch commands**: source of truth is `PACK_TUI_COMMAND` in each pack's + resources/shell-profile.sh (openclaw→`openclaw tui`, hermes→`hermes`, + claude-code→`claude`) — NOT provides.commands. + Caveat: bare `hermes` may be one-shot CLI, not a REPL — live-verify in Phase 2/3; + if not interactive, daily-driver=hermes degrades to `none` + banner hint. +4. **agents helper** status/driver must reflect the selected primary (see point 2). +5. **Sizing**: keep troika at t4g.xlarge regardless of primary (builder-only + already implies it). (Note: openclaw pack min is t4g.large, not xlarge — immaterial.) +6. **Manifest is openclaw-bound**: health_check AND provides.services + ([openclaw-gateway]) assume primary=openclaw. health_check is operationally + inert (only checked for presence by test-pack-contracts) — keep static but + document; install.sh must only reference/enable openclaw-gateway when + primary=openclaw. `agents` helper is the real runtime health surface. +7. **CFN**: add `Primary` param (AllowedValues openclaw|hermes, + default openclaw) + UserData passthrough, TOGETHER with the §12.6 batch + (troika in PackName AllowedValues, DailyDriver, CodexModel — none exist yet; + UserData currently passes only `--model`, template.yaml:1133). +8. **Installer question**: "Which OpenClaw-family agent is your first horse?" + (gum choose) + `--primary` flag; review summary line. +9. **Roundhouse deferred** (owner decision, resolves the review BLOCKER): + roundhouse is NOT a primary option for now. Reason: its install hard-fails + without telegram_bot_token_secret + telegram_user (roundhouse/install.sh:97-129), + and the installer only collects those when PACK_NAME==roundhouse + (install.sh:~2815). IF added later: extend that prompt condition to + `troika && primary==roundhouse`, plumb params through CFN/bootstrap to the + dep, and use `roundhouse tui` (PACK_TUI_COMMAND) for autolaunch. +10. **Shell profile**: troika banner/aliases (loki, lt) are openclaw-flavored — + make primary-aware in Phase 4 (autolaunch/profile phase). +11. Cleanup (unrelated, low-pri): hermes manifest says `deps: []` while registry + gives it bedrockify — metadata drift, masked by troika's explicit dep. + +## 12b. Code quality requirements (owner, 2026-07-12) + +All troika code (pack install.sh, agents helper, installer/bootstrap/CFN changes) +must be clean, DRY, and maintainable. Concretely: + +1. **Single source of truth for agent metadata.** The agent→launch-command, + agent→binary, agent→display-name mappings must live in ONE place (e.g. an + associative array or a small lookup function in packs/troika/install.sh that + reads PACK_TUI_COMMAND from each pack's shell-profile.sh). The autolaunch + case, VALID_DRIVERS, and the agents helper must all derive from it — no + parallel hardcoded lists (the §12a review found 3 already; collapse them). +2. **No copy-paste between installer/bootstrap/pack.** Param plumbing for + primary/daily-driver/codex-model follows the ONE existing pattern + (PACK_CONFIG); extend it, don't invent a second mechanism. +3. **Functions over repetition** in install.sh: config-file writes, sentinel + block management, and validation should each be one helper used everywhere. +4. **Shellcheck-clean at -S warning** for all new/touched shell files — no + new suppressions without a comment explaining why. +5. **Tests mirror structure**: when a lookup table gains an agent, exactly one + test fixture should need updating. If adding hermes support requires edits + in >2 code sites (lookup + tests), the design is wrong — refactor first. +6. **Idempotency by construction**: every write to shared files (.bashrc, + config.toml) goes through the sentinel/merge helpers, never raw appends. + +Reviewers in troika reviews should flag violations of this section as MAJOR. + ## 12. Review amendments (Codex/GPT-5.5, verified against code) Verdict: required amendments before implementation. All folded into the plan above @@ -222,18 +302,18 @@ conceptually; listed here as the authoritative checklist: but still add pack-minimum enforcement to match advanced mode (:1940). 5. **Volume sizes are not plumbed**: installer never passes registry root/data_volume_gb to CFN (template defaults 40/80 at template.yaml:177). - Trifecta's needs happen to match the defaults — document, or add params. -6. **CFN**: add `trifecta` to PackName AllowedValues (template.yaml:117) + + Troika's needs happen to match the defaults — document, or add params. +6. **CFN**: add `troika` to PackName AllowedValues (template.yaml:117) + `DailyDriver`/`CodexModel` params + explicit UserData passthrough. 7. **Codex config must be MERGED, not replaced**: codex-cli pack writes approval_policy/sandbox_mode to ~/.codex/config.toml (codex-cli/install.sh:195). - Trifecta edits only model_provider/model keys. Keep Bedrock rewiring in - trifecta only — standalone codex-cli stays OpenAI (it rejects Bedrock ids, :87). + Troika edits only model_provider/model keys. Keep Bedrock rewiring in + troika only — standalone codex-cli stays OpenAI (it rejects Bedrock ids, :87). 8. **Autolaunch gating**: tty-check alone fires on every interactive SSM shell, including troubleshooting. Add sentinel-replacement idempotency (bootstrap appends .bashrc blocks blindly, bootstrap.sh:515/:524), allow `none` as a valid daily-driver value, and keep LOKI_NO_TUI + post-exit-to-shell behavior. 9. **requires_openai_key is inert in the installer** (registry data + tests only); codex standalone handles auth post-deploy. No installer guard needed. -10. **Profile lock (owner decision)**: trifecta is ALWAYS builder profile — +10. **Profile lock (owner decision)**: troika is ALWAYS builder profile — registry compatible_profiles: [builder]; installer skips/validates profile. diff --git a/TROIKA-REVIEW.md b/TROIKA-REVIEW.md new file mode 100644 index 0000000..28d71b3 --- /dev/null +++ b/TROIKA-REVIEW.md @@ -0,0 +1,314 @@ +# Troika Feature Code Review +> Reviewed: 2026-07-12 | Reviewer: subagent (Claude Sonnet 4.6) +> Scope: packs/troika/, packs/registry.{yaml,json}, install.sh (troika sections), deploy/bootstrap.sh, deploy/cloudformation/template.yaml + +--- + +## Summary + +The troika feature is structurally sound and largely satisfies the §12b code-quality mandate. The pack follows the single-source-of-truth `_read_tui_cmd` lookup pattern, dep-substitution lives in a single `get_effective_deps` helper, PACK_CONFIG is the sole plumbing mechanism, and all 59 pack-level tests pass with shellcheck clean at `-S warning`. The TOML merge Python script is a standout piece — it handles idempotency, key deduplication, and TOML escape correctly. + +There is one **critical bug** (shebang placement in generated `agents` script), two **major correctness gaps** (no installer-level cross-validation of `--daily-driver` vs `--primary`, autolaunch block frozen at first install), and a small cluster of minor items. None block a staged deploy to a scratch account, but the critical and major items should be fixed before a broader rollout. + +--- + +## Critical Issues (blockers) + +### C1 — Shebang on line 2 in generated `agents` script + +**File:** `packs/troika/install.sh` lines 258–260 + +The `agents` script is generated with a `# shellcheck disable=SC2086` comment on line 1 and the `#!/usr/bin/env bash` shebang on line 2: + +```bash +cat > "${AGENTS_TMP}" < "${AGENTS_TMP}" <\n\n" +``` + +**Manifest health_check:** Accept as-is for now (operationally inert per plan) but add an inline comment: `# NOTE: static; valid only for primary=openclaw (§12a.6)`. + +--- + +### m4 — No test for `primary=hermes` code path + +**File:** `packs/troika/test.sh` + +The test suite validates invalid primary/driver rejection but has zero coverage for the `primary=hermes` path. It doesn't verify: +- That `VALID_DRIVERS` contains `hermes` (not `openclaw`) when primary=hermes +- That the autolaunch block case-branches to `hermes` (not `openclaw tui`) when primary=hermes +- That the agents helper `VALID_DRIVERS` embedded string is hermes-aware + +The `deploy/test-bootstrap.sh` does cover `get_effective_deps` substitution for `primary=hermes` (Test 7), which is good. The gap is at the pack level. + +**Suggested test addition** (in section 3, daily-driver validation): +```bash +# Hermes-as-primary: valid drivers include hermes, exclude openclaw +if PACK_CONFIG=/dev/null bash "${INSTALL}" --primary hermes --daily-driver hermes >/dev/null 2>&1; then + pass "install.sh: accepts hermes as daily-driver when primary=hermes" +else + fail "install.sh: rejected hermes as daily-driver when primary=hermes (should accept)" +fi + +if PACK_CONFIG=/dev/null bash "${INSTALL}" --primary hermes --daily-driver openclaw >/dev/null 2>&1; then + fail "install.sh: accepted openclaw as daily-driver when primary=hermes (should reject)" +else + pass "install.sh: rejects openclaw as daily-driver when primary=hermes" +fi +``` + +--- + +### m5 — Unknown-arg handler in `install.sh` uses fragile `&&...||` pattern + +**File:** `packs/troika/install.sh` line 71 + +```bash +*) [[ $# -gt 1 ]] && [[ "$2" != --* ]] && shift 2 || shift ;; +``` + +This works correctly for all current inputs but the `A && B && C || D` chaining is a well-known shell footgun: if `A` is true and `B` is false, `C` is skipped and `D` runs — which happens to produce the right result here (shift 1 when next arg is a flag), but it's non-obvious and will confuse a future maintainer. The other packs (`openclaw`, `hermes`, etc.) use the same one-liner pattern — this isn't troika-specific — but since troika is new code, adopting a more readable form would be a small improvement: + +```bash +*) + if [[ $# -gt 1 ]] && [[ "$2" != --* ]]; then + shift 2 + else + shift + fi + ;; +``` + +--- + +### m6 — `_read_tui_cmd` strips only double-quotes + +**File:** `packs/troika/install.sh` lines 83–93 + +```bash +cmd="${cmd#\"}" # strip leading " +cmd="${cmd%\"}" # strip trailing " +``` + +If a future pack uses `PACK_TUI_COMMAND='my cmd'` (single quotes), the stripping won't fire and the value will include the literal quotes. All current packs use `=` without quotes or with double quotes, so this is safe today. Worth a comment or a more general strip: + +```bash +cmd="${cmd#[\"']}" # strip leading quote +cmd="${cmd%[\"']}" # strip trailing quote +``` + +--- + +### m7 — No test verifies the generated `agents` shebang placement + +Directly related to C1. The test suite checks `install.sh`'s shebang on line 126-127 but doesn't extract and validate the generated script's first line. A simple check would catch the regression permanently: + +```bash +# Verify agents script has shebang on line 1 +_generated_shebang=$(bash "${INSTALL}" --help >/dev/null; ... ) # or parse the heredoc directly +``` +(Implementation left to the maintainer since it requires either extracting the heredoc or running a dry-run install against a temp home.) + +--- + +### m8 — `manifest.yaml` `provides.commands` lists `openclaw` unconditionally + +**File:** `packs/troika/manifest.yaml` lines 42–46 + +```yaml +provides: + commands: + - openclaw + - claude + - codex + - agents + services: + - openclaw-gateway +``` + +When `primary=hermes`, `openclaw` is not installed and `openclaw-gateway` is not enabled. The manifest is static and the plan acknowledges this (§12a.6). This is acceptable as-is, but `hermes` is missing from `provides.commands` entirely even for the hermes-primary case. Consider adding a note in the manifest or a separate `provides.commands_primary_openclaw` / `provides.commands_primary_hermes` comment. + +--- + +## Positive Notes (things done well) + +**Architecture:** +- **`_read_tui_cmd` lookup function** is an elegant single-source-of-truth implementation that satisfies §12b.1 cleanly. All three autolaunch sites (VALID_DRIVERS, case dispatch, agents helper status loop) derive from it. +- **`get_effective_deps` in bootstrap.sh** handles the troika dep-substitution in exactly one place, applying correctly to both step counting and install dispatch. No copy-paste. +- **PACK_CONFIG is the sole plumbing mechanism** — no second channel was invented. Primary/daily-driver/codex-model flow through the same JSON config that all other pack params use. + +**TOML merge Python script:** +The Bedrock rewire logic in `packs/troika/install.sh` (lines 150–230) is the most complex piece and it's excellent: +- Idempotency: removes any prior troika block before writing (regex strip) +- Conflict avoidance: strips `model =` from the codex-cli managed block to prevent TOML duplicate-key errors while preserving `approval_policy` and `sandbox_mode` +- Placement: troika block goes to the top (before any `[table]`), valid per TOML spec +- Escape: the `toml_escape` function handles all required control-character cases including newline injection detection +- Error handling: validates the model string, exits with code 2 on newline in model value (prevents shell injection into TOML) + +**Safety / idempotency:** +- Sentinel-based `.bashrc` guard correctly prevents double-append on re-run +- `[[ $- == *i* ]]`, `[[ -t 0 ]]`, `LOKI_TUI_LAUNCHED`, and `LOKI_NO_TUI` guards are all present and in the correct order — the autolaunch cannot fire on non-tty SSM sessions, scp-over-ssm, or `aws ssm send-command` +- `CODEX_CONFIG` gets `chmod 600` after write — correct +- Temp file cleanup uses `trap ... EXIT` then `trap - EXIT` after explicit removal — clean pattern +- SSM put-parameter uses `|| warn` (non-fatal on IAM permission gap) — correct severity + +**Registry consistency:** +`registry.yaml` and `registry.json` are byte-for-byte semantically consistent (all 59 test-sync-registry.sh checks pass). `requires_openai_key: false` is correctly set and the installer's `compatible_profiles` enforcement will auto-lock troika to `builder`. + +**Test coverage:** +59 tests, 0 failures, 0 skips in `packs/troika/test.sh`. Shellcheck clean at `-S warning` for both `install.sh` and `shell-profile.sh`. The suite covers: manifest structure, arg parsing, valid/invalid driver rejection, sentinel idempotency, all escape hatches, and registry consistency. This is significantly better test coverage than most other packs. + +**bootstrap.sh dep substitution:** +`get_effective_deps` correctly handles the hermes-primary case (Test 7 in test-bootstrap.sh passes). The substitution is atomic: `openclaw` is replaced, `bedrockify` (which is both a troika explicit dep AND openclaw's dep) is not duplicated because dep resolution is single-level. This satisfies plan §12.2 (risk #5 resolved, documented in TROIKA-PLAN §12). + +**CFN integration:** +`Primary` param has correct `AllowedValues: [openclaw, hermes]`. Both `Primary` and `DailyDriver` are listed in the `ParameterGroups` UI grouping. The `bootstrap.sh` passthrough is explicit (not relying on generic unknown-arg forwarding). Array sync check (`PARAM_CFN_NAMES` == `PARAM_VALUES` length) fires at deploy time — good guard against future drift. + +--- + +*End of review. 3 critical/major items warrant fixes before broader rollout; minors can be addressed in a follow-up pass.* diff --git a/TROIKA-STATUS.md b/TROIKA-STATUS.md new file mode 100644 index 0000000..e53601a --- /dev/null +++ b/TROIKA-STATUS.md @@ -0,0 +1,94 @@ +# TROIKA-STATUS.md — Phases 5 & 6 Implementation Status + +**Date:** 2026-07-12 +**Implementer:** Subagent (depth 1) +**Phases completed:** 5 (Preflight Codex-model probe) + 6 (Docs) + +--- + +## Phase 5: Preflight Codex-model probe + +### What was implemented + +**File modified:** `install.sh` + +Two additions to `install.sh`: + +1. **`_check_codex_model_access()` helper function** (placed after `_bedrock_access_guidance`, before `check_vpc_quota`, ~line 1460): + - Takes `codex_model` and `probe_region` as arguments + - **Attempt 1**: Tries `aws bedrock-runtime converse` — covers the case where Mantle does support converse, and catches ThrottlingException/TooManyRequests (throttled = access granted) + - **Attempt 2 (fallback)**: `aws bedrock list-foundation-models --query "modelSummaries[?contains(modelId, 'openai')]"` — confirms Mantle models are visible in the region/account + - If fallback also returns nothing: issues a non-fatal warning with model-access console URL + - Does NOT call `confirm_or_abort` — purely informational (user already saw Sonnet warning) + - Does NOT modify `BEDROCK_ACCESS_OK` — that global tracks Sonnet only + +2. **Troika probe trigger** (added at end of `check_bedrock_access()`, before closing `}`): + - Condition: `PACK_NAME=troika` OR `PRESELECT_PACK=troika` OR `TROIKA_CODEX_MODEL` non-empty + - Rationale: `PACK_NAME` is still "openclaw" (default) at preflight time because pack selection runs later (`run_config_and_review`). `PRESELECT_PACK` covers `--pack troika` non-interactive mode. `TROIKA_CODEX_MODEL` covers `--codex-model` explicit overrides. + - Uses `${TROIKA_CODEX_MODEL:-openai.gpt-5.5}` as the model to probe + +### Shellcheck + +- Pre-change: 18 output lines (3 pre-existing warnings, all unrelated to Troika) +- Post-change: 18 output lines (same — zero new warnings introduced) +- Exit code: 0 (shellcheck -S warning) + +--- + +## Phase 6: Docs + +### Files created/modified + +**New file:** `docs/agents/troika.mdx` + +Content covers: +- What Troika is (three-harness coding machine: OpenClaw/Hermes + Claude Code + Codex CLI, all via Bedrock) +- Summary table of the three horses with binaries and roles +- Daily driver switching (`agents driver `, `agents` status, escape hatches) +- Review workflow concept (manual cross-review commands; `agents review` stretch goal noted) +- Stack requirements table (t4g.xlarge, 80GB data volume, builder-only, region requirements) +- Bedrock model access section (two separate grants required: Anthropic Sonnet + Mantle openai.gpt-5.5) +- What the pack installs (6 steps) +- Resource usage notes +- First-run commands +- Links to related docs + +**Modified:** `README.md` + +Two tables updated: +1. **"Agent packs" summary table** (~line 89): Added `| **Troika** *(experimental)* | ...` row after Codex CLI +2. **"Available Packs" reference table** (~line 207): Added `` | `troika` | Agent *(experimental)* | ... `` row after `codex-cli`, before `roundhouse` + +--- + +## Test Results + +### `bash packs/troika/test.sh` + +``` +Passed: 59 +Failed: 0 +Skipped: 0 +✓ All tests passed +``` + +### `bash tests/test-pack-contracts.sh` + +``` +Discovered 7 agent pack(s): claude-code codex-cli hermes kiro-cli openclaw roundhouse troika + +Passed: 155 +Failed: 0 +Skipped: 0 +✓ All pack contracts satisfied +``` + +--- + +## Code quality (§12b compliance) + +- ✅ DRY: `_check_codex_model_access` is a single helper, called once from `check_bedrock_access` +- ✅ Single source of truth: probe trigger checks all known early signals (`PACK_NAME`, `PRESELECT_PACK`, `TROIKA_CODEX_MODEL`) +- ✅ Non-fatal by construction: no `confirm_or_abort`, no `fail`, `BEDROCK_ACCESS_OK` unchanged +- ✅ Shellcheck-clean at `-S warning` (zero new warnings) +- ✅ No hardcoded pack logic in troika pack files — only `install.sh` (main installer) was modified diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index 7f3c678..3d5af43 100755 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -124,6 +124,10 @@ LITELLM_KEY="" LITELLM_MODEL="" PROVIDER_KEY="" SKIP_TELEMETRON="false" +# Troika-specific params (primary horse, daily-driver, codex model — §12a.1, §12b) +PRIMARY="" +DAILY_DRIVER="" +CODEX_MODEL="" # Pack-specific: optional Secrets Manager id/arn to resolve at install time # into KIRO_API_KEY inside the kiro-cli pack (and potentially others later). # The raw key is NEVER written to CFN state, UserData, or bootstrap logs. @@ -216,6 +220,21 @@ while [[ $# -gt 0 ]]; do TELEGRAM_USER="$2" shift 2 ;; + --primary) + [[ $# -gt 1 ]] || { echo "ERROR: --primary requires a value" >&2; exit 1; } + PRIMARY="$2" + shift 2 + ;; + --daily-driver) + [[ $# -gt 1 ]] || { echo "ERROR: --daily-driver requires a value" >&2; exit 1; } + DAILY_DRIVER="$2" + shift 2 + ;; + --codex-model) + [[ $# -gt 1 ]] || { echo "ERROR: --codex-model requires a value" >&2; exit 1; } + CODEX_MODEL="$2" + shift 2 + ;; --*) # Skip unknown options (with optional value) if [[ $# -gt 1 ]] && [[ "$2" != --* ]]; then @@ -256,6 +275,9 @@ jq -n \ --arg telegram_bot_token_secret "$TELEGRAM_BOT_TOKEN_SECRET" \ --arg telegram_user "$TELEGRAM_USER" \ --arg skip_telemetron "$SKIP_TELEMETRON" \ + --arg primary "$PRIMARY" \ + --arg daily_driver "$DAILY_DRIVER" \ + --arg codex_model "$CODEX_MODEL" \ '{pack:$pack, profile:$profile, region:$region, model:$model, gw_port:$gw_port, model_mode:$model_mode, bedrockify_port:$bedrockify_port, hermes_model:$hermes_model, litellm_url:$litellm_url, @@ -264,7 +286,10 @@ jq -n \ "from-secret":$from_secret, telegram_bot_token_secret:$telegram_bot_token_secret, telegram_user:$telegram_user, - "skip-telemetron":$skip_telemetron}' > "${PACK_CONFIG}" + "skip-telemetron":$skip_telemetron, + primary:$primary, + "daily-driver":$daily_driver, + "codex-model":$codex_model}' > "${PACK_CONFIG}" chmod 600 "${PACK_CONFIG}" chown ec2-user:ec2-user "${PACK_CONFIG}" export PACK_CONFIG @@ -323,6 +348,22 @@ registry_get_deps() { " "$REGISTRY" } +# get_effective_deps PACK — registry_get_deps with troika primary-substitution (§12a.1, §12b). +# When PACK_NAME=troika and PRIMARY≠openclaw, replaces the 'openclaw' dep with the +# selected primary. Applied to BOTH step-counting and install dispatch via this single +# helper (not two copies — §12b). No-op when primary=openclaw or pack≠troika. +get_effective_deps() { + local pack="$1" + local dep + while IFS= read -r dep; do + if [[ "$pack" == "troika" && "${PRIMARY:-openclaw}" != "openclaw" && "$dep" == "openclaw" ]]; then + echo "${PRIMARY}" + else + echo "$dep" + fi + done < <(registry_get_deps "$pack") +} + # registry_get_data_vol PACK — prints data_volume_gb value or "80" default registry_get_data_vol() { local pack="$1" @@ -344,7 +385,7 @@ _total_steps=$(_count_steps_in "${DEPLOY_DIR}/bootstrap.sh") while IFS= read -r _dep; do [[ -n "$_dep" ]] && _dep_script="${PACKS_DIR}/${_dep}/install.sh" && \ [[ -f "$_dep_script" ]] && _total_steps=$((_total_steps + $(_count_steps_in "$_dep_script"))) -done < <(registry_get_deps "${PACK_NAME}") +done < <(get_effective_deps "${PACK_NAME}") # Add steps from main pack _pack_script="${PACKS_DIR}/${PACK_NAME}/install.sh" @@ -615,7 +656,7 @@ step "Phase 2: Pack Dispatch" DEPS=() while IFS= read -r dep; do [[ -n "$dep" ]] && DEPS+=("$dep") -done < <(registry_get_deps "${PACK_NAME}") +done < <(get_effective_deps "${PACK_NAME}") info "Pack: ${PACK_NAME}" if [[ ${#DEPS[@]} -gt 0 ]]; then diff --git a/deploy/cloudformation/template.yaml b/deploy/cloudformation/template.yaml index 5537ad3..12695b5 100644 --- a/deploy/cloudformation/template.yaml +++ b/deploy/cloudformation/template.yaml @@ -26,6 +26,9 @@ Metadata: - KiroFromSecret - TelegramBotTokenSecret - TelegramUser + - Primary + - DailyDriver + - CodexModel - RequestQuotaIncreases - Label: default: "🛡️ Security Services" @@ -124,7 +127,8 @@ Parameters: - kiro-cli - codex-cli - roundhouse - Description: "Agent pack to deploy. 'openclaw' is the stateful AI agent with 24/7 gateway (recommended). 'roundhouse' is the pi-based agent with Telegram. 'hermes' is the NousResearch CLI agent (lighter). 'kiro-cli' and 'codex-cli' are experimental." + - troika + Description: "Agent pack to deploy. 'openclaw' is the stateful AI agent with 24/7 gateway (recommended). 'roundhouse' is the pi-based agent with Telegram. 'hermes' is the NousResearch CLI agent (lighter). 'kiro-cli' and 'codex-cli' are experimental. 'troika' installs OpenClaw/Hermes + Claude Code + Codex CLI on one instance (experimental)." ProfileName: Type: String @@ -264,6 +268,24 @@ Parameters: Default: '' Description: "Telegram username for bot pairing (roundhouse pack only, without @ prefix)." + Primary: + Type: String + Default: openclaw + AllowedValues: + - openclaw + - hermes + Description: "Troika pack only: the OpenClaw-family first horse (openclaw or hermes). Ignored for other packs." + + DailyDriver: + Type: String + Default: '' + Description: "Troika pack only: agent to auto-launch when you SSM into the instance (openclaw, hermes, claude-code, codex-cli, none; default=primary). Ignored for other packs." + + CodexModel: + Type: String + Default: 'openai.gpt-5.5' + Description: "Troika pack only: Bedrock Mantle model ID for Codex CLI. Ignored for other packs." + EnableBedrockForm: Type: String Default: 'false' @@ -1143,7 +1165,10 @@ Resources: --provider-api-key "$PROVIDER_API_KEY" \ --kiro-from-secret "$KIRO_FROM_SECRET" \ --telegram-bot-token-secret "$TELEGRAM_BOT_TOKEN_SECRET" \ - --telegram-user "$TELEGRAM_USER" + --telegram-user "$TELEGRAM_USER" \ + --primary "${Primary}" \ + --daily-driver "${DailyDriver}" \ + --codex-model "${CodexModel}" # Signal CFN success — defense-in-depth: bootstrap.sh also signals, # but this catches edge cases where bootstrap.sh signal fails silently. # CFN ignores duplicate signals, so double-fire is safe. diff --git a/deploy/test-bootstrap.sh b/deploy/test-bootstrap.sh index dcaacde..88a39f2 100755 --- a/deploy/test-bootstrap.sh +++ b/deploy/test-bootstrap.sh @@ -211,6 +211,110 @@ else fi echo "" +# ── Test 7: Troika dep-substitution ────────────────────────────────────────── +info "Test 7: get_effective_deps troika primary-substitution" +# Verify get_effective_deps is present and implements the substitution logic. +# We source only the registry helpers + get_effective_deps from bootstrap.sh; +# the rest of the script is not executed. + +REGISTRY="${DEPLOY_DIR}/../packs/registry.yaml" +if [[ ! -f "$REGISTRY" ]]; then + info " Registry not found — skipping dep-substitution test" +else + # Extract get_effective_deps function + its dependency (registry_get_deps) to a temp file + _DEP_TEST_SCRIPT=$(mktemp /tmp/test-dep-subst-XXXXXX.sh) + cat > "$_DEP_TEST_SCRIPT" << 'DEPTEST_EOF' +#!/bin/bash +set -euo pipefail +REGISTRY="$1" +PRIMARY="${2:-openclaw}" + +# Minimal stubs for bootstrap helpers bootstrap.sh needs before registry helpers +registry_get_deps() { + local pack="$1" + awk " + /^ ${pack}:/{found=1; in_deps=0; next} + found && /^ [a-z]/{exit} + found && /^ deps:/{in_deps=1; next} + found && in_deps && /^ - /{gsub(/^ - /, \"\"); print; next} + found && in_deps && !/^ /{in_deps=0} + " "$REGISTRY" +} + +get_effective_deps() { + local pack="$1" + local dep + while IFS= read -r dep; do + if [[ "$pack" == "troika" && "${PRIMARY:-openclaw}" != "openclaw" && "$dep" == "openclaw" ]]; then + echo "${PRIMARY}" + else + echo "$dep" + fi + done < <(registry_get_deps "$pack") +} + +# Test 1: troika + primary=openclaw → no substitution +PRIMARY="openclaw" +DEPS_OC=$(get_effective_deps troika | tr '\n' ',') +if [[ "$DEPS_OC" == *"openclaw"* ]]; then + echo "PASS: primary=openclaw → openclaw dep preserved" +else + echo "FAIL: primary=openclaw should preserve openclaw dep; got: $DEPS_OC" + exit 1 +fi + +# Test 2: troika + primary=hermes → openclaw replaced with hermes +PRIMARY="hermes" +DEPS_HERMES=$(get_effective_deps troika | tr '\n' ',') +if [[ "$DEPS_HERMES" == *"hermes"* && "$DEPS_HERMES" != *"openclaw"* ]]; then + echo "PASS: primary=hermes → hermes dep substituted, openclaw removed" +else + echo "FAIL: primary=hermes substitution wrong; got: $DEPS_HERMES" + exit 1 +fi + +# Test 3: non-troika pack unaffected by PRIMARY!=openclaw +PRIMARY="hermes" +DEPS_OC_PACK=$(get_effective_deps openclaw | tr '\n' ',') +# openclaw's own dep list should be unchanged +if [[ "$DEPS_OC_PACK" != *"hermes"* ]]; then + echo "PASS: non-troika pack unaffected by PRIMARY=hermes" +else + echo "FAIL: non-troika pack should not be affected; got: $DEPS_OC_PACK" + exit 1 +fi + +# Test 4: order preservation — bedrockify,,claude-code,codex-cli +PRIMARY="hermes" +deps_arr=() +while IFS= read -r dep; do + deps_arr+=("$dep") +done < <(get_effective_deps troika) +if [[ "${deps_arr[0]}" == "bedrockify" && "${deps_arr[1]}" == "hermes" \ + && "${deps_arr[2]}" == "claude-code" && "${deps_arr[3]}" == "codex-cli" ]]; then + echo "PASS: dep order preserved (bedrockify→hermes→claude-code→codex-cli)" +else + echo "FAIL: dep order wrong; got: ${deps_arr[*]}" + exit 1 +fi + +echo "ALL_PASS" +DEPTEST_EOF + + DEP_OUT=$(bash "$_DEP_TEST_SCRIPT" "$REGISTRY" 2>&1) && DEP_RC=0 || DEP_RC=$? + rm -f "$_DEP_TEST_SCRIPT" + + if echo "$DEP_OUT" | grep -q "FAIL"; then + fail "dep-substitution: at least one case failed — see above" + echo "$DEP_OUT" | grep -v PASS + elif echo "$DEP_OUT" | grep -q "ALL_PASS"; then + ok "dep-substitution: primary=openclaw no-op, primary=hermes substitutes, non-troika unaffected, order preserved" + else + fail "dep-substitution: unexpected output — got: $DEP_OUT" + fi +fi +echo "" + # ── Summary ─────────────────────────────────────────────────────────────────── echo "================================================================" echo " Results: $PASS passed, $FAIL failed" diff --git a/docs/agents/troika.mdx b/docs/agents/troika.mdx new file mode 100644 index 0000000..bf9bbac --- /dev/null +++ b/docs/agents/troika.mdx @@ -0,0 +1,173 @@ +--- +title: "Troika" +description: "Three-harness coding machine — OpenClaw/Hermes + Claude Code + Codex CLI, all via Amazon Bedrock." +--- + + +**Experimental pack.** Troika installs three AI agent runtimes on a single EC2 instance — an OpenClaw-family agent (OpenClaw or Hermes), Claude Code, and Codex CLI — all routed through Amazon Bedrock. No OpenAI or Anthropic API keys needed. One agent auto-launches when you SSM in; the others are a command away. + + +## What Troika is + +Troika gives you a **mixture of harnesses** on one box: + +| Horse | Binary | Role | +|---|---|---| +| OpenClaw (or Hermes) | `openclaw tui` / `hermes` | Persistent stateful agent — gateway service, memory, multi-channel | +| Claude Code | `claude` | Interactive coding CLI — Anthropic native Bedrock support | +| Codex CLI | `codex` | OpenAI Codex on Bedrock Mantle — builder agent, full tool access | + +All three share the same EC2 instance, IAM role, and Bedrock quota. Only the **daily driver** persists continuously; the other two are on-demand CLI tools. + +The `agents` helper is the control plane: + +```bash +agents # → status: which binaries are installed + current daily driver +agents driver # → switch daily driver (openclaw | claude-code | codex-cli | none) +``` + +### The review workflow (stretch goal) + +The multi-harness setup enables a **cross-review flow** where two agents inspect each other's output: + +```bash +agents review path/to/diff # (coming in Phase 2) — runs non-driver agents in exec mode +``` + +Today you can approximate this manually: + +```bash +# OpenClaw builds, Claude Code reviews +git diff HEAD~1 | claude -p "Review these changes for bugs, security issues, and style" + +# Codex arbitrates +git diff HEAD~1 | codex exec "Arbitrate: is this change safe to merge?" +``` + +## Daily driver switching + +The **daily driver** is the agent that auto-launches when you open an interactive SSM session. It is stored in `~/.config/lowkey/daily-driver`. + +```bash +# See current state +agents + +# Switch to Claude Code +agents driver claude-code + +# Switch to Codex CLI +agents driver codex-cli + +# Switch back to OpenClaw +agents driver openclaw + +# Disable auto-launch entirely +agents driver none +``` + +**Escape hatches** (apply to the current session only): + +```bash +# Suppress auto-launch for this session +export LOKI_NO_TUI=1 + +# Or just press Ctrl+C during the 2-second grace period on login +``` + +The auto-launch only fires in a real interactive TTY (`[[ -t 0 ]]`). It will **not** trigger on `aws ssm send-command`, SCP-over-SSM, port-forwarding sessions, or scripted exec. + +## Install — non-interactive + +```bash +# Default: primary=openclaw, daily-driver=openclaw +curl -sfL install.lowkey.run | bash -s -- -y --pack troika --profile builder + +# Hermes as the first horse +curl -sfL install.lowkey.run | bash -s -- -y --pack troika --profile builder \ + --primary hermes --daily-driver claude-code + +# Override Codex Mantle model +curl -sfL install.lowkey.run | bash -s -- -y --pack troika --profile builder \ + --codex-model openai.gpt-5.5 --region us-east-1 +``` + + +Troika is **builder-only** — it always deploys with the `builder` IAM profile. The installer auto-selects `builder` and ignores other profiles. + + +## Pack parameters + +| Param | Flag | Default | Description | +|---|---|---|---| +| `primary` | `--primary` | `openclaw` | First-horse agent (`openclaw` or `hermes`) | +| `daily-driver` | `--daily-driver` | *(follows primary)* | Agent to auto-launch on SSM login | +| `model` | `--model` | `us.anthropic.claude-sonnet-4-6` | Bedrock model for Claude-family agents | +| `codex-model` | `--codex-model` | `openai.gpt-5.5` | Bedrock Mantle model ID for Codex CLI | +| `region` | `--region` | `us-east-1` | AWS region for Bedrock | + +## Stack requirements + +| | Troika | +|---|---| +| Profile | `builder` only | +| Instance | `t4g.xlarge` (4 vCPU / 16 GB RAM) | +| Root volume | 40 GB | +| Data volume | 80 GB (OpenClaw brain) | +| Bedrock: Anthropic | `us.anthropic.claude-sonnet-4-6` — enable in Model access | +| Bedrock: Mantle | `openai.gpt-5.5` — enable in Model access (separate grant) | +| Region | `us-east-1` recommended (Bedrock Mantle is region-limited) | + +### Bedrock model access + +Troika requires **two separate model-access grants** in Bedrock: + +1. **Anthropic Sonnet** — used by OpenClaw/Hermes and Claude Code + → [Bedrock Model Access](https://us-east-1.console.aws.amazon.com/bedrock/home?region=us-east-1#/modelaccess) + +2. **Bedrock Mantle (`openai.gpt-5.5`)** — used by Codex CLI + → Same console, separate toggle. Mantle model access is region-limited; `us-east-1` is verified working. + +The installer's preflight check probes **both** and warns if either is missing. The install continues regardless — you can enable access post-deploy if needed. + +## What the pack installs + +The `bedrockify → → claude-code → codex-cli` deps run first (via bootstrap dep resolution). Then `packs/troika/install.sh`: + +1. Validates and persists the `daily-driver` selection to `~/.config/lowkey/daily-driver` +2. Merges Bedrock provider config into `~/.codex/config.toml` (preserves other keys) +3. Exports `AWS_REGION` to `~/.bashrc` (Codex reads `AWS_REGION`, not `AWS_DEFAULT_REGION`) +4. Installs the `agents` multi-harness helper to `/usr/local/bin/agents` +5. Appends the auto-launch block to `~/.bashrc` (sentinel-guarded, idempotent) +6. Publishes daily-driver choice to SSM Parameter Store (`/loki/daily-driver`) + +## Memory and resource usage + +Only the **daily driver** runs persistently. On `t4g.xlarge` (16 GB RAM): + +- OpenClaw gateway: ~300 MB resident (persistent systemd service) +- Claude Code + Codex: launched on demand, exit when done + +Running all three simultaneously is supported — the instance has headroom for concurrent interactive TUI sessions. + +## First-run (post-install) + +```bash +# Connect to the instance +aws ssm start-session --target + +# Check status of all three agents +agents + +# Run the cross-review workflow manually +git diff HEAD~1 | claude -p "Review for bugs" +git diff HEAD~1 | codex exec "Is this safe to merge?" + +# Switch your daily driver +agents driver claude-code +``` + +## Related + +- [OpenClaw](/agents/openclaw) — persistent stateful agent +- [Claude Code](/agents/claude-code) — Anthropic coding CLI +- [Codex CLI](/agents/codex-cli) — OpenAI Codex coding agent diff --git a/install.sh b/install.sh index de4323a..b0b087b 100755 --- a/install.sh +++ b/install.sh @@ -657,6 +657,9 @@ _TELEM_LIB_READY=1 AUTO_YES=false PRESELECT_PACK="" PRESELECT_METHOD="" +TROIKA_PRIMARY="" # --primary flag: first horse (openclaw | hermes) +TROIKA_DAILY_DRIVER="" # --daily-driver flag: auto-launch agent +TROIKA_CODEX_MODEL="" # --codex-model flag: Bedrock model for Codex CLI PRESELECT_PROFILE="" INSTALL_MODE="" # "simple" or "advanced", empty = ask DEBUG_IN_REPO=false @@ -713,6 +716,24 @@ while [[ $# -gt 0 ]]; do exit 1 fi TELEGRAM_USER="$2"; shift 2 ;; + --primary) + if [[ $# -lt 2 || "$2" == --* ]]; then + echo -e "\033[0;31m✗\033[0m --primary requires a value (openclaw, hermes)" >&2 + exit 1 + fi + TROIKA_PRIMARY="$2"; shift 2 ;; + --daily-driver) + if [[ $# -lt 2 || "$2" == --* ]]; then + echo -e "\033[0;31m✗\033[0m --daily-driver requires a value (openclaw, hermes, claude-code, codex-cli, none)" >&2 + exit 1 + fi + TROIKA_DAILY_DRIVER="$2"; shift 2 ;; + --codex-model) + if [[ $# -lt 2 || "$2" == --* ]]; then + echo -e "\033[0;31m✗\033[0m --codex-model requires a Bedrock model ID" >&2 + exit 1 + fi + TROIKA_CODEX_MODEL="$2"; shift 2 ;; --debug-in-repo) DEBUG_IN_REPO=true; shift ;; --test|--dry-run) TEST_MODE=true; shift ;; --auto-rename-account-enabled) AUTO_RENAME_ACCOUNT=true; shift ;; @@ -728,10 +749,17 @@ Options: --simple Force simple install mode --advanced Force advanced install mode --pack Agent pack (openclaw, claude-code, codex-cli, - kiro-cli, hermes, roundhouse) + kiro-cli, hermes, roundhouse, troika) --profile Permission profile (builder, account_assistant, personal_assistant) --method Deploy method (default: cfn) + --primary Troika: first horse (openclaw | hermes; + default openclaw) + --daily-driver Troika: agent to auto-launch on SSM login + ( | claude-code | codex-cli | none; + default = primary) + --codex-model Troika: Bedrock Mantle model for Codex CLI + (default openai.gpt-5.5) --kiro-from-secret Secrets Manager id/arn for Kiro API key (kiro-cli headless mode) --telegram-bot-token Telegram bot token (roundhouse pack; @@ -1394,6 +1422,17 @@ check_bedrock_access() { echo -e " ${DIM}$(echo "$invoke_err" | head -1)${NC}" _bedrock_access_guidance "$probe_region" fi + + # Phase 5: Troika Codex (Bedrock Mantle) model probe — non-fatal, independent + # of the Sonnet result above. BEDROCK_ACCESS_OK tracks Sonnet only. + # PACK_NAME is not yet resolved at preflight time (pack selection runs later); + # detect troika early via PRESELECT_PACK (--pack flag) or TROIKA_CODEX_MODEL + # (--codex-model flag). + if [[ "${PACK_NAME:-}" == "troika" ]] \ + || [[ "${PRESELECT_PACK:-}" == "troika" ]] \ + || [[ -n "${TROIKA_CODEX_MODEL:-}" ]]; then + _check_codex_model_access "${TROIKA_CODEX_MODEL:-openai.gpt-5.5}" "$probe_region" + fi } _bedrock_access_guidance() { @@ -1418,6 +1457,61 @@ _bedrock_access_guidance() { confirm_or_abort "Continue with the install anyway?" "default_yes" } +# Phase 5: Probe Bedrock Mantle (openai.*) model access for Troika's Codex CLI. +# Mantle models use the OpenAI-compatible Responses API, NOT bedrock-runtime +# converse — attempt converse first; fall back to list-foundation-models grep +# for openai.* ids. Non-fatal: warns and lets the install continue. +_check_codex_model_access() { + local codex_model="$1" + local probe_region="$2" + + echo "" + info "Checking Codex (Bedrock Mantle) model access: ${codex_model} @ ${probe_region}..." + + # Attempt 1: converse — Mantle models likely return UnsupportedOperationException + # (they use the OpenAI-compatible Responses API path, not converse). We try + # anyway because the error response is informative and throttle = access OK. + local codex_err="" + if codex_err=$(aws bedrock-runtime converse \ + --model-id "$codex_model" \ + --messages '[{"role":"user","content":[{"text":"ping"}]}]' \ + --inference-config '{"maxTokens":1}' \ + --region "$probe_region" 2>&1 >/dev/null); then + ok "Codex Mantle model verified via converse (${codex_model})" + return 0 + elif grep -qiE "ThrottlingException|TooManyRequests|ServiceQuotaExceeded" <<<"$codex_err"; then + ok "Codex Mantle model access verified (throttled — access is enabled)" + return 0 + fi + + # Attempt 2: list-foundation-models grep for openai.* ids. + # Confirms Bedrock Mantle is visible/available in this region + account, + # even if converse is unsupported for Mantle model IDs. + local openai_ids="" + openai_ids=$(aws bedrock list-foundation-models \ + --region "$probe_region" \ + --query "modelSummaries[?contains(modelId, 'openai')]" \ + --output text 2>/dev/null || true) + + if [[ -n "$openai_ids" ]]; then + ok "Bedrock Mantle models visible (openai.* ids found in ${probe_region})" + info "Codex CLI uses the OpenAI-compatible Responses API — converse probe does not apply." + else + warn "Codex Mantle model '${codex_model}' may not be accessible in ${probe_region}." + echo -e " ${DIM}$(echo "$codex_err" | head -1)${NC}" + echo "" + echo " Troika's Codex CLI uses Amazon Bedrock Mantle (openai.* model IDs)." + echo " Bedrock Mantle access is separate from Anthropic model access." + echo " Enable Mantle model access (if available to your account):" + echo " https://${probe_region}.console.aws.amazon.com/bedrock/home?region=${probe_region}#/modelaccess" + echo "" + echo " Note: Bedrock Mantle is region-limited; us-east-1 is the recommended region." + echo " The install will continue — Codex will not function until access is enabled." + echo "" + # Non-fatal: no confirm_or_abort (user already saw Sonnet warning if applicable) + fi +} + check_vpc_quota() { local check_region="${DEPLOY_REGION:-$REGION}" echo "" @@ -1785,11 +1879,38 @@ choose_pack() { ok "Agent: ${PACK_NAME}" } -# Check pack/profile compatibility +# Check pack/profile compatibility against registry compatible_profiles. +# When a pack declares compatible_profiles, auto-selects the first allowed +# profile (or fails fast when --profile was explicit). No-op for packs with +# no restriction (most packs). check_pack_profile_compat() { - # No pack-specific profile restrictions at present. Packs may declare - # compatible_profiles in packs/registry.yaml; add enforcement here if needed. - : + local registry="$_PACK_REGISTRY" + [[ -z "$registry" || ! -f "$registry" ]] && return 0 + + local compat_profiles + compat_profiles=$(jq -r --arg p "$PACK_NAME" '.packs[$p].compatible_profiles // [] | .[]' "$registry" 2>/dev/null || true) + [[ -z "$compat_profiles" ]] && return 0 # no restriction + + local profile_ok=false + while IFS= read -r cp; do + [[ "$PROFILE_NAME" == "$cp" ]] && profile_ok=true && break + done <<< "$compat_profiles" + + [[ "$profile_ok" == "true" ]] && return 0 + + local allowed_list + allowed_list=$(printf '%s' "$compat_profiles" | tr '\n' ',' | sed 's/,$//') + + if [[ -n "${PRESELECT_PROFILE:-}" ]]; then + fail "Pack '${PACK_NAME}' requires profile in [${allowed_list}]. Got --profile '${PRESELECT_PROFILE}'." + fi + + # Interactive selection was outside allowed set — auto-correct to first allowed profile + local first_compat + first_compat=$(printf '%s' "$compat_profiles" | head -1) + info "${PACK_NAME} is restricted to [${allowed_list}] profile(s) — auto-selecting '${first_compat}'" + PROFILE_NAME="$first_compat" + ok "Profile: ${PROFILE_NAME}" } # ============================================================================ @@ -1817,6 +1938,25 @@ collect_config_simple() { *) INSTANCE_TYPE="t4g.xlarge" ;; esac + # Pack minimum (§12.4): enforce registry instance_type as floor so a pack that + # requires a larger instance isn't silently under-provisioned in simple mode. + if [[ -n "$registry" && -f "$registry" ]]; then + local _sim_pack_min + _sim_pack_min=$(jq -r --arg p "$PACK_NAME" '.packs[$p].instance_type // ""' "$registry" 2>/dev/null || echo "") + case "$_sim_pack_min" in + t4g.xlarge) + if [[ "$INSTANCE_TYPE" != "t4g.xlarge" ]]; then + INSTANCE_TYPE="t4g.xlarge" + info "${PACK_NAME} requires t4g.xlarge minimum (§12.4) — upgrading from profile default" + fi ;; + t4g.large) + if [[ "$INSTANCE_TYPE" == "t4g.medium" ]]; then + INSTANCE_TYPE="t4g.large" + info "${PACK_NAME} requires t4g.large minimum (§12.4) — upgrading from profile default" + fi ;; + esac + fi + # Environment name: auto-generate local existing_count existing_count=$(aws ec2 describe-vpcs \ @@ -1966,7 +2106,7 @@ collect_security_config() { # Parameter source-of-truth: single mapping for CFN Console and CFN CLI # ============================================================================ # ⚠ KEEP THESE TWO ARRAYS IN SYNC — same order, same count -PARAM_CFN_NAMES=(EnvironmentName PackName ProfileName InstanceType DefaultModel ModelMode BedrockRegion LokiWatermark EnableBedrockForm EnableSecurityHub EnableGuardDuty EnableInspector EnableAccessAnalyzer EnableConfigRecorder ExistingVpcId ExistingSubnetId RepoBranch KiroFromSecret TelegramBotTokenSecret TelegramUser) +PARAM_CFN_NAMES=(EnvironmentName PackName ProfileName InstanceType DefaultModel ModelMode BedrockRegion LokiWatermark EnableBedrockForm EnableSecurityHub EnableGuardDuty EnableInspector EnableAccessAnalyzer EnableConfigRecorder ExistingVpcId ExistingSubnetId RepoBranch KiroFromSecret TelegramBotTokenSecret TelegramUser Primary DailyDriver CodexModel) PARAM_VALUES=() # populated by build_deploy_params() # Per-pack default model (passed to CFN DefaultModel / bootstrap.sh --model). @@ -1980,6 +2120,7 @@ pack_default_model() { kiro-cli) echo "kiro-cloud" ;; # Kiro uses its own inference; value is informational only openclaw) echo "us.anthropic.claude-sonnet-4-6" ;; claude-code) echo "us.anthropic.claude-sonnet-4-6" ;; + troika) echo "us.anthropic.claude-sonnet-4-6" ;; # hermes depends on bedrockify; 'model' is the Bedrock id bedrockify # proxies to (NOT the Hermes-specific model ID, which is a separate # 'hermes-model' param on the pack). @@ -2020,6 +2161,9 @@ build_deploy_params() { "${KIRO_FROM_SECRET:-}" "${TELEGRAM_BOT_TOKEN_SECRET:-}" "${TELEGRAM_USER:-}" + "${TROIKA_PRIMARY:-openclaw}" + "${TROIKA_DAILY_DRIVER:-}" + "${TROIKA_CODEX_MODEL:-openai.gpt-5.5}" ) # Validate parallel arrays are in sync [[ ${#PARAM_CFN_NAMES[@]} -eq ${#PARAM_VALUES[@]} ]] \ @@ -2075,6 +2219,10 @@ show_summary() { summary+="Deploy via ${deploy_method_label}\n" summary+="Account ${ACCOUNT_ID}\n" summary+="Agent ${PACK_NAME}\n" + if [[ "${PACK_NAME:-}" == "troika" ]]; then + summary+="Primary ${TROIKA_PRIMARY:-openclaw}\n" + summary+="Daily driver ${TROIKA_DAILY_DRIVER:-${TROIKA_PRIMARY:-openclaw}}\n" + fi summary+="Profile ${PROFILE_NAME}\n" summary+="Instance ${INSTANCE_TYPE}\n" summary+="Region ${DEPLOY_REGION}\n" @@ -2842,6 +2990,63 @@ run_config_and_review() { # Rebuild params with telegram values now set build_deploy_params fi + + # Pack-specific: Troika primary + daily-driver selection + if [[ "${PACK_NAME:-}" == "troika" ]]; then + # ── Primary (first horse) ──────────────────────────────────────────────── + if [[ -z "${TROIKA_PRIMARY:-}" ]]; then + if [[ "$AUTO_YES" == true ]]; then + TROIKA_PRIMARY="openclaw" + ok "Primary (troika): ${TROIKA_PRIMARY} (default)" + else + local _tri_primary_choice + _gum_or_die _tri_primary_choice $GUM choose \ + --header "Which OpenClaw-family agent is your first horse?" \ + --selected "openclaw" \ + "openclaw" \ + "hermes" || _tri_primary_choice="openclaw" + TROIKA_PRIMARY="${_tri_primary_choice:-openclaw}" + ok "Primary: ${TROIKA_PRIMARY}" + fi + else + ok "Primary (troika): ${TROIKA_PRIMARY} (pre-selected)" + fi + + # ── Daily driver ───────────────────────────────────────────────────────── + if [[ -z "${TROIKA_DAILY_DRIVER:-}" ]]; then + if [[ "$AUTO_YES" == true ]]; then + TROIKA_DAILY_DRIVER="${TROIKA_PRIMARY}" + ok "Daily driver (troika): ${TROIKA_DAILY_DRIVER} (default = primary)" + else + local _tri_dd_choice + _gum_or_die _tri_dd_choice $GUM choose \ + --header "Which agent auto-launches when you SSM in?" \ + --selected "${TROIKA_PRIMARY}" \ + "${TROIKA_PRIMARY}" \ + "claude-code" \ + "codex-cli" \ + "none" || _tri_dd_choice="${TROIKA_PRIMARY}" + TROIKA_DAILY_DRIVER="${_tri_dd_choice:-${TROIKA_PRIMARY}}" + ok "Daily driver: ${TROIKA_DAILY_DRIVER}" + fi + else + ok "Daily driver (troika): ${TROIKA_DAILY_DRIVER} (pre-selected)" + fi + + # Cross-validate daily-driver vs primary (catches --daily-driver openclaw --primary hermes) + case "${TROIKA_DAILY_DRIVER}" in + "${TROIKA_PRIMARY}"|claude-code|codex-cli|none) : ;; + *) + fail "--daily-driver '${TROIKA_DAILY_DRIVER}' is not valid when --primary='${TROIKA_PRIMARY}'. Valid: ${TROIKA_PRIMARY} | claude-code | codex-cli | none" + ;; + esac + + # Codex model: default if not pre-set + TROIKA_CODEX_MODEL="${TROIKA_CODEX_MODEL:-openai.gpt-5.5}" + + # Rebuild params with troika values now set + build_deploy_params + fi show_summary || { # User chose "Change settings" → re-run in advanced mode with current values as preselects PRESELECT_PACK="$PACK_NAME" diff --git a/packs/registry.json b/packs/registry.json index 0b21d46..6b02948 100644 --- a/packs/registry.json +++ b/packs/registry.json @@ -102,6 +102,30 @@ "compatible_profiles": [ "builder" ] + }, + "troika": { + "type": "agent", + "description": "Troika — mixture of harnesses: OpenClaw/Hermes + Claude Code + Codex CLI on one instance, all via Bedrock", + "deps": [ + "bedrockify", + "openclaw", + "claude-code", + "codex-cli" + ], + "instance_type": "t4g.xlarge", + "root_volume_gb": 40, + "data_volume_gb": 80, + "default_model": "us.anthropic.claude-sonnet-4-6", + "ports": { + "gateway": 3001 + }, + "brain": true, + "claude_code": false, + "experimental": true, + "requires_openai_key": false, + "compatible_profiles": [ + "builder" + ] } } } diff --git a/packs/registry.yaml b/packs/registry.yaml index b89fdba..2cdf256 100644 --- a/packs/registry.yaml +++ b/packs/registry.yaml @@ -98,3 +98,24 @@ packs: requires_telegram_token: true compatible_profiles: - builder + + troika: + type: agent + description: "Troika — mixture of harnesses: OpenClaw/Hermes + Claude Code + Codex CLI on one instance, all via Bedrock" + deps: + - bedrockify + - openclaw + - claude-code + - codex-cli + instance_type: t4g.xlarge + root_volume_gb: 40 + data_volume_gb: 80 + default_model: "us.anthropic.claude-sonnet-4-6" + ports: + gateway: 3001 + brain: true + claude_code: false + experimental: true + requires_openai_key: false + compatible_profiles: + - builder diff --git a/packs/test-packs.sh b/packs/test-packs.sh index 1a5e717..e103ad2 100755 --- a/packs/test-packs.sh +++ b/packs/test-packs.sh @@ -301,6 +301,7 @@ declare -A PACK_RESOURCES PACK_RESOURCES[bedrockify]="bedrockify.service.tpl" PACK_RESOURCES[openclaw]="config-gen.py openclaw-gateway.service.tpl" PACK_RESOURCES[hermes]="hermes-config.yaml.tpl hermes-env.tpl" +PACK_RESOURCES[troika]="shell-profile.sh" for pack in "${PACKS[@]}"; do RESOURCES_DIR="${SCRIPT_DIR}/${pack}/resources" diff --git a/packs/troika/install.sh b/packs/troika/install.sh new file mode 100755 index 0000000..4bdc791 --- /dev/null +++ b/packs/troika/install.sh @@ -0,0 +1,445 @@ +#!/usr/bin/env bash +# packs/troika/install.sh — Wire OpenClaw/Hermes + Claude Code + Codex CLI (all via Bedrock) +# +# Runs AFTER deps: bedrockify → → claude-code → codex-cli +# (bootstrap substitutes openclaw dep with selected primary; §12a.1) +# +# Responsibilities: +# 1. Validate and persist the daily-driver selection +# 2. Rewire Codex CLI → Bedrock (merge model_provider + model; preserve other keys per §12.7) +# 3. Ensure AWS_REGION is exported for Codex (reads AWS_REGION, not AWS_DEFAULT_REGION) +# 4. Install the `agents` multi-harness helper to /usr/local/bin +# 5. Append auto-launch block to ~/.bashrc (idempotent, sentinel-guarded per §12.8) +# 6. Publish daily-driver to SSM for observability +# +# Idempotent: safe to re-run. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PACKS_REL_DIR="${SCRIPT_DIR}/.." # parent of all pack directories + +# shellcheck source=../common.sh +source "${SCRIPT_DIR}/../common.sh" + +# ── Constants ───────────────────────────────────────────────────────────────── +# §12.8: sentinel string used for idempotency guard on ~/.bashrc block +AUTOLAUNCH_SENTINEL="# --- troika-autolaunch ---" +AWS_REGION_SENTINEL="# --- troika-aws-region ---" +DAILY_DRIVER_FILE="${HOME}/.config/lowkey/daily-driver" + +# ── Defaults (from pack config, then CLI overrides) ─────────────────────────── +PACK_ARG_PRIMARY="$(pack_config_get "primary" "openclaw")" +PACK_ARG_DAILY_DRIVER="$(pack_config_get "daily-driver" "")" # empty → tracks primary +PACK_ARG_MODEL="$(pack_config_get model "us.anthropic.claude-sonnet-4-6")" +PACK_ARG_CODEX_MODEL="$(pack_config_get "codex-model" "openai.gpt-5.5")" +PACK_ARG_REGION="$(pack_config_get region "us-east-1")" + +# ── Help ────────────────────────────────────────────────────────────────────── +usage() { + cat < OpenClaw-family first horse (openclaw | hermes; default: openclaw) + --daily-driver Agent to auto-launch on SSM login (default: follows primary) + Valid: | claude-code | codex-cli | none + --model Bedrock model for Claude-family agents (default: us.anthropic.claude-sonnet-4-6) + --codex-model Bedrock Mantle model for Codex CLI (default: openai.gpt-5.5) + --region AWS region for Bedrock (default: us-east-1) + --help Show this help message + +Examples: + ./install.sh --daily-driver openclaw + ./install.sh --daily-driver claude-code --region eu-west-1 + ./install.sh --daily-driver none # disable auto-launch +EOF +} + +# ── Arg parsing ─────────────────────────────────────────────────────────────── +while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) usage; exit 0 ;; + --primary) PACK_ARG_PRIMARY="$2"; shift 2 ;; + --daily-driver) PACK_ARG_DAILY_DRIVER="$2"; shift 2 ;; + --model) PACK_ARG_MODEL="$2"; shift 2 ;; + --codex-model) PACK_ARG_CODEX_MODEL="$2"; shift 2 ;; + --region) PACK_ARG_REGION="$2"; shift 2 ;; + *) + # Unknown args: warn but don't fail (may be passed by bootstrap for other packs) + if [[ $# -gt 1 ]] && [[ "$2" != --* ]]; then + shift 2 + else + shift + fi + ;; + esac +done + +PRIMARY="${PACK_ARG_PRIMARY}" +MODEL="${PACK_ARG_MODEL}" +CODEX_MODEL="${PACK_ARG_CODEX_MODEL}" +REGION="${PACK_ARG_REGION}" + +# §12b.1: Single source of truth — read PACK_TUI_COMMAND from each pack's shell-profile. +# VALID_DRIVERS, autolaunch case, and agents helper ALL derive from _read_tui_cmd(). +_read_tui_cmd() { + local profile="$1" + [[ -f "$profile" ]] || { printf ''; return 0; } + local line cmd + line=$(grep -m1 '^PACK_TUI_COMMAND=' "$profile" 2>/dev/null || true) + [[ -z "$line" ]] && { printf ''; return 0; } + cmd="${line#PACK_TUI_COMMAND=}" + cmd="${cmd#\"}" # strip leading " + cmd="${cmd%\"}" # strip trailing " + printf '%s' "${cmd}" +} + +_PRIMARY_CMD="$(_read_tui_cmd "${PACKS_REL_DIR}/${PRIMARY}/resources/shell-profile.sh")" +_PRIMARY_CMD="${_PRIMARY_CMD:-${PRIMARY}}" +_CLAUDE_CMD="$(_read_tui_cmd "${PACKS_REL_DIR}/claude-code/resources/shell-profile.sh")" +_CLAUDE_CMD="${_CLAUDE_CMD:-claude}" +_CODEX_CMD="$(_read_tui_cmd "${PACKS_REL_DIR}/codex-cli/resources/shell-profile.sh")" +_CODEX_CMD="${_CODEX_CMD:-codex}" + +_PRIMARY_BIN="${_PRIMARY_CMD%% *}" +_CLAUDE_BIN="${_CLAUDE_CMD%% *}" +_CODEX_BIN="${_CODEX_CMD%% *}" + +# Derive VALID_DRIVERS from metadata — §12a.2, §12b.1 (no hardcoded openclaw) +VALID_DRIVERS=("${PRIMARY}" claude-code codex-cli none) + +# DAILY_DRIVER: default tracks selected primary — §12a.2 +DAILY_DRIVER="${PACK_ARG_DAILY_DRIVER:-${PRIMARY}}" + +pack_banner "troika" +log "primary=${PRIMARY} daily-driver=${DAILY_DRIVER} model=${MODEL} codex-model=${CODEX_MODEL} region=${REGION}" + +# ── Validate daily-driver ───────────────────────────────────────────────────── +step "Validating primary" +case "${PRIMARY}" in + openclaw|hermes) ok "primary is valid: ${PRIMARY}" ;; + *) fail "Invalid primary '${PRIMARY}'. Must be openclaw or hermes." ;; +esac + +step "Validating daily-driver" + +_valid_driver=0 +for _v in "${VALID_DRIVERS[@]}"; do + [[ "$DAILY_DRIVER" == "$_v" ]] && _valid_driver=1 && break +done + +if [[ "$_valid_driver" -eq 0 ]]; then + fail "Invalid daily-driver '${DAILY_DRIVER}'. Valid set for primary=${PRIMARY}: ${VALID_DRIVERS[*]}" +fi +ok "daily-driver is valid: ${DAILY_DRIVER} (primary=${PRIMARY})" + +# ── Persist daily-driver ────────────────────────────────────────────────────── +step "Persisting daily-driver config" + +mkdir -p "$(dirname "${DAILY_DRIVER_FILE}")" +printf '%s\n' "${DAILY_DRIVER}" > "${DAILY_DRIVER_FILE}" +chmod 644 "${DAILY_DRIVER_FILE}" +ok "daily-driver written to ${DAILY_DRIVER_FILE}" + +# ── Rewire Codex CLI → Bedrock ──────────────────────────────────────────────── +# §12.7: MERGE only model_provider + model keys; preserve approval_policy / sandbox_mode +# written by the codex-cli dep. Never replace the whole file. +step "Rewiring Codex CLI to Bedrock (merge model_provider + model)" + +CODEX_HOME="${HOME}/.codex" +CODEX_CONFIG="${CODEX_HOME}/config.toml" +mkdir -p "${CODEX_HOME}" + +CODEX_MODEL="${CODEX_MODEL}" python3 - "${CODEX_CONFIG}" <<'PYEOF' +import os, sys, re + +path = sys.argv[1] +cmodel = os.environ.get("CODEX_MODEL", "openai.gpt-5.5") + +# TOML-escape a basic-string value (newlines in values are illegal) +if "\n" in cmodel or "\r" in cmodel: + sys.stderr.write(f"error: codex-model contains newline: {cmodel!r}\n") + sys.exit(2) + +def toml_escape(s): + out = [] + for ch in s: + code = ord(ch) + if ch == "\\": out.append("\\\\") + elif ch == '"': out.append('\\"') + elif ch == "\t": out.append("\\t") + elif ch == "\b": out.append("\\b") + elif ch == "\f": out.append("\\f") + elif code < 0x20 or code == 0x7f: + out.append(f"\\u{code:04X}") + else: + out.append(ch) + return "".join(out) + +escaped = toml_escape(cmodel) + +# Sentinel strings +TRI_START = "# >>> managed by lowkey troika pack (bedrock) >>>" +TRI_END = "# <<< managed by lowkey troika pack (bedrock) <<<" +CC_START = "# >>> managed by lowkey codex-cli pack >>>" +CC_END = "# <<< managed by lowkey codex-cli pack <<<" + +tri_block = ( + TRI_START + "\n" + "# Bedrock provider — managed by packs/troika/install.sh.\n" + "# Edits inside this block will be overwritten on pack re-run.\n" + "model_provider = \"amazon-bedrock\"\n" + f"model = \"{escaped}\"\n" + + TRI_END + "\n" +) + +existing = "" +if os.path.exists(path): + with open(path) as f: + existing = f.read() + +# 1. Remove any previous troika block (idempotency) +tri_re = re.compile( + re.escape(TRI_START) + r".*?" + re.escape(TRI_END) + r"\n?", + re.DOTALL, +) +text = tri_re.sub("", existing, count=1) + +# 2. Remove `model = "..."` from the codex-cli managed block to avoid TOML +# duplicate-key errors. approval_policy and sandbox_mode are preserved. +def strip_model_from_cc_block(s): + cc_re = re.compile( + re.escape(CC_START) + r"(.*?)" + re.escape(CC_END), + re.DOTALL, + ) + def _replace(m): + content = m.group(1) + # Remove model = "..." lines (handles escaped quotes in value) + content = re.sub(r'\nmodel\s*=\s*"(?:[^"\\]|\\.)*"[ \t]*', "", content) + return CC_START + content + CC_END + return cc_re.sub(_replace, s, count=1) + +text = strip_model_from_cc_block(text) + +# 3. Place troika block at the TOP (bare top-level keys before any [table]) +stripped = text.lstrip("\n") +new = (tri_block + "\n" + stripped) if stripped else tri_block +if not new.endswith("\n"): + new += "\n" + +with open(path, "w") as f: + f.write(new) +print(f"[ok] Bedrock config merged: {path}") +PYEOF + +chmod 600 "${CODEX_CONFIG}" +ok "Codex Bedrock config merged: ${CODEX_CONFIG}" + +# ── Export AWS_REGION for Codex ─────────────────────────────────────────────── +# Codex CLI reads AWS_REGION (not only AWS_DEFAULT_REGION). Bootstrap already +# exports AWS_DEFAULT_REGION; we export both so Codex finds the right region. +step "Ensuring AWS_REGION exported in .bashrc" + +BASHRC="${HOME}/.bashrc" +# Sentinel-replace: remove stale block before writing fresh one (handles --region changes on re-run) +# Safety: only sed-delete if exactly 2 sentinel lines exist (complete block); single sentinel = corrupted, skip +if grep -qF "${AWS_REGION_SENTINEL}" "${BASHRC}" 2>/dev/null; then + _sentinel_count=$(grep -cF "${AWS_REGION_SENTINEL}" "${BASHRC}" 2>/dev/null || true) + if [[ "${_sentinel_count}" -ge 2 ]]; then + sed -i "/${AWS_REGION_SENTINEL//\//\\/}/,/${AWS_REGION_SENTINEL//\//\\/}/d" "${BASHRC}" + else + warn "Incomplete AWS_REGION sentinel in ${BASHRC} (${_sentinel_count} line) — removing single line" + sed -i "/${AWS_REGION_SENTINEL//\//\\/}/d" "${BASHRC}" + fi +fi +printf '\n%s\n# Troika: Codex CLI reads AWS_REGION (not only AWS_DEFAULT_REGION)\nexport AWS_REGION="%s"\n%s\n' \ + "${AWS_REGION_SENTINEL}" "${REGION}" "${AWS_REGION_SENTINEL}" >> "${BASHRC}" +ok "AWS_REGION=\"${REGION}\" written to ${BASHRC}" + +# ── Install agents helper ───────────────────────────────────────────────────── +step "Installing agents helper" + +AGENTS_BIN="/usr/local/bin/agents" +AGENTS_TMP="$(mktemp)" +# Ensure temp file is cleaned up even on error +# shellcheck disable=SC2064 +trap "rm -f '${AGENTS_TMP}'" EXIT + +cat > "${AGENTS_TMP}" <" >&2 + exit 1 + fi + if ! _valid_driver "\$new_driver"; then + echo "Unknown driver: \${new_driver} (valid: ${PRIMARY} | claude-code | codex-cli | none)" >&2 + exit 1 + fi + mkdir -p "\$(dirname "\${DAILY_DRIVER_FILE}")" + printf '%s\n' "\${new_driver}" > "\${DAILY_DRIVER_FILE}" + echo "Daily driver set to: \${new_driver}" + ;; + + review) + echo "agents review: not yet implemented (stretch goal — Phase 2+)" >&2 + exit 1 + ;; + + ""|status) + dd="(none)" + [[ -f "\${DAILY_DRIVER_FILE}" ]] && dd="\$(cat "\${DAILY_DRIVER_FILE}")" + printf "\n=== Troika — Agent Status ===\n\n" + for _bin in "${_PRIMARY_BIN}" "${_CLAUDE_BIN}" "${_CODEX_BIN}"; do + if command -v "\${_bin}" &>/dev/null; then + _ver="\$("\${_bin}" --version 2>/dev/null | head -1 || echo unknown)" + printf " \033[0;32m✓\033[0m %-12s %s\n" "\${_bin}:" "\${_ver}" + else + printf " \033[0;31m✗\033[0m %-12s not found\n" "\${_bin}:" + fi + done + unset _bin _ver + printf "\n Daily driver: %s\n\n" "\${dd}" + printf " agents driver → switch daily driver\n" + printf " LOKI_NO_TUI=1 → suppress auto-launch for this session\n\n" + ;; + + *) + printf 'Usage: agents [status | driver ]\n\n' + printf ' status Show installed agents and current daily driver\n' + printf ' driver Set daily driver (%s | claude-code | codex-cli | none)\n\n' "${PRIMARY}" + printf 'Daily driver values:\n' + printf ' %-20s Auto-launch: %s\n' "${PRIMARY}" "${_PRIMARY_CMD}" + printf ' %-20s Auto-launch: %s\n' "claude-code" "${_CLAUDE_CMD}" + printf ' %-20s Auto-launch: %s\n' "codex-cli" "${_CODEX_CMD}" + printf ' %-20s Disable auto-launch\n' "none" + printf '\nEscape hatches:\n' + printf ' agents driver none Disable auto-launch permanently\n' + printf ' LOKI_NO_TUI=1 Disable auto-launch for this session\n\n' + exit 1 + ;; +esac +AGENTS_SCRIPT + +if [[ -w "$(dirname "${AGENTS_BIN}")" ]]; then + cp "${AGENTS_TMP}" "${AGENTS_BIN}" + chmod 755 "${AGENTS_BIN}" + ok "agents helper installed: ${AGENTS_BIN}" +elif sudo -n true 2>/dev/null; then + sudo cp "${AGENTS_TMP}" "${AGENTS_BIN}" + sudo chmod 755 "${AGENTS_BIN}" + ok "agents helper installed (sudo): ${AGENTS_BIN}" +else + warn "Cannot write to $(dirname "${AGENTS_BIN}") — falling back to ~/.local/bin/agents" + AGENTS_BIN="${HOME}/.local/bin/agents" + mkdir -p "$(dirname "${AGENTS_BIN}")" + cp "${AGENTS_TMP}" "${AGENTS_BIN}" + chmod 755 "${AGENTS_BIN}" + ok "agents helper installed: ${AGENTS_BIN}" +fi +rm -f "${AGENTS_TMP}" +trap - EXIT + +# ── Append auto-launch block to .bashrc (idempotent) ───────────────────────── +# §12.8: sentinel-replacement idempotency — grep guard prevents double-append. +# Safety: [[ -t 0 ]] + interactive guard prevents firing on non-tty SSM sessions, +# port-forwarding, scp-over-ssm, or aws ssm send-command invocations. +# Escape hatches: Ctrl+C during grace period; LOKI_NO_TUI=1; agents driver none. +step "Appending auto-launch block to .bashrc" + +BASHRC="${HOME}/.bashrc" + +# Sentinel-replace: remove stale block before writing fresh one (handles re-runs with different --primary) +# Safety: only sed range-delete if exactly 2 sentinel lines exist (complete block); single = corrupted +if grep -qF "${AUTOLAUNCH_SENTINEL}" "${BASHRC}" 2>/dev/null; then + _sentinel_count=$(grep -cF "${AUTOLAUNCH_SENTINEL}" "${BASHRC}" 2>/dev/null || true) + if [[ "${_sentinel_count}" -ge 2 ]]; then + sed -i "/${AUTOLAUNCH_SENTINEL//\//\\/}/,/${AUTOLAUNCH_SENTINEL//\//\\/}/d" "${BASHRC}" + log "Removed stale auto-launch block from ${BASHRC} (will rewrite)" + else + warn "Incomplete autolaunch sentinel in ${BASHRC} (${_sentinel_count} line) — removing single line" + sed -i "/${AUTOLAUNCH_SENTINEL//\//\\/}/d" "${BASHRC}" + fi +fi + +# Write fresh autolaunch block +# Note: variable references inside the AUTOLAUNCH heredoc are escaped (\$) +# so they expand at runtime (when .bashrc is sourced), not at install time. +cat >> "${BASHRC}" </dev/null 2>&1; then + ok "openclaw-gateway service is enabled (primary=openclaw)" + else + warn "openclaw-gateway service not found (primary=openclaw)" + fi +fi + +# ── Write SSM parameter for observability ──────────────────────────────────── +step "Writing SSM parameter /loki/daily-driver" + +aws ssm put-parameter \ + --name "/loki/daily-driver" \ + --value "${DAILY_DRIVER}" \ + --type String \ + --overwrite \ + --region "${REGION}" \ + >/dev/null 2>&1 \ + && ok "SSM parameter /loki/daily-driver = ${DAILY_DRIVER}" \ + || warn "SSM parameter write failed (non-fatal — check IAM role)" + +# ── Done ────────────────────────────────────────────────────────────────────── +write_done_marker "troika" +printf "\n[PACK:troika] INSTALLED — daily driver: %s\n" "${DAILY_DRIVER}" +printf " %-12s %s\n" "${PRIMARY}:" "${_PRIMARY_CMD}" +printf " %-12s %s\n" "claude-code:" "${_CLAUDE_CMD}" +printf " %-12s %s\n" "codex-cli:" "${_CODEX_CMD}" +printf " switch: agents driver \n\n" diff --git a/packs/troika/manifest.yaml b/packs/troika/manifest.yaml new file mode 100644 index 0000000..1c5dc66 --- /dev/null +++ b/packs/troika/manifest.yaml @@ -0,0 +1,49 @@ +name: troika +version: "1.0.0" +type: agent +description: "Troika — mixture of harnesses: OpenClaw/Hermes + Claude Code + Codex CLI on one instance, all via Bedrock" + +deps: + - bedrockify + - openclaw + - claude-code + - codex-cli + +requirements: + arch: + - arm64 + - amd64 + os: + - al2023 + - ubuntu2204 + min_instance_type: t4g.xlarge + +params: + - name: primary + description: "OpenClaw-family first horse (openclaw | hermes; default openclaw)" + default: "openclaw" + - name: daily-driver + description: "Agent to auto-launch on SSM login (primary | claude-code | codex-cli | none; default follows primary)" + default: "" + - name: model + description: "Bedrock model ID for Claude-family agents (OpenClaw + Claude Code)" + default: "us.anthropic.claude-sonnet-4-6" + - name: codex-model + description: "Bedrock Mantle model ID for Codex CLI" + default: "openai.gpt-5.5" + - name: region + description: "AWS region for Bedrock" + default: "us-east-1" + +health_check: + command: "openclaw --version && claude --version && codex --version" + timeout: 30 + +provides: + commands: + - openclaw + - claude + - codex + - agents + services: + - openclaw-gateway diff --git a/packs/troika/resources/shell-profile.sh b/packs/troika/resources/shell-profile.sh new file mode 100644 index 0000000..1ee82e2 --- /dev/null +++ b/packs/troika/resources/shell-profile.sh @@ -0,0 +1,26 @@ +# shellcheck shell=bash +# Troika shell profile — sourced by bootstrap for .bashrc and /etc/profile.d +# Covers all three harnesses: OpenClaw, Claude Code, Codex CLI +# shellcheck disable=SC2034 # Variables are exported/used by the bootstrap dispatcher +# NOTE: PACK_TUI_COMMAND intentionally set to "bash --login" because troika's +# .bashrc autolaunch block handles daily-driver dispatch dynamically. +# Using a hardcoded binary here would break --primary hermes (openclaw not installed) +# or --daily-driver codex-cli (wrong binary in SSM session document). +PACK_TUI_COMMAND="bash --login" + +PACK_ALIASES=' +alias loki="openclaw" +alias lt="openclaw tui" +alias cc="claude" +alias cx="codex" +' + +PACK_BANNER_NAME="Troika Agent Environment" +PACK_BANNER_EMOJI="🔱" +PACK_BANNER_COMMANDS=' + openclaw tui → Launch OpenClaw TUI (default daily driver) + claude → Launch Claude Code + codex → Launch Codex CLI + agents → Show all agent status + daily driver + agents driver → Switch daily driver (openclaw|claude-code|codex-cli|none) +' diff --git a/packs/troika/test.sh b/packs/troika/test.sh new file mode 100755 index 0000000..b2d8476 --- /dev/null +++ b/packs/troika/test.sh @@ -0,0 +1,396 @@ +#!/usr/bin/env bash +# packs/troika/test.sh — contract tests for the Troika pack +# +# Tests (all offline, no AWS, no live installs): +# 1. manifest.yaml structure and required keys +# 2. install.sh: syntax, interface, conventions +# 3. daily-driver validation (valid values accepted, invalid rejected) +# 4. .bashrc autolaunch block idempotency (sentinel-guarded) +# 5. resources/shell-profile.sh required variables +# 6. Registry entries consistent (yaml + json) +# +# Usage: bash packs/troika/test.sh +# Exit: 0 if all tests pass, 1 otherwise. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PACK_DIR="${SCRIPT_DIR}" +PACKS_DIR="${SCRIPT_DIR}/.." +INSTALL="${PACK_DIR}/install.sh" +MANIFEST="${PACK_DIR}/manifest.yaml" +PROFILE="${PACK_DIR}/resources/shell-profile.sh" + +# ── Test harness ────────────────────────────────────────────────────────────── +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[0;33m' +CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m' +PASS=0; FAIL=0; SKIP=0 + +pass() { printf "${GREEN} ✓${NC} %s\n" "$1"; PASS=$((PASS+1)); } +fail() { printf "${RED} ✗${NC} %s\n" "$1"; FAIL=$((FAIL+1)); } +skip() { printf "${YELLOW} ○${NC} %s (skipped)\n" "$1"; SKIP=$((SKIP+1)); } +header() { printf "\n${BOLD}${CYAN}%s${NC}\n" "$1"; } + +# ── 1. manifest.yaml ────────────────────────────────────────────────────────── +header "Test: manifest.yaml" + +[[ -f "${MANIFEST}" ]] && pass "manifest.yaml exists" || { fail "manifest.yaml missing"; } + +if command -v python3 &>/dev/null && python3 -c "import yaml" 2>/dev/null; then + if python3 - "${MANIFEST}" <<'PY' ; then +import sys, yaml +data = yaml.safe_load(open(sys.argv[1])) +required = ["name","version","type","description","deps","params","health_check","provides"] +missing = [k for k in required if k not in data] +if missing: + sys.exit(f"missing keys: {missing}") +# name matches directory +if data.get("name") != "troika": + sys.exit(f"name mismatch: {data.get('name')!r} != 'troika'") +# type is agent +if data.get("type") != "agent": + sys.exit(f"type mismatch: {data.get('type')!r}") +# deps in correct order +expected_deps = ["bedrockify", "openclaw", "claude-code", "codex-cli"] +if data.get("deps") != expected_deps: + sys.exit(f"deps wrong: {data.get('deps')} != {expected_deps}") +# required params present +param_names = [p["name"] for p in data.get("params", [])] +for req in ["primary", "daily-driver", "model", "codex-model", "region"]: + if req not in param_names: + sys.exit(f"missing param: {req}") +# provides commands includes all four +cmds = data.get("provides", {}).get("commands", []) +for cmd in ["openclaw", "claude", "codex", "agents"]: + if cmd not in cmds: + sys.exit(f"missing command in provides: {cmd}") +print("OK") +PY + pass "manifest.yaml: valid YAML, required keys, deps order, params, provides" + else + fail "manifest.yaml: structure invalid (see output above)" + fi + + # primary param: present with default=openclaw + if python3 - "${MANIFEST}" <<'PY' 2>/dev/null; then +import sys, yaml +data = yaml.safe_load(open(sys.argv[1])) +params = {p["name"]: p for p in data.get("params", [])} +pr = params.get("primary", {}) +assert "primary" in params, "primary param missing" +assert pr.get("default") == "openclaw", f"primary default={pr.get('default')!r}" +print("OK") +PY + pass "manifest.yaml: primary param present with default openclaw" + else + fail "manifest.yaml: primary param missing or default not openclaw" + fi + + # daily-driver default is empty string (tracks primary — §12a.2) + if python3 - "${MANIFEST}" <<'PY' 2>/dev/null; then +import sys, yaml +data = yaml.safe_load(open(sys.argv[1])) +params = {p["name"]: p for p in data.get("params", [])} +dd = params.get("daily-driver", {}) +assert dd.get("default") in ("", None), f"daily-driver default should be empty, got {dd.get('default')!r}" +print("OK") +PY + pass "manifest.yaml: daily-driver default is empty (tracks primary — §12a.2)" + else + fail "manifest.yaml: daily-driver default is not empty (should track primary)" + fi + + # experimental: true + if python3 - "${MANIFEST}" <<'PY' 2>/dev/null; then +import sys, yaml +data = yaml.safe_load(open(sys.argv[1])) +# experimental is not a manifest field (it's registry-only), just verify type=agent +assert data.get("type") == "agent" +print("OK") +PY + pass "manifest.yaml: type=agent" + else + fail "manifest.yaml: type is not agent" + fi +else + skip "manifest.yaml YAML structure tests: python3 or pyyaml not available" +fi + +# ── 2. install.sh ───────────────────────────────────────────────────────────── +header "Test: install.sh" + +[[ -f "${INSTALL}" ]] && pass "install.sh exists" || fail "install.sh missing" +[[ -x "${INSTALL}" ]] && pass "install.sh is executable" || fail "install.sh is NOT executable (chmod +x needed)" + +# Shebang +_shebang="$(head -1 "${INSTALL}")" +[[ "${_shebang}" == "#!/usr/bin/env bash" ]] && pass "install.sh: correct shebang" || fail "install.sh: wrong shebang: ${_shebang}" + +# Bash syntax check +bash -n "${INSTALL}" 2>/dev/null && pass "install.sh: bash -n clean (no syntax errors)" || fail "install.sh: bash syntax errors" + +# Sources common.sh +grep -q 'source.*common\.sh' "${INSTALL}" && pass "install.sh: sources common.sh" || fail "install.sh: does not source common.sh" + +# set -euo pipefail +grep -q 'set -euo pipefail' "${INSTALL}" && pass "install.sh: set -euo pipefail" || fail "install.sh: missing set -euo pipefail" + +# Writes done marker for 'troika' +grep -q 'write_done_marker.*troika' "${INSTALL}" && pass "install.sh: writes done marker for 'troika'" || fail "install.sh: missing write_done_marker troika" + +# --help exits 0 and produces useful output +HELP_OUT="$(bash "${INSTALL}" --help 2>&1)" && HELP_RC=0 || HELP_RC=$? +[[ "${HELP_RC}" -eq 0 ]] && pass "install.sh: --help exits 0" || fail "install.sh: --help exits ${HELP_RC}" +[[ -n "${HELP_OUT}" ]] && pass "install.sh: --help produces output" || fail "install.sh: --help produces no output" + +for flag in --daily-driver --model --codex-model --region --help; do + printf '%s' "${HELP_OUT}" | grep -q -- "${flag}" \ + && pass "install.sh: --help mentions ${flag}" \ + || fail "install.sh: --help missing ${flag}" +done + +# VALID_DRIVERS derives from PRIMARY metadata (§12b.1 — not hardcoded) +_valid_drivers_line="$(grep 'VALID_DRIVERS=(' "${INSTALL}" 2>/dev/null || true)" +for drv in claude-code codex-cli none; do + printf '%s' "${_valid_drivers_line}" | grep -q "${drv}" \ + && pass "install.sh: '${drv}' in VALID_DRIVERS" \ + || fail "install.sh: '${drv}' missing from VALID_DRIVERS" +done +# §12b.1: at least one VALID_DRIVERS line should reference PRIMARY dynamically +if grep -q 'VALID_DRIVERS=.*PRIMARY' "${INSTALL}"; then + pass "install.sh: VALID_DRIVERS uses PRIMARY slot — §12b.1" +else + fail "install.sh: VALID_DRIVERS does not use PRIMARY slot" +fi + +# Validation logic present (rejects invalid drivers) +grep -q '_valid_driver' "${INSTALL}" \ + && pass "install.sh: has daily-driver validation logic" \ + || fail "install.sh: missing daily-driver validation logic" + +# §12b.1 litmus: _read_tui_cmd single lookup helper +if grep -q '_read_tui_cmd' "${INSTALL}"; then + pass "install.sh: _read_tui_cmd lookup helper present — §12b.1" +else + fail "install.sh: _read_tui_cmd missing — required by §12b.1" +fi + +# --primary flag in arg parsing +if grep -q -- '--primary' "${INSTALL}"; then + pass "install.sh: --primary arg parsed" +else + fail "install.sh: --primary not parsed" +fi + +# primary read from pack_config +if grep -q 'pack_config_get.*primary' "${INSTALL}"; then + pass "install.sh: primary read from PACK_CONFIG" +else + fail "install.sh: primary not read from PACK_CONFIG" +fi + +# DAILY_DRIVER defaults to PRIMARY — §12a.2 +if grep -q 'DAILY_DRIVER.*:-.*PRIMARY' "${INSTALL}"; then + pass "install.sh: DAILY_DRIVER defaults to PRIMARY — §12a.2" +else + fail "install.sh: DAILY_DRIVER does not default to PRIMARY" +fi + +# openclaw-gateway guard — §12a.6 +if grep -q 'PRIMARY.*openclaw' "${INSTALL}"; then + pass "install.sh: openclaw-gateway gated on primary=openclaw — §12a.6" +else + fail "install.sh: missing openclaw-gateway primary=openclaw guard" +fi + +# ── 3. daily-driver validation (live behaviour) ─────────────────────────────── +header "Test: daily-driver validation" + +# Run install.sh with a bad driver — must exit non-zero +if PACK_CONFIG=/dev/null bash "${INSTALL}" --daily-driver "INVALID_DRIVER_XYZ" >/dev/null 2>&1; then + fail "install.sh: accepted invalid daily-driver 'INVALID_DRIVER_XYZ' (should reject)" +else + pass "install.sh: rejects invalid daily-driver 'INVALID_DRIVER_XYZ'" +fi + +# Invalid primary must also be rejected +if PACK_CONFIG=/dev/null bash "${INSTALL}" --primary "INVALID_PRIMARY_XYZ" >/dev/null 2>&1; then + fail "install.sh: accepted invalid primary 'INVALID_PRIMARY_XYZ' (should reject)" +else + pass "install.sh: rejects invalid primary 'INVALID_PRIMARY_XYZ'" +fi + +# 'none' must be accepted (§12.8 requirement) — verified via VALID_DRIVERS array +_vd_line="$(grep 'VALID_DRIVERS=(' "${INSTALL}" 2>/dev/null || true)" +printf '%s' "${_vd_line}" | grep -q 'none' \ + && pass "install.sh: 'none' is explicitly listed in VALID_DRIVERS" \ + || fail "install.sh: 'none' is not in VALID_DRIVERS" + +# ── 4. .bashrc autolaunch idempotency ──────────────────────────────────────── +header "Test: .bashrc autolaunch block idempotency" + + +# Sentinel variable is assigned in install.sh, and referenced at least twice +# (once in grep guard + twice in the heredoc = 3+ occurrences of AUTOLAUNCH_SENTINEL) +_sentinel_var_count="$(grep -c 'AUTOLAUNCH_SENTINEL' "${INSTALL}" 2>/dev/null || true)" +[[ "${_sentinel_var_count}" -ge 3 ]] \ + && pass "install.sh: AUTOLAUNCH_SENTINEL referenced ${_sentinel_var_count} times (assign + grep guard + heredoc start + end)" \ + || fail "install.sh: AUTOLAUNCH_SENTINEL appears only ${_sentinel_var_count} time(s) — expected ≥3" + +# Idempotency guard (grep -qF sentinel before appending) +grep -qE 'grep.*AUTOLAUNCH_SENTINEL|grep.*troika-autolaunch' "${INSTALL}" \ + && pass "install.sh: has sentinel-based idempotency guard" \ + || fail "install.sh: missing sentinel idempotency guard" + +# Test actual idempotency: replicate the sentinel-guarded append logic +_SENTINEL="# --- troika-autolaunch ---" +_tmp_bashrc="$(mktemp)" + +# Simulate the append logic (mirrors what install.sh does) +_append_once() { + local bashrc="$1" sentinel="$2" + if grep -qF "${sentinel}" "${bashrc}" 2>/dev/null; then + return 0 + fi + printf '\n%s\n# test block content\n%s\n' "${sentinel}" "${sentinel}" >> "${bashrc}" +} + +_append_once "${_tmp_bashrc}" "${_SENTINEL}" +_append_once "${_tmp_bashrc}" "${_SENTINEL}" +_append_once "${_tmp_bashrc}" "${_SENTINEL}" + +_count="$(grep -cF "${_SENTINEL}" "${_tmp_bashrc}" || true)" +rm -f "${_tmp_bashrc}" + +[[ "${_count}" -eq 2 ]] \ + && pass "autolaunch block: idempotent (sentinel appears exactly 2 times after 3 append attempts)" \ + || fail "autolaunch block: NOT idempotent (sentinel count: ${_count}, expected 2)" + +# Safety guards present: interactive check + tty check + LOKI_NO_TUI escape hatch +grep -q 'LOKI_NO_TUI' "${INSTALL}" \ + && pass "install.sh: LOKI_NO_TUI escape hatch present" \ + || fail "install.sh: missing LOKI_NO_TUI escape hatch" + +grep -q 'LOKI_TUI_LAUNCHED' "${INSTALL}" \ + && pass "install.sh: LOKI_TUI_LAUNCHED nested-relaunch guard present" \ + || fail "install.sh: missing LOKI_TUI_LAUNCHED guard" + +grep -qE '\[\[ .*-t 0.*\]\]|\[\[.*-t 0\]\]' "${INSTALL}" \ + && pass "install.sh: tty check [[ -t 0 ]] present (prevents non-tty auto-launch)" \ + || fail "install.sh: missing [[ -t 0 ]] guard" + +# 'none' case in auto-launch switch statement (§12.8) +grep -qE 'none.*\).*:' "${INSTALL}" || grep -q "none) : ;;" "${INSTALL}" \ + && pass "install.sh: 'none' daily-driver no-ops in auto-launch switch" \ + || fail "install.sh: 'none' case missing from auto-launch switch" + +# ── 5. resources/shell-profile.sh ──────────────────────────────────────────── +header "Test: resources/shell-profile.sh" + +[[ -f "${PROFILE}" ]] && pass "resources/shell-profile.sh exists" || fail "resources/shell-profile.sh missing" + +for var in PACK_ALIASES PACK_BANNER_NAME PACK_BANNER_EMOJI PACK_BANNER_COMMANDS; do + grep -q "^${var}=" "${PROFILE}" 2>/dev/null \ + && pass "shell-profile.sh: defines ${var}" \ + || fail "shell-profile.sh: missing ${var}" +done + +# Sources cleanly under set -euo pipefail +bash -c "set -euo pipefail; source '${PROFILE}'" 2>/dev/null \ + && pass "shell-profile.sh: sources cleanly under set -euo pipefail" \ + || fail "shell-profile.sh: errors when sourced with set -euo pipefail" + +# PACK_BANNER_NAME mentions Troika +grep -qi 'troika' "${PROFILE}" \ + && pass "shell-profile.sh: PACK_BANNER_NAME mentions Troika" \ + || fail "shell-profile.sh: PACK_BANNER_NAME does not mention Troika" + +# Banner mentions all three agents +for agent_cmd in openclaw claude codex; do + grep -q "${agent_cmd}" "${PROFILE}" \ + && pass "shell-profile.sh: mentions '${agent_cmd}'" \ + || fail "shell-profile.sh: missing '${agent_cmd}'" +done + +# ── 6. Registry consistency ─────────────────────────────────────────────────── +header "Test: registry consistency" + +REGISTRY_YAML="${PACKS_DIR}/registry.yaml" +REGISTRY_JSON="${PACKS_DIR}/registry.json" + +# YAML +if python3 - "${REGISTRY_YAML}" <<'PY' 2>/dev/null; then +import sys, yaml +data = yaml.safe_load(open(sys.argv[1])) +t = data.get("packs", {}).get("troika", {}) +assert t, "troika not found in registry.yaml" +assert t.get("type") == "agent", f"type={t.get('type')!r}" +assert t.get("instance_type") == "t4g.xlarge", f"instance_type={t.get('instance_type')!r}" +assert t.get("experimental") is True, f"experimental={t.get('experimental')!r}" +assert t.get("requires_openai_key") is False, f"requires_openai_key={t.get('requires_openai_key')!r}" +assert t.get("brain") is True, f"brain={t.get('brain')!r}" +expected_deps = ["bedrockify", "openclaw", "claude-code", "codex-cli"] +assert t.get("deps") == expected_deps, f"deps={t.get('deps')!r}" +assert "builder" in t.get("compatible_profiles", []), "builder not in compatible_profiles" +print("OK") +PY + pass "registry.yaml: troika entry has correct fields" \ + || fail "registry.yaml: troika entry has incorrect fields" +fi + +# JSON +if [[ -f "${REGISTRY_JSON}" ]] && command -v jq &>/dev/null; then + jq -e '.packs.troika' "${REGISTRY_JSON}" >/dev/null 2>&1 \ + && pass "registry.json: troika entry present" \ + || fail "registry.json: troika entry missing" + + _type="$(jq -r '.packs.troika.type' "${REGISTRY_JSON}")" + [[ "${_type}" == "agent" ]] && pass "registry.json: troika type=agent" || fail "registry.json: troika type=${_type}" + + _experimental="$(jq -r '.packs.troika.experimental' "${REGISTRY_JSON}")" + [[ "${_experimental}" == "true" ]] && pass "registry.json: troika experimental=true" || fail "registry.json: troika experimental=${_experimental}" + + _req_oai="$(jq -r '.packs.troika.requires_openai_key' "${REGISTRY_JSON}")" + [[ "${_req_oai}" == "false" ]] && pass "registry.json: troika requires_openai_key=false" || fail "registry.json: troika requires_openai_key=${_req_oai}" + + _itype="$(jq -r '.packs.troika.instance_type' "${REGISTRY_JSON}")" + [[ "${_itype}" == "t4g.xlarge" ]] && pass "registry.json: troika instance_type=t4g.xlarge" || fail "registry.json: troika instance_type=${_itype}" + + _model="$(jq -r '.packs.troika.default_model' "${REGISTRY_JSON}")" + [[ "${_model}" == "us.anthropic.claude-sonnet-4-6" ]] && pass "registry.json: troika default_model correct" || fail "registry.json: troika default_model=${_model}" + + # Deps array + _deps="$(jq -c '.packs.troika.deps' "${REGISTRY_JSON}")" + [[ "${_deps}" == '["bedrockify","openclaw","claude-code","codex-cli"]' ]] \ + && pass "registry.json: troika deps order correct (bedrockify→openclaw→claude-code→codex-cli)" \ + || fail "registry.json: troika deps wrong: ${_deps}" +else + skip "registry.json tests: jq not available or file missing" +fi + +# ── shellcheck (if available) ───────────────────────────────────────────────── +header "Test: shellcheck" + +if command -v shellcheck &>/dev/null; then + shellcheck -S warning "${INSTALL}" 2>/dev/null \ + && pass "shellcheck: install.sh clean (no warnings at 'warning' level)" \ + || fail "shellcheck: install.sh has warnings/errors" + shellcheck -S warning "${PROFILE}" 2>/dev/null \ + && pass "shellcheck: shell-profile.sh clean" \ + || fail "shellcheck: shell-profile.sh has warnings/errors" +else + skip "shellcheck: not installed" +fi + +# ── Summary ─────────────────────────────────────────────────────────────────── +printf "\n${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n" +printf "${BOLD} Troika Pack Test Results${NC}\n" +printf "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n" +printf " ${GREEN}Passed:${NC} %d\n" "${PASS}" +printf " ${RED}Failed:${NC} %d\n" "${FAIL}" +printf " ${YELLOW}Skipped:${NC} %d\n" "${SKIP}" +printf "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n\n" + +[[ "${FAIL}" -eq 0 ]] && printf "${GREEN}✓ All tests passed${NC}\n\n" && exit 0 +printf "${RED}✗ %d test(s) failed${NC}\n\n" "${FAIL}" +exit 1 diff --git a/tests/test-registry-parser.sh b/tests/test-registry-parser.sh index 90c04a8..e449bdc 100644 --- a/tests/test-registry-parser.sh +++ b/tests/test-registry-parser.sh @@ -66,10 +66,10 @@ get_value() { echo "=== Test: real registry.json (agent packs) ===" output=$(list_agents "$REGISTRY") agent_count=$(echo "$output" | grep -c . || true) -if [[ "$agent_count" -ge 6 ]]; then - echo " ✓ lists at least 6 agents (found $agent_count)"; PASS=$((PASS + 1)) +if [[ "$agent_count" -ge 7 ]]; then + echo " ✓ lists at least 7 agents (found $agent_count)"; PASS=$((PASS + 1)) else - echo " ✗ expected at least 6 agents, found $agent_count"; FAIL=$((FAIL + 1)) + echo " ✗ expected at least 7 agents, found $agent_count"; FAIL=$((FAIL + 1)) fi assert_contains "includes openclaw" "openclaw|" "$output" assert_contains "includes claude-code" "claude-code|" "$output" @@ -79,6 +79,7 @@ removed_packs=$(echo "$output" | grep -c -E '^(pi|ironclaw|nemoclaw)\|' || true) assert_eq "excludes removed packs (pi/ironclaw/nemoclaw)" "0" "$removed_packs" bedrockify_as_pack=$(echo "$output" | grep -c '^bedrockify|' || true) assert_eq "excludes base packs (bedrockify)" "0" "$bedrockify_as_pack" +assert_contains "includes troika" "troika|" "$output" # ---- Test: experimental flag ------------------------------------------------ echo "" @@ -88,6 +89,25 @@ assert_contains "openclaw experimental=false" "|false" "$(echo "$output" | grep assert_contains "hermes is experimental" "|true" "$(echo "$output" | grep '^hermes|')" assert_contains "kiro-cli is experimental" "|true" "$(echo "$output" | grep '^kiro-cli|')" +# ---- Test: troika pack fields ----------------------------------------------- +echo "" +echo "=== Test: troika pack (registry.json) ===" +assert_eq "troika → t4g.xlarge" "t4g.xlarge" "$(get_value "$REGISTRY" troika instance_type)" +assert_eq "troika → default_model" "us.anthropic.claude-sonnet-4-6" "$(get_value "$REGISTRY" troika default_model)" +assert_eq "troika → requires_openai_key=false" "false" "$(jq -r '.packs.troika.requires_openai_key' "$REGISTRY" 2>/dev/null)" +assert_eq "troika → experimental=true" "true" "$(get_value "$REGISTRY" troika experimental)" +assert_eq "troika → brain=true" "true" "$(get_value "$REGISTRY" troika brain)" +assert_eq "troika → root_volume_gb=40" "40" "$(get_value "$REGISTRY" troika root_volume_gb)" +assert_eq "troika → data_volume_gb=80" "80" "$(get_value "$REGISTRY" troika data_volume_gb)" +# troika should be listed as experimental +assert_contains "troika is experimental" "|true" "$(echo "$output" | grep '^troika|')" +# deps order: bedrockify → openclaw → claude-code → codex-cli +_troika_deps=$(jq -c '.packs.troika.deps' "$REGISTRY" 2>/dev/null) +assert_eq "troika → deps order" '["bedrockify","openclaw","claude-code","codex-cli"]' "$_troika_deps" +# compatible_profiles includes builder +_troika_profiles=$(jq -c '.packs.troika.compatible_profiles' "$REGISTRY" 2>/dev/null) +assert_eq "troika → compatible_profiles=[builder]" '["builder"]' "$_troika_profiles" + # ---- Test: instance_type lookup --------------------------------------------- echo "" echo "=== Test: instance_type lookup ===" diff --git a/tests/test-sync-registry.sh b/tests/test-sync-registry.sh index db2b832..eb0ea90 100755 --- a/tests/test-sync-registry.sh +++ b/tests/test-sync-registry.sh @@ -182,6 +182,54 @@ check_field instance_type "t4g.medium" check_field data_volume_gb 0 check_field brain false +# ── Test 8b: troika pack round-trips with all expected fields ─────────────── +header "Test 8b — troika entry has expected fields (Bedrock-only, no OpenAI key)" +check_troika_field() { + local field="$1" expected="$2" + local got + got=$(jq -r --arg f "$field" '.packs.troika[$f]' "$SB/packs/registry.json") + if [[ "$got" == "$expected" ]]; then + pass "troika.$field = $expected" + else + fail "troika.$field expected '$expected' got '$got'" + fi +} +check_troika_field type agent +check_troika_field default_model "us.anthropic.claude-sonnet-4-6" +check_troika_field experimental true +check_troika_field instance_type "t4g.xlarge" +check_troika_field data_volume_gb 80 +check_troika_field brain true +check_troika_field claude_code false +# requires_openai_key=false must be literal JSON false (jq -r returns 'false', not 'null') +_tri_req_oai=$(jq -r '.packs.troika.requires_openai_key' "$SB/packs/registry.json") +if [[ "$_tri_req_oai" == "false" ]]; then + pass "troika.requires_openai_key = false" +else + fail "troika.requires_openai_key expected 'false' got '$_tri_req_oai'" +fi +# deps order: bedrockify → openclaw → claude-code → codex-cli +_tri_deps=$(jq -c '.packs.troika.deps' "$SB/packs/registry.json") +if [[ "$_tri_deps" == '["bedrockify","openclaw","claude-code","codex-cli"]' ]]; then + pass "troika.deps order correct (bedrockify→openclaw→claude-code→codex-cli)" +else + fail "troika.deps wrong: got $_tri_deps" +fi +# compatible_profiles=[builder] +_tri_profiles=$(jq -c '.packs.troika.compatible_profiles' "$SB/packs/registry.json") +if [[ "$_tri_profiles" == '["builder"]' ]]; then + pass "troika.compatible_profiles = [builder]" +else + fail "troika.compatible_profiles wrong: got $_tri_profiles" +fi +# ports.gateway=3001 +_tri_gw=$(jq -r '.packs.troika.ports.gateway' "$SB/packs/registry.json") +if [[ "$_tri_gw" == "3001" ]]; then + pass "troika.ports.gateway = 3001" +else + fail "troika.ports.gateway expected '3001' got '$_tri_gw'" +fi + # ── Test 9: YAML → JSON data equality (deep compare) ───────────────────────── header "Test 9 — YAML and JSON represent identical data (deep equality)" RESULT=$(python3 <