From bcce29e57a4b0f25d4627cf4da05545976d73979 Mon Sep 17 00:00:00 2001 From: Aaron Elijah Mars <61592645+aaronjmars@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:50:22 -0400 Subject: [PATCH 01/20] feat(harness-adapter): credential manifest + gateway manifest with drift test Each adapter's rh-meta block now lists its credentials (secret, kind, auth_mode, label, prefix, get_url, login_cmd, aeon_cmd, cred_paths, expires/refresh, aux_secrets), most preferred first in exactly the order scripts/resolve-harness.sh picks them, plus default_model. The old auth summary is derived from that list by the generator, so the two cannot disagree. The generator also writes harness-adapter/gateways.json from a gw-meta block in adapters/claude.sh: every llm-gateway.sh provider in the default cascade order with secrets, prefixes, base URL and transport. Drift fixed on the way: - grok no longer claims an OpenRouter fallback it does not have - pi lists ANTHROPIC_OAUTH_TOKEN, which resolve-harness.sh already honours - cli.install/min_version match the real pins (kimi/vibe had no install command; fx said 0.0.5 vs v0.0.12; cursor/hermes said latest) - cursor with no CURSOR_API_KEY is labelled AUTH_MODE=none instead of openrouter (label only; install still fails closed) - hivemindos added to the dashboard gateway registry scripts/tests/test_credential_manifest.sh (wired into ci-tests) holds the mirror to resolve-harness.sh (parsed and exercised), harness-auth.ts, the aeon.yml env blocks, install pins, llm-gateway.sh and gateway-registry.ts. ci-harnesses-json now diffs gateways.json too. --- .claude/skills/aeon/references/ci.md | 2 +- .github/workflows/ci-harnesses-json.yml | 21 +- .github/workflows/ci-tests.yml | 9 + apps/dashboard/lib/gateway-registry.ts | 8 + harness-adapter/README.md | 36 +- harness-adapter/adapters/claude.sh | 27 +- harness-adapter/adapters/codex.sh | 9 +- harness-adapter/adapters/cursor.sh | 20 +- harness-adapter/adapters/fx.sh | 8 +- harness-adapter/adapters/grok.sh | 8 +- harness-adapter/adapters/hermes.sh | 21 +- harness-adapter/adapters/kimi.sh | 9 +- harness-adapter/adapters/pi.sh | 10 +- harness-adapter/adapters/vibe.sh | 8 +- harness-adapter/bin/generate-harnesses-json | 83 ++++- harness-adapter/gateways.json | 130 +++++++ harness-adapter/harnesses.json | 305 +++++++++++++++-- plugin/skills/aeon/references/ci.md | 2 +- scripts/resolve-harness.sh | 8 +- scripts/tests/test_credential_manifest.sh | 317 ++++++++++++++++++ scripts/tests/test_generate_harnesses_json.sh | 43 ++- scripts/tests/test_resolve_harness.sh | 2 + 22 files changed, 1015 insertions(+), 71 deletions(-) create mode 100644 harness-adapter/gateways.json create mode 100644 scripts/tests/test_credential_manifest.sh diff --git a/.claude/skills/aeon/references/ci.md b/.claude/skills/aeon/references/ci.md index aa576576a44..dd9039925fb 100644 --- a/.claude/skills/aeon/references/ci.md +++ b/.claude/skills/aeon/references/ci.md @@ -16,7 +16,7 @@ Thirteen `ci-*.yml` workflows. Twelve are **path-filtered** and fire on `pull_re | `ci-readme-catalog` | `.github/README.md`, `catalog/*.json`, `docs/skill-packs.md`, `docs/aeon-setup.md`, `docs/examples/README.md`, the hero SVG | README skill tables and skill counts match the catalog | `node scripts/validate-readme-catalog.mjs` | | `ci-tests` | `scripts/**`, `bin/**`, `aeon.yml`, `.github/workflows/aeon.yml`, `harness-adapter/adapters/**` | the `scripts/tests/` suites + config validation | see below | | `ci-shellcheck` | `aeon`, `scripts/**`, `bin/**`, `harness-adapter/**`, `skills/**/*.sh` | shellcheck on the tracked shell surface | `bash scripts/lint-shell.sh` | -| `ci-harnesses-json` | `harness-adapter/adapters/**`, `harness-adapter/bin/generate-harnesses-json`, `harness-adapter/harnesses.json` | committed harness manifest == fresh regen | `harness-adapter/bin/generate-harnesses-json` | +| `ci-harnesses-json` | `harness-adapter/adapters/**`, `harness-adapter/bin/generate-harnesses-json`, `harness-adapter/harnesses.json`, `harness-adapter/gateways.json` | committed harness + gateway manifests == fresh regen | `harness-adapter/bin/generate-harnesses-json` | | `ci-capabilities-parity` | `bin/install-skill-pack`, `docs/CAPABILITIES.md` | capabilities taxonomy in sync across both | `bash scripts/check-capabilities-parity.sh` | | `ci-skill-packs` | `catalog/skill-packs.json`, `docs/community-skill-packs.md`, `bin/install-skill-pack`, `skills/security/trusted-sources.txt` | community registry well-formed + matches the Listed packs table in `docs/community-skill-packs.md`; no unbacked `trust_level: trusted` | `node scripts/validate-skill-packs.mjs` | | `ci-agents-md` | `CLAUDE.md`, `STRATEGY.md`, `AGENTS.md`, `scripts/gen-agents-md.js` | `AGENTS.md` regenerated from `CLAUDE.md` (with `STRATEGY.md` inlined) | `node scripts/gen-agents-md.js --check` | diff --git a/.github/workflows/ci-harnesses-json.yml b/.github/workflows/ci-harnesses-json.yml index 3fcb2300a11..ccd97df0bbb 100644 --- a/.github/workflows/ci-harnesses-json.yml +++ b/.github/workflows/ci-harnesses-json.yml @@ -1,11 +1,13 @@ name: ci-harnesses-json -# harness-adapter/harnesses.json is the capability manifest for the seven adapters - +# harness-adapter/harnesses.json is the capability manifest for the nine adapters - # the local analog of a UHP GET /v1/harnesses discovery response. It is generated # from each adapters/.sh rh-meta block by # harness-adapter/bin/generate-harnesses-json. This workflow fails any PR that # edits a generator input without committing the regenerated manifest, so the -# manifest cannot drift from the adapters it describes. +# manifest cannot drift from the adapters it describes. The same generator writes +# harness-adapter/gateways.json (the claude gateway cascade, from the gw-meta +# block in adapters/claude.sh), so it is diffed the same way. # # Same shape as ci-skills-json.yml. There are no git-derived fields here, so only # the `generated` timestamp is normalized out of the diff. @@ -16,6 +18,7 @@ on: - 'harness-adapter/adapters/**' - 'harness-adapter/bin/generate-harnesses-json' - 'harness-adapter/harnesses.json' + - 'harness-adapter/gateways.json' - '.github/workflows/ci-harnesses-json.yml' push: branches: [main] @@ -23,6 +26,7 @@ on: - 'harness-adapter/adapters/**' - 'harness-adapter/bin/generate-harnesses-json' - 'harness-adapter/harnesses.json' + - 'harness-adapter/gateways.json' workflow_dispatch: permissions: @@ -39,15 +43,20 @@ jobs: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - name: Regenerate harnesses.json and diff (ignoring generated timestamp) + - name: Regenerate harnesses.json + gateways.json and diff (ignoring generated timestamp) run: | set -euo pipefail norm() { sed -E 's/"generated": *"[^"]*"/"generated":""/' "$1"; } norm harness-adapter/harnesses.json > /tmp/hn.committed.json + norm harness-adapter/gateways.json > /tmp/gw.committed.json harness-adapter/bin/generate-harnesses-json norm harness-adapter/harnesses.json > /tmp/hn.regenerated.json - if ! diff -u /tmp/hn.committed.json /tmp/hn.regenerated.json; then - echo "::error::harnesses.json is stale - run harness-adapter/bin/generate-harnesses-json and commit the result (the 'generated' timestamp is ignored)." + norm harness-adapter/gateways.json > /tmp/gw.regenerated.json + stale=0 + diff -u /tmp/hn.committed.json /tmp/hn.regenerated.json || stale=1 + diff -u /tmp/gw.committed.json /tmp/gw.regenerated.json || stale=1 + if [ "$stale" = 1 ]; then + echo "::error::harnesses.json or gateways.json is stale - run harness-adapter/bin/generate-harnesses-json and commit the result (the 'generated' timestamp is ignored)." exit 1 fi - echo "harnesses-json: OK - committed manifest matches a fresh regen (generated timestamp aside)." + echo "harnesses-json: OK - committed manifests match a fresh regen (generated timestamp aside)." diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 2acc06740b9..13ccc630104 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -21,6 +21,8 @@ on: - '.github/workflows/chain-runner.yml' - 'skills/**' - 'apps/dashboard/lib/constants.ts' + - 'apps/dashboard/lib/harness-auth.ts' + - 'apps/dashboard/lib/gateway-registry.ts' - '.github/workflows/ci-tests.yml' push: branches: [main] @@ -35,6 +37,8 @@ on: - '.github/workflows/chain-runner.yml' - 'skills/**' - 'apps/dashboard/lib/constants.ts' + - 'apps/dashboard/lib/harness-auth.ts' + - 'apps/dashboard/lib/gateway-registry.ts' - '.github/workflows/ci-tests.yml' workflow_dispatch: @@ -121,6 +125,11 @@ jobs: run: bash scripts/tests/test_generate_harnesses_json.sh - name: harness resolution tests run: bash scripts/tests/test_resolve_harness.sh + # harnesses.json credentials/default_model + gateways.json mirror code in + # resolve-harness.sh, llm-gateway.sh, install-harness.sh, aeon.yml and the + # dashboard registries; this fails the moment any of them drifts. + - name: credential manifest consistency tests + run: bash scripts/tests/test_credential_manifest.sh - name: workflow harness choice tests run: bash scripts/tests/test_workflow_harness_choices.sh - name: dashboard model lists vs workflow model choices + harness defaults diff --git a/apps/dashboard/lib/gateway-registry.ts b/apps/dashboard/lib/gateway-registry.ts index 3a7b67daea5..6fd4d21d9cf 100644 --- a/apps/dashboard/lib/gateway-registry.ts +++ b/apps/dashboard/lib/gateway-registry.ts @@ -25,8 +25,16 @@ export const GATEWAY_REGISTRY = { // endpoint. No distinctive key prefix (dropdown-only, like UsePod/Venice). // Not a harness - there is no glm CLI; harness: glm is a dead name. glm: { label: 'GLM (Z.AI)', secretName: 'GLM_API_KEY', prefixes: [], domain: 'z.ai' }, + // HivemindOS Models: OpenAI-compatible, billed to a credit balance, bridged + // by the claude-code-router sidecar (scripts/llm-gateway.sh). No key prefix, + // so it is dropdown-only like UsePod/Venice. Last in the auto cascade. + hivemindos: { label: 'HivemindOS Models', secretName: 'HIVEMINDOS_CREDIT_TOKEN', prefixes: [], domain: 'hivemindos.liamvisionary.com' }, } as const +// The claude gateway cascade is mirrored in harness-adapter/gateways.json +// (generated from adapters/claude.sh); scripts/tests/test_credential_manifest.sh +// fails CI when a slug, secret, prefix or label here drifts from it. + export type GatewaySlug = keyof typeof GATEWAY_REGISTRY export const GATEWAY_SLUGS = Object.keys(GATEWAY_REGISTRY) as GatewaySlug[] diff --git a/harness-adapter/README.md b/harness-adapter/README.md index 6d195fdf22f..13285be7877 100644 --- a/harness-adapter/README.md +++ b/harness-adapter/README.md @@ -223,9 +223,38 @@ jq '.harnesses[] | select(.mcp != "unsupported") | .id' harnesses.json `.github/workflows/ci-harnesses-json.yml` fails any PR whose committed manifest does not match a fresh regen, so it cannot drift from the adapters it describes. -The manifest covers harness *capabilities* only; the resolver's default-model -policy stays in `scripts/resolve-harness.sh`, so a model-pin edit never -staleness-fails this gate. + +Each harness also lists its **credentials**: every secret that can authenticate +it, most preferred first, in exactly the order `scripts/resolve-harness.sh` +picks them. Per entry: + +| key | meaning | +|-----|---------| +| `secret` | repo secret name | +| `kind` | `oauth_capture` (a CLI login whose files are tar+base64'd into the secret), `oauth_token` (a token a CLI mints, e.g. `claude setup-token`), `api_key`, `oidc` | +| `auth_mode` | the `AUTH_MODE` resolve-harness.sh reports when this credential is the one in use (`native-oauth` / `native-key` / `openrouter`) | +| `label` | human name | +| `prefix` | key prefix, when the provider has one (optional) | +| `get_url` | where to get it | +| `login_cmd` | the provider CLI's own login command (optional) | +| `aeon_cmd` | the local command that obtains and stores it (optional) | +| `cred_paths` | `$HOME`-relative files captured, for `oauth_capture` | +| `expires` / `refresh` | lifetime and how to renew (optional notes) | +| `aux_secrets` | extra secrets the credential needs to stay alive (grok: `GH_SECRETS_PAT` or `GH_GLOBAL` to persist the rotated refresh token) | + +The older `auth` summary (`native_oauth` / `native_key` / `openrouter`) is +derived from that list by the generator. `default_model` mirrors resolve-harness.sh's +`DEFAULT_HM` (`default` = the harness's own configured model). The claude harness +points at [`gateways.json`](gateways.json), generated from the `gw-meta` block in +`adapters/claude.sh`: every provider `scripts/llm-gateway.sh` can route Claude Code +through, in its default `GATEWAY_ORDER`, with secret(s), key prefixes, base URL, +transport (`native` / `anthropic-compatible` / `sidecar`) and where to get a key. + +resolve-harness.sh and llm-gateway.sh stay the runtime source; the manifests are a +read-only mirror for `aeon init`, `bin/onboard` and the dashboard. +`scripts/tests/test_credential_manifest.sh` (ci-tests) fails the moment they +drift from those scripts, the install pins, the aeon.yml `env:` blocks, or the +dashboard's `harness-auth.ts` / `gateway-registry.ts`. ## Layout @@ -234,6 +263,7 @@ run-harness dispatcher: args → RH_* env → sandbox/timeout → ada adapters/.sh one per harness: invoke, translate, normalize (claude grok codex pi vibe kimi fx) harnesses.json generated capability manifest (UHP GET /v1/harnesses analog) bin/generate-harnesses-json aggregate adapters' rh-meta blocks → harnesses.json +gateways.json generated claude gateway cascade (gw-meta block in adapters/claude.sh) lib/envelope.sh emit/validate the contract envelope lib/tools-grammar.sh --allowedTools → per-harness permissions lib/mcp-translate.sh .mcp.json → codex -c flags / vibe TOML / kimi home; ${VAR} expansion diff --git a/harness-adapter/adapters/claude.sh b/harness-adapter/adapters/claude.sh index 1e4f91a2a09..43dba77fcd3 100755 --- a/harness-adapter/adapters/claude.sh +++ b/harness-adapter/adapters/claude.sh @@ -6,7 +6,7 @@ # { # "id": "claude", # "label": "Claude Code", -# "cli": { "install": "npm i -g @anthropic-ai/claude-code", "bin": "claude", "min_version": "2.1.287" }, +# "cli": { "install": "npm i -g @anthropic-ai/claude-code@2.1.287", "bin": "claude", "min_version": "2.1.287" }, # "invoke": "claude -p - --output-format json", # "round_trip": true, # "token_usage": "full", @@ -16,10 +16,33 @@ # "mcp": "native", # "max_turns": "native", # "claude_md": "native+imports", -# "auth": { "native_oauth": ["CLAUDE_CODE_OAUTH_TOKEN"], "native_key": ["ANTHROPIC_API_KEY"], "openrouter": false }, +# "default_model": "claude-sonnet-5-5", +# "credentials": [ +# { "secret": "CLAUDE_CODE_OAUTH_TOKEN", "kind": "oauth_token", "auth_mode": "native-oauth", "label": "Claude subscription (Pro/Max)", "prefix": "sk-ant-oat", "get_url": "https://claude.ai", "login_cmd": "claude setup-token", "aeon_cmd": "./aeon auth --harness claude-code", "expires": "about 1 year (claude setup-token mints a long-lived token)", "refresh": "re-run ./aeon auth --harness claude-code before it expires" }, +# { "secret": "ANTHROPIC_API_KEY", "kind": "api_key", "auth_mode": "native-key", "label": "Anthropic API key (pay as you go)", "prefix": "sk-ant-api", "get_url": "https://console.anthropic.com/settings/keys", "aeon_cmd": "./aeon auth --key " } +# ], +# "gateways": "gateways.json", # "native_control_path": "gateway" # } # rh-meta-end +# +# The claude harness's provider cascade (scripts/llm-gateway.sh), in its default +# GATEWAY_ORDER. scripts/tests/test_credential_manifest.sh holds it to that file +# and to apps/dashboard/lib/gateway-registry.ts. +# gw-meta-start - gateway manifest source of truth (bin/generate-harnesses-json -> gateways.json) +# [ +# { "id": "claude", "label": "Claude subscription", "secrets": ["CLAUDE_CODE_OAUTH_TOKEN"], "prefixes": ["sk-ant-oat"], "transport": "native", "base_url": "https://api.anthropic.com", "get_url": "https://claude.ai" }, +# { "id": "anthropic", "label": "Anthropic API", "secrets": ["ANTHROPIC_API_KEY"], "prefixes": ["sk-ant-api"], "transport": "native", "base_url": "https://api.anthropic.com", "get_url": "https://console.anthropic.com/settings/keys" }, +# { "id": "openrouter", "label": "OpenRouter", "secrets": ["OPENROUTER_API_KEY"], "prefixes": ["sk-or-"], "transport": "anthropic-compatible", "base_url": "https://openrouter.ai/api", "get_url": "https://openrouter.ai/settings/keys" }, +# { "id": "bankr", "label": "Bankr", "secrets": ["BANKR_LLM_KEY"], "prefixes": ["bk_"], "transport": "anthropic-compatible", "base_url": "https://llm.bankr.bot", "get_url": "https://docs.bankr.bot/llm-gateway/overview" }, +# { "id": "usepod", "label": "UsePod", "secrets": ["USEPOD_TOKEN"], "prefixes": [], "transport": "anthropic-compatible", "base_url": "https://api.usepod.ai/proxy/", "get_url": "https://usepod.ai" }, +# { "id": "venice", "label": "Venice", "secrets": ["VENICE_API_KEY"], "prefixes": [], "transport": "sidecar", "base_url": "https://api.venice.ai/api/v1/chat/completions", "get_url": "https://venice.ai/settings/api" }, +# { "id": "surplus", "label": "Surplus Intelligence", "secrets": ["SURPLUS_API_KEY"], "prefixes": ["inf_"], "transport": "sidecar", "base_url": "https://www.surplusintelligence.ai/api/inference/v1/chat/completions", "get_url": "https://surplusintelligence.ai" }, +# { "id": "grok", "label": "Grok (xAI)", "secrets": ["XAI_API_KEY"], "prefixes": ["xai-"], "transport": "anthropic-compatible", "base_url": "https://api.x.ai", "get_url": "https://console.x.ai" }, +# { "id": "glm", "label": "GLM (Z.AI)", "secrets": ["GLM_API_KEY", "ZAI_API_KEY"], "prefixes": [], "transport": "anthropic-compatible", "base_url": "https://api.z.ai/api/anthropic", "get_url": "https://z.ai" }, +# { "id": "hivemindos", "label": "HivemindOS Models", "secrets": ["HIVEMINDOS_CREDIT_TOKEN"], "prefixes": [], "transport": "sidecar", "base_url": "https://hivemindos-paid-agent-gateway.hivemindos.workers.dev/api/paid-agents/default/chat/completions", "get_url": "https://hivemindos.liamvisionary.com" } +# ] +# gw-meta-end set -uo pipefail . "$RH_LIB/envelope.sh" diff --git a/harness-adapter/adapters/codex.sh b/harness-adapter/adapters/codex.sh index 3c4c9b2f373..c1fed43fe71 100755 --- a/harness-adapter/adapters/codex.sh +++ b/harness-adapter/adapters/codex.sh @@ -20,7 +20,7 @@ # { # "id": "codex", # "label": "OpenAI Codex CLI", -# "cli": { "install": "npm i -g @openai/codex", "bin": "codex", "min_version": "0.159.3" }, +# "cli": { "install": "npm i -g --ignore-scripts @openai/codex@0.159.3", "bin": "codex", "min_version": "0.159.3" }, # "invoke": "codex exec --json -", # "round_trip": true, # "token_usage": "full", @@ -30,7 +30,12 @@ # "mcp": "native+inline-toml", # "max_turns": "timeout", # "claude_md": "fallback", -# "auth": { "native_oauth": ["CODEX_AUTH"], "native_key": ["OPENAI_API_KEY"], "openrouter": true }, +# "default_model": "openai/gpt-6-luna", +# "credentials": [ +# { "secret": "CODEX_AUTH", "kind": "oauth_capture", "auth_mode": "native-oauth", "label": "ChatGPT login (Plus/Pro)", "get_url": "https://chatgpt.com", "login_cmd": "codex login", "aeon_cmd": "./aeon auth --harness codex", "cred_paths": [".codex/auth.json"], "expires": "codex refreshes the access token from the captured refresh token at run start", "refresh": "re-run ./aeon auth --harness codex if a run reports a login error" }, +# { "secret": "OPENAI_API_KEY", "kind": "api_key", "auth_mode": "native-key", "label": "OpenAI API key", "prefix": "sk-", "get_url": "https://platform.openai.com/api-keys", "aeon_cmd": "./aeon auth --harness codex --key " }, +# { "secret": "OPENROUTER_API_KEY", "kind": "api_key", "auth_mode": "openrouter", "label": "OpenRouter key (one key covers most harnesses)", "prefix": "sk-or-", "get_url": "https://openrouter.ai/settings/keys", "aeon_cmd": "./aeon secrets set OPENROUTER_API_KEY --stdin" } +# ], # "native_control_path": "run-harness" # } # rh-meta-end diff --git a/harness-adapter/adapters/cursor.sh b/harness-adapter/adapters/cursor.sh index 7ed8f9ec38d..6d60994dabc 100755 --- a/harness-adapter/adapters/cursor.sh +++ b/harness-adapter/adapters/cursor.sh @@ -1,7 +1,25 @@ #!/usr/bin/env bash # Cursor CLI adapter: documented `agent -p` headless mode. # rh-meta-start -# {"id":"cursor","label":"Cursor CLI","cli":{"install":"curl -fsSL https://cursor.com/install | bash","bin":"agent","min_version":"latest"},"invoke":"agent -p --trust --output-format json","round_trip":true,"token_usage":"none","cost":false,"read_only":"sandbox","structured_output":"shim","mcp":"native","max_turns":"none","claude_md":"native","auth":{"native_oauth":[],"native_key":["CURSOR_API_KEY"],"openrouter":false},"native_control_path":"run-harness"} +# { +# "id": "cursor", +# "label": "Cursor CLI", +# "cli": { "install": "curl -fsSL https://cursor.com/install | bash", "bin": "agent", "min_version": "2026.10.01-14929f9" }, +# "invoke": "agent -p --trust --output-format json", +# "round_trip": true, +# "token_usage": "none", +# "cost": false, +# "read_only": "sandbox", +# "structured_output": "shim", +# "mcp": "native", +# "max_turns": "none", +# "claude_md": "native", +# "default_model": "auto", +# "credentials": [ +# { "secret": "CURSOR_API_KEY", "kind": "api_key", "auth_mode": "native-key", "label": "Cursor API key", "get_url": "https://cursor.com/dashboard", "aeon_cmd": "./aeon auth --harness cursor --key " } +# ], +# "native_control_path": "run-harness" +# } # rh-meta-end set -uo pipefail . "$RH_LIB/envelope.sh" diff --git a/harness-adapter/adapters/fx.sh b/harness-adapter/adapters/fx.sh index 2057edf0e43..6569183dd13 100644 --- a/harness-adapter/adapters/fx.sh +++ b/harness-adapter/adapters/fx.sh @@ -47,7 +47,7 @@ # { # "id": "fx", # "label": "fx", -# "cli": { "install": "curl -fsSL https://fx.sh/setup.sh | bash", "bin": "fx", "min_version": "0.0.5" }, +# "cli": { "install": "curl -fsSL https://fx.sh/setup.sh | bash", "bin": "fx", "min_version": "0.0.12" }, # "invoke": "fx ask --json --auto --no-save", # "round_trip": true, # "token_usage": "full", @@ -57,7 +57,11 @@ # "mcp": "native", # "max_turns": "native", # "claude_md": "native", -# "auth": { "native_oauth": [], "native_key": ["AI_GATEWAY_API_KEY", "VERCEL_OIDC_TOKEN"], "openrouter": false }, +# "default_model": "default", +# "credentials": [ +# { "secret": "AI_GATEWAY_API_KEY", "kind": "api_key", "auth_mode": "native-key", "label": "Vercel AI Gateway key", "get_url": "https://vercel.com/docs/ai-gateway", "aeon_cmd": "./aeon auth --harness fx --key " }, +# { "secret": "VERCEL_OIDC_TOKEN", "kind": "oidc", "auth_mode": "native-key", "label": "Vercel OIDC token", "get_url": "https://vercel.com/docs/oidc", "login_cmd": "vercel env pull", "expires": "about 12h (short-lived; best where Vercel provisions it for you)", "refresh": "pull a fresh token with vercel env pull and set it again" } +# ], # "native_control_path": "run-harness" # } # rh-meta-end diff --git a/harness-adapter/adapters/grok.sh b/harness-adapter/adapters/grok.sh index 6fd4636eefb..c58e92065cd 100755 --- a/harness-adapter/adapters/grok.sh +++ b/harness-adapter/adapters/grok.sh @@ -23,7 +23,7 @@ # { # "id": "grok", # "label": "Grok Build", -# "cli": { "install": "npm i -g @xai-official/grok", "bin": "grok", "min_version": "1.0.46" }, +# "cli": { "install": "npm i -g @xai-official/grok@1.0.46", "bin": "grok", "min_version": "1.0.46" }, # "invoke": "grok -p --output-format streaming-json", # "round_trip": true, # "token_usage": "full", @@ -33,7 +33,11 @@ # "mcp": "native+trust", # "max_turns": "native", # "claude_md": "native", -# "auth": { "native_oauth": ["GROK_CREDENTIALS"], "native_key": ["XAI_API_KEY"], "openrouter": true }, +# "default_model": "grok-4.7", +# "credentials": [ +# { "secret": "GROK_CREDENTIALS", "kind": "oauth_capture", "auth_mode": "native-oauth", "label": "X account login (grok login)", "get_url": "https://x.ai/grok", "login_cmd": "grok login --device-auth", "aeon_cmd": "./aeon auth --harness grok", "cred_paths": [".grok/auth.json"], "expires": "access token about 6h; xAI rotates the refresh token on every refresh", "refresh": "scripts/run-grok.sh refreshes before each run and writes the rotated token back to GROK_CREDENTIALS, which needs GH_SECRETS_PAT or GH_GLOBAL", "aux_secrets": ["GH_SECRETS_PAT", "GH_GLOBAL"] }, +# { "secret": "XAI_API_KEY", "kind": "api_key", "auth_mode": "native-key", "label": "xAI API key", "prefix": "xai-", "get_url": "https://console.x.ai", "aeon_cmd": "./aeon auth --harness grok --key " } +# ], # "native_control_path": "run-grok.sh" # } # rh-meta-end diff --git a/harness-adapter/adapters/hermes.sh b/harness-adapter/adapters/hermes.sh index 9fa5ee968bc..ceaa9d06649 100755 --- a/harness-adapter/adapters/hermes.sh +++ b/harness-adapter/adapters/hermes.sh @@ -1,7 +1,26 @@ #!/usr/bin/env bash # Hermes Agent adapter using documented scripted `hermes -z` and --usage-file. # rh-meta-start -# {"id":"hermes","label":"Hermes (Nous Portal)","cli":{"install":"curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash","bin":"hermes","min_version":"latest"},"invoke":"hermes --usage-file -z prompt","round_trip":true,"token_usage":"full","cost":true,"read_only":"sandbox","structured_output":"shim","mcp":"native","max_turns":"native","claude_md":"native","auth":{"native_oauth":["HERMES_AUTH"],"native_key":[],"openrouter":true},"native_control_path":"run-harness"} +# { +# "id": "hermes", +# "label": "Hermes (Nous Portal)", +# "cli": { "install": "curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash", "bin": "hermes", "min_version": "2026.9.24" }, +# "invoke": "hermes --usage-file -z prompt", +# "round_trip": true, +# "token_usage": "full", +# "cost": true, +# "read_only": "sandbox", +# "structured_output": "shim", +# "mcp": "native", +# "max_turns": "native", +# "claude_md": "native", +# "default_model": "default", +# "credentials": [ +# { "secret": "HERMES_AUTH", "kind": "oauth_capture", "auth_mode": "native-oauth", "label": "Nous Portal login", "get_url": "https://portal.nousresearch.com", "login_cmd": "hermes auth add nous --type oauth", "aeon_cmd": "./aeon auth --harness hermes", "cred_paths": [".hermes/auth.json", ".hermes/config.yaml"], "refresh": "re-run ./aeon auth --harness hermes if a run reports a login error" }, +# { "secret": "OPENROUTER_API_KEY", "kind": "api_key", "auth_mode": "openrouter", "label": "OpenRouter key (one key covers most harnesses)", "prefix": "sk-or-", "get_url": "https://openrouter.ai/settings/keys", "aeon_cmd": "./aeon secrets set OPENROUTER_API_KEY --stdin" } +# ], +# "native_control_path": "run-harness" +# } # rh-meta-end set -uo pipefail . "$RH_LIB/envelope.sh" diff --git a/harness-adapter/adapters/kimi.sh b/harness-adapter/adapters/kimi.sh index 13cc93eab3d..beed00bb8a3 100644 --- a/harness-adapter/adapters/kimi.sh +++ b/harness-adapter/adapters/kimi.sh @@ -23,7 +23,7 @@ # { # "id": "kimi", # "label": "Kimi Code", -# "cli": { "install": "", "bin": "kimi", "min_version": "2.1.1" }, +# "cli": { "install": "npm i -g --ignore-scripts @moonshot-ai/kimi-code@2.1.1", "bin": "kimi", "min_version": "2.1.1" }, # "invoke": "kimi -p --output-format stream-json", # "round_trip": true, # "token_usage": "none", @@ -33,7 +33,12 @@ # "mcp": "native+overlay", # "max_turns": "timeout", # "claude_md": "native", -# "auth": { "native_oauth": ["KIMI_AUTH"], "native_key": ["MOONSHOT_API_KEY"], "openrouter": true }, +# "default_model": "moonshotai/kimi-k2.6", +# "credentials": [ +# { "secret": "KIMI_AUTH", "kind": "oauth_capture", "auth_mode": "native-oauth", "label": "Kimi (Moonshot) login", "get_url": "https://www.kimi.com", "login_cmd": "kimi login", "aeon_cmd": "./aeon auth --harness kimi", "cred_paths": [".kimi-code/credentials", ".kimi-code/config.toml"], "refresh": "re-run ./aeon auth --harness kimi if a run reports a login error" }, +# { "secret": "MOONSHOT_API_KEY", "kind": "api_key", "auth_mode": "native-key", "label": "Moonshot API key", "prefix": "sk-", "get_url": "https://platform.moonshot.ai/console/api-keys", "aeon_cmd": "./aeon auth --harness kimi --key " }, +# { "secret": "OPENROUTER_API_KEY", "kind": "api_key", "auth_mode": "openrouter", "label": "OpenRouter key (one key covers most harnesses)", "prefix": "sk-or-", "get_url": "https://openrouter.ai/settings/keys", "aeon_cmd": "./aeon secrets set OPENROUTER_API_KEY --stdin" } +# ], # "native_control_path": "run-harness" # } # rh-meta-end diff --git a/harness-adapter/adapters/pi.sh b/harness-adapter/adapters/pi.sh index 0273eb9fad2..19637fdead1 100755 --- a/harness-adapter/adapters/pi.sh +++ b/harness-adapter/adapters/pi.sh @@ -32,7 +32,7 @@ # { # "id": "pi", # "label": "Pi", -# "cli": { "install": "npm i -g --ignore-scripts @earendil-works/pi-coding-agent", "bin": "pi", "min_version": "0.99.2" }, +# "cli": { "install": "npm i -g --ignore-scripts @earendil-works/pi-coding-agent@0.99.2", "bin": "pi", "min_version": "0.99.2" }, # "invoke": "pi -p --mode json", # "round_trip": true, # "token_usage": "full", @@ -42,7 +42,13 @@ # "mcp": "native", # "max_turns": "timeout", # "claude_md": "native", -# "auth": { "native_oauth": [], "native_key": ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"], "openrouter": true }, +# "default_model": "deepseek/deepseek-v4-flash", +# "credentials": [ +# { "secret": "ANTHROPIC_API_KEY", "kind": "api_key", "auth_mode": "native-key", "label": "Anthropic API key", "prefix": "sk-ant-api", "get_url": "https://console.anthropic.com/settings/keys", "aeon_cmd": "./aeon auth --harness pi --key " }, +# { "secret": "ANTHROPIC_OAUTH_TOKEN", "kind": "oauth_token", "auth_mode": "native-key", "label": "Claude subscription token", "prefix": "sk-ant-oat", "get_url": "https://claude.ai", "login_cmd": "claude setup-token", "aeon_cmd": "./aeon auth --harness pi --key ", "expires": "about 1 year (claude setup-token mints a long-lived token)" }, +# { "secret": "OPENAI_API_KEY", "kind": "api_key", "auth_mode": "native-key", "label": "OpenAI API key", "prefix": "sk-", "get_url": "https://platform.openai.com/api-keys", "aeon_cmd": "./aeon auth --harness pi --key " }, +# { "secret": "OPENROUTER_API_KEY", "kind": "api_key", "auth_mode": "openrouter", "label": "OpenRouter key (one key covers most harnesses)", "prefix": "sk-or-", "get_url": "https://openrouter.ai/settings/keys", "aeon_cmd": "./aeon secrets set OPENROUTER_API_KEY --stdin" } +# ], # "native_control_path": "run-harness" # } # rh-meta-end diff --git a/harness-adapter/adapters/vibe.sh b/harness-adapter/adapters/vibe.sh index b74bcc42a0b..29859745442 100644 --- a/harness-adapter/adapters/vibe.sh +++ b/harness-adapter/adapters/vibe.sh @@ -23,7 +23,7 @@ # { # "id": "vibe", # "label": "Mistral Vibe", -# "cli": { "install": "", "bin": "vibe", "min_version": "2.25.8" }, +# "cli": { "install": "pipx install mistral-vibe==2.25.8", "bin": "vibe", "min_version": "2.25.8" }, # "invoke": "vibe -p --trust --output json", # "round_trip": true, # "token_usage": "none", @@ -33,7 +33,11 @@ # "mcp": "native", # "max_turns": "native", # "claude_md": "native", -# "auth": { "native_oauth": [], "native_key": ["MISTRAL_API_KEY"], "openrouter": true }, +# "default_model": "mistralai/mistral-medium-3-5", +# "credentials": [ +# { "secret": "MISTRAL_API_KEY", "kind": "api_key", "auth_mode": "native-key", "label": "Mistral API key", "get_url": "https://console.mistral.ai/api-keys", "aeon_cmd": "./aeon auth --harness vibe --key " }, +# { "secret": "OPENROUTER_API_KEY", "kind": "api_key", "auth_mode": "openrouter", "label": "OpenRouter key (one key covers most harnesses)", "prefix": "sk-or-", "get_url": "https://openrouter.ai/settings/keys", "aeon_cmd": "./aeon secrets set OPENROUTER_API_KEY --stdin" } +# ], # "native_control_path": "run-harness" # } # rh-meta-end diff --git a/harness-adapter/bin/generate-harnesses-json b/harness-adapter/bin/generate-harnesses-json index 530cecffc7f..7329145df17 100755 --- a/harness-adapter/bin/generate-harnesses-json +++ b/harness-adapter/bin/generate-harnesses-json @@ -16,10 +16,18 @@ set -eo pipefail # one queryable file instead of scattered across the README table and # resolve-harness.sh. # -# The manifest describes harness CAPABILITIES only. The resolver's default-model -# policy stays in scripts/resolve-harness.sh on purpose - mirroring it here would -# make this manifest go stale on every model-pin edit, and default model is a -# router decision, not a harness capability. +# Each block also carries the harness's CREDENTIALS: every secret that can +# authenticate it, most preferred first, in exactly the order +# scripts/resolve-harness.sh picks them, with where to get each one and the +# command that stores it. The legacy `auth` summary ({native_oauth, native_key, +# openrouter}) is DERIVED from that list here, so the two cannot disagree. +# `default_model` mirrors resolve-harness.sh's DEFAULT_HM for a fresh instance. +# resolve-harness.sh stays the runtime source for both; the manifest is a +# read-only mirror for the dashboard, `aeon init` and bin/onboard, and +# scripts/tests/test_credential_manifest.sh fails CI the moment they drift. +# +# claude.sh also carries a gw-meta block: the claude harness's LLM-gateway +# cascade (scripts/llm-gateway.sh), written out as gateways.json in the same pass. # # The manifest is generated, never hand-edited: change the block in the adapter # and re-run this. ci-harnesses-json.yml fails any PR whose committed manifest @@ -28,6 +36,7 @@ set -eo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" ADAPTERS="$ROOT/adapters" OUTPUT="$ROOT/harnesses.json" +GW_OUTPUT="$ROOT/gateways.json" command -v jq >/dev/null 2>&1 || { echo "generate-harnesses-json: jq is required" >&2; exit 1; } @@ -36,13 +45,40 @@ FORMAT=(jq .) # Required keys every adapter meta block must carry. Keep in sync with the enum # assertions in scripts/tests/test_generate_harnesses_json.sh. -REQUIRED='["id","label","cli","invoke","round_trip","token_usage","cost","read_only","structured_output","mcp","max_turns","claude_md","auth","native_control_path"]' +REQUIRED='["id","label","cli","invoke","round_trip","token_usage","cost","read_only","structured_output","mcp","max_turns","claude_md","default_model","credentials","native_control_path"]' + +# Per-credential schema. kind: how the secret is obtained - oauth_capture (a CLI +# login whose credential files are tar+base64'd into the secret), oauth_token (a +# token a CLI mints, e.g. claude setup-token), api_key, oidc. auth_mode: the +# AUTH_MODE resolve-harness.sh reports when this credential is the one in use. +CRED_KINDS='["oauth_capture","oauth_token","api_key","oidc"]' +AUTH_MODES='["native-oauth","native-key","openrouter"]' +CRED_KEYS='["secret","kind","auth_mode","label","prefix","get_url","login_cmd","aeon_cmd","cred_paths","expires","refresh","aux_secrets"]' -extract_meta() { # $1 = adapter file -> the enclosed JSON object on stdout - sed -n '/rh-meta-start/,/rh-meta-end/p' "$1" \ - | grep -v 'rh-meta-start\|rh-meta-end' \ +extract_block() { # $1 = adapter file, $2 = block tag -> the enclosed JSON on stdout + sed -n "/$2-start/,/$2-end/p" "$1" \ + | grep -v -e "$2-start" -e "$2-end" \ | sed -E 's/^#[[:space:]]?//' } +extract_meta() { extract_block "$1" rh-meta; } + +# Fail with a named error on a credential entry outside the schema above. +check_credentials() { # $1 = adapter basename, $2 = meta JSON + local problems + problems="$(echo "$2" | jq -r --argjson kinds "$CRED_KINDS" --argjson modes "$AUTH_MODES" --argjson keys "$CRED_KEYS" ' + (.credentials | if type != "array" or length == 0 then ["credentials must be a non-empty array"] else [] end) + + [ (.credentials // [])[] as $c + | (if ($c.secret | type) != "string" or ($c.secret | test("^[A-Z][A-Z0-9_]+$") | not) then "bad secret name \($c.secret)" else empty end), + (if ($c.kind | IN($kinds[]) | not) then "\($c.secret): kind \($c.kind) not in \($kinds | join("|"))" else empty end), + (if ($c.auth_mode | IN($modes[]) | not) then "\($c.secret): auth_mode \($c.auth_mode) not in \($modes | join("|"))" else empty end), + (if ($c.label | type) != "string" or $c.label == "" then "\($c.secret): label is required" else empty end), + (if ($c.get_url | type) != "string" or ($c.get_url | startswith("https://") | not) then "\($c.secret): get_url must be an https URL" else empty end), + (if $c.kind == "oauth_capture" and ((($c.cred_paths | type) != "array") or ($c.login_cmd | type) != "string") then "\($c.secret): oauth_capture needs cred_paths and login_cmd" else empty end), + (if (($c | keys) - $keys | length) > 0 then "\($c.secret): unknown key(s) \(($c | keys) - $keys | join(", "))" else empty end) ] + + (if ([.credentials[]?.secret] | length) != ([.credentials[]?.secret] | unique | length) then ["duplicate credential secret"] else [] end) + | .[]')" + [ -z "$problems" ] || { echo "error: adapters/$1.sh credentials: $problems" >&2; exit 1; } +} metas="" for f in "$ADAPTERS"/*.sh; do @@ -54,15 +90,34 @@ for f in "$ADAPTERS"/*.sh; do [ -z "$missing" ] || { echo "error: adapters/$base.sh meta missing key(s): $missing" >&2; exit 1; } id="$(echo "$meta" | jq -r '.id')" [ "$id" = "$base" ] || { echo "error: adapters/$base.sh meta id '$id' does not match filename '$base'" >&2; exit 1; } + if echo "$meta" | jq -e 'has("auth")' >/dev/null; then + echo "error: adapters/$base.sh meta has an 'auth' key - it is derived from 'credentials' now, remove it" >&2; exit 1 + fi + check_credentials "$base" "$meta" + # The legacy auth summary, derived from the ordered credential list. + meta="$(echo "$meta" | jq '. + { auth: { + native_oauth: [.credentials[] | select(.auth_mode == "native-oauth") | .secret], + native_key: [.credentials[] | select(.auth_mode == "native-key") | .secret], + openrouter: ([.credentials[] | select(.auth_mode == "openrouter")] | length > 0) } }')" metas+="$meta"$'\n' done +gw="$(extract_block "$ADAPTERS/claude.sh" gw-meta)" +[ -n "$gw" ] || { echo "error: no gw-meta block in adapters/claude.sh" >&2; exit 1; } +echo "$gw" | jq -e 'type == "array" and length > 0' >/dev/null 2>&1 \ + || { echo "error: adapters/claude.sh gw-meta block is not a non-empty JSON array" >&2; exit 1; } +gwbad="$(echo "$gw" | jq -r '.[] | select( + (.id | type) != "string" or (.label | type) != "string" or (.secrets | type) != "array" or (.secrets | length) == 0 + or (.prefixes | type) != "array" or (.transport | IN("native","anthropic-compatible","sidecar") | not) + or (.base_url | type) != "string" or (.get_url | startswith("https://") | not)) | .id // "?"')" +[ -z "$gwbad" ] || { echo "error: adapters/claude.sh gw-meta: malformed entry: $gwbad" >&2; exit 1; } + harnesses="$(printf '%s' "$metas" | jq -s 'sort_by(.id)')" generated="$(date -u +%Y-%m-%dT%H:%M:%SZ)" jq -n --argjson h "$harnesses" --arg gen "$generated" ' { - note: "harness-adapter capability manifest - the local analog of a UHP GET /v1/harnesses discovery response. Generated by bin/generate-harnesses-json from each adapters/.sh rh-meta block; do not hand-edit. Describes harness capabilities only; default-model policy lives in scripts/resolve-harness.sh.", + note: "harness-adapter capability manifest - the local analog of a UHP GET /v1/harnesses discovery response. Generated by bin/generate-harnesses-json from each adapters/.sh rh-meta block; do not hand-edit. credentials are ordered most preferred first, exactly as scripts/resolve-harness.sh picks them, and auth is derived from them; default_model mirrors its DEFAULT_HM. resolve-harness.sh stays the runtime source; scripts/tests/test_credential_manifest.sh keeps this mirror honest.", contract: { stdin: "prompt", stdout: "{result, usage{input_tokens,output_tokens,cache_read_input_tokens,cache_creation_input_tokens}, session_id?, total_cost_usd?}", @@ -73,4 +128,12 @@ jq -n --argjson h "$harnesses" --arg gen "$generated" ' harnesses: $h }' | "${FORMAT[@]}" > "$OUTPUT" -echo "generate-harnesses-json: wrote $OUTPUT ($(echo "$harnesses" | jq 'length') harnesses)" >&2 +jq -n --argjson g "$gw" --arg gen "$generated" ' + { + note: "LLM-gateway manifest for the claude harness: every provider scripts/llm-gateway.sh can route Claude Code through, in its default GATEWAY_ORDER (the auto cascade). Generated by bin/generate-harnesses-json from the gw-meta block in adapters/claude.sh; do not hand-edit. transport: native = straight to Anthropic, anthropic-compatible = base URL swap, sidecar = claude-code-router translating to an OpenAI-compatible upstream.", + generated: $gen, + count: ($g | length), + gateways: $g + }' | "${FORMAT[@]}" > "$GW_OUTPUT" + +echo "generate-harnesses-json: wrote $OUTPUT ($(echo "$harnesses" | jq 'length') harnesses) and $GW_OUTPUT ($(echo "$gw" | jq 'length') gateways)" >&2 diff --git a/harness-adapter/gateways.json b/harness-adapter/gateways.json new file mode 100644 index 00000000000..fe7603ad008 --- /dev/null +++ b/harness-adapter/gateways.json @@ -0,0 +1,130 @@ +{ + "note": "LLM-gateway manifest for the claude harness: every provider scripts/llm-gateway.sh can route Claude Code through, in its default GATEWAY_ORDER (the auto cascade). Generated by bin/generate-harnesses-json from the gw-meta block in adapters/claude.sh; do not hand-edit. transport: native = straight to Anthropic, anthropic-compatible = base URL swap, sidecar = claude-code-router translating to an OpenAI-compatible upstream.", + "generated": "2026-10-02T19:46:38Z", + "count": 10, + "gateways": [ + { + "id": "claude", + "label": "Claude subscription", + "secrets": [ + "CLAUDE_CODE_OAUTH_TOKEN" + ], + "prefixes": [ + "sk-ant-oat" + ], + "transport": "native", + "base_url": "https://api.anthropic.com", + "get_url": "https://claude.ai" + }, + { + "id": "anthropic", + "label": "Anthropic API", + "secrets": [ + "ANTHROPIC_API_KEY" + ], + "prefixes": [ + "sk-ant-api" + ], + "transport": "native", + "base_url": "https://api.anthropic.com", + "get_url": "https://console.anthropic.com/settings/keys" + }, + { + "id": "openrouter", + "label": "OpenRouter", + "secrets": [ + "OPENROUTER_API_KEY" + ], + "prefixes": [ + "sk-or-" + ], + "transport": "anthropic-compatible", + "base_url": "https://openrouter.ai/api", + "get_url": "https://openrouter.ai/settings/keys" + }, + { + "id": "bankr", + "label": "Bankr", + "secrets": [ + "BANKR_LLM_KEY" + ], + "prefixes": [ + "bk_" + ], + "transport": "anthropic-compatible", + "base_url": "https://llm.bankr.bot", + "get_url": "https://docs.bankr.bot/llm-gateway/overview" + }, + { + "id": "usepod", + "label": "UsePod", + "secrets": [ + "USEPOD_TOKEN" + ], + "prefixes": [], + "transport": "anthropic-compatible", + "base_url": "https://api.usepod.ai/proxy/", + "get_url": "https://usepod.ai" + }, + { + "id": "venice", + "label": "Venice", + "secrets": [ + "VENICE_API_KEY" + ], + "prefixes": [], + "transport": "sidecar", + "base_url": "https://api.venice.ai/api/v1/chat/completions", + "get_url": "https://venice.ai/settings/api" + }, + { + "id": "surplus", + "label": "Surplus Intelligence", + "secrets": [ + "SURPLUS_API_KEY" + ], + "prefixes": [ + "inf_" + ], + "transport": "sidecar", + "base_url": "https://www.surplusintelligence.ai/api/inference/v1/chat/completions", + "get_url": "https://surplusintelligence.ai" + }, + { + "id": "grok", + "label": "Grok (xAI)", + "secrets": [ + "XAI_API_KEY" + ], + "prefixes": [ + "xai-" + ], + "transport": "anthropic-compatible", + "base_url": "https://api.x.ai", + "get_url": "https://console.x.ai" + }, + { + "id": "glm", + "label": "GLM (Z.AI)", + "secrets": [ + "GLM_API_KEY", + "ZAI_API_KEY" + ], + "prefixes": [], + "transport": "anthropic-compatible", + "base_url": "https://api.z.ai/api/anthropic", + "get_url": "https://z.ai" + }, + { + "id": "hivemindos", + "label": "HivemindOS Models", + "secrets": [ + "HIVEMINDOS_CREDIT_TOKEN" + ], + "prefixes": [], + "transport": "sidecar", + "base_url": "https://hivemindos-paid-agent-gateway.hivemindos.workers.dev/api/paid-agents/default/chat/completions", + "get_url": "https://hivemindos.liamvisionary.com" + } + ] +} diff --git a/harness-adapter/harnesses.json b/harness-adapter/harnesses.json index 051c6d6dece..e436786044b 100644 --- a/harness-adapter/harnesses.json +++ b/harness-adapter/harnesses.json @@ -1,5 +1,5 @@ { - "note": "harness-adapter capability manifest - the local analog of a UHP GET /v1/harnesses discovery response. Generated by bin/generate-harnesses-json from each adapters/.sh rh-meta block; do not hand-edit. Describes harness capabilities only; default-model policy lives in scripts/resolve-harness.sh.", + "note": "harness-adapter capability manifest - the local analog of a UHP GET /v1/harnesses discovery response. Generated by bin/generate-harnesses-json from each adapters/.sh rh-meta block; do not hand-edit. credentials are ordered most preferred first, exactly as scripts/resolve-harness.sh picks them, and auth is derived from them; default_model mirrors its DEFAULT_HM. resolve-harness.sh stays the runtime source; scripts/tests/test_credential_manifest.sh keeps this mirror honest.", "contract": { "stdin": "prompt", "stdout": "{result, usage{input_tokens,output_tokens,cache_read_input_tokens,cache_creation_input_tokens}, session_id?, total_cost_usd?}", @@ -9,14 +9,14 @@ "124": "timeout" } }, - "generated": "2026-10-01T20:03:33Z", + "generated": "2026-10-02T19:46:38Z", "count": 9, "harnesses": [ { "id": "claude", "label": "Claude Code", "cli": { - "install": "npm i -g @anthropic-ai/claude-code", + "install": "npm i -g @anthropic-ai/claude-code@2.1.287", "bin": "claude", "min_version": "2.1.287" }, @@ -29,6 +29,32 @@ "mcp": "native", "max_turns": "native", "claude_md": "native+imports", + "default_model": "claude-sonnet-5-5", + "credentials": [ + { + "secret": "CLAUDE_CODE_OAUTH_TOKEN", + "kind": "oauth_token", + "auth_mode": "native-oauth", + "label": "Claude subscription (Pro/Max)", + "prefix": "sk-ant-oat", + "get_url": "https://claude.ai", + "login_cmd": "claude setup-token", + "aeon_cmd": "./aeon auth --harness claude-code", + "expires": "about 1 year (claude setup-token mints a long-lived token)", + "refresh": "re-run ./aeon auth --harness claude-code before it expires" + }, + { + "secret": "ANTHROPIC_API_KEY", + "kind": "api_key", + "auth_mode": "native-key", + "label": "Anthropic API key (pay as you go)", + "prefix": "sk-ant-api", + "get_url": "https://console.anthropic.com/settings/keys", + "aeon_cmd": "./aeon auth --key " + } + ], + "gateways": "gateways.json", + "native_control_path": "gateway", "auth": { "native_oauth": [ "CLAUDE_CODE_OAUTH_TOKEN" @@ -37,14 +63,13 @@ "ANTHROPIC_API_KEY" ], "openrouter": false - }, - "native_control_path": "gateway" + } }, { "id": "codex", "label": "OpenAI Codex CLI", "cli": { - "install": "npm i -g @openai/codex", + "install": "npm i -g --ignore-scripts @openai/codex@0.159.3", "bin": "codex", "min_version": "0.159.3" }, @@ -57,6 +82,42 @@ "mcp": "native+inline-toml", "max_turns": "timeout", "claude_md": "fallback", + "default_model": "openai/gpt-6-luna", + "credentials": [ + { + "secret": "CODEX_AUTH", + "kind": "oauth_capture", + "auth_mode": "native-oauth", + "label": "ChatGPT login (Plus/Pro)", + "get_url": "https://chatgpt.com", + "login_cmd": "codex login", + "aeon_cmd": "./aeon auth --harness codex", + "cred_paths": [ + ".codex/auth.json" + ], + "expires": "codex refreshes the access token from the captured refresh token at run start", + "refresh": "re-run ./aeon auth --harness codex if a run reports a login error" + }, + { + "secret": "OPENAI_API_KEY", + "kind": "api_key", + "auth_mode": "native-key", + "label": "OpenAI API key", + "prefix": "sk-", + "get_url": "https://platform.openai.com/api-keys", + "aeon_cmd": "./aeon auth --harness codex --key " + }, + { + "secret": "OPENROUTER_API_KEY", + "kind": "api_key", + "auth_mode": "openrouter", + "label": "OpenRouter key (one key covers most harnesses)", + "prefix": "sk-or-", + "get_url": "https://openrouter.ai/settings/keys", + "aeon_cmd": "./aeon secrets set OPENROUTER_API_KEY --stdin" + } + ], + "native_control_path": "run-harness", "auth": { "native_oauth": [ "CODEX_AUTH" @@ -65,8 +126,7 @@ "OPENAI_API_KEY" ], "openrouter": true - }, - "native_control_path": "run-harness" + } }, { "id": "cursor", @@ -74,7 +134,7 @@ "cli": { "install": "curl -fsSL https://cursor.com/install | bash", "bin": "agent", - "min_version": "latest" + "min_version": "2026.10.01-14929f9" }, "invoke": "agent -p --trust --output-format json", "round_trip": true, @@ -85,14 +145,25 @@ "mcp": "native", "max_turns": "none", "claude_md": "native", + "default_model": "auto", + "credentials": [ + { + "secret": "CURSOR_API_KEY", + "kind": "api_key", + "auth_mode": "native-key", + "label": "Cursor API key", + "get_url": "https://cursor.com/dashboard", + "aeon_cmd": "./aeon auth --harness cursor --key " + } + ], + "native_control_path": "run-harness", "auth": { "native_oauth": [], "native_key": [ "CURSOR_API_KEY" ], "openrouter": false - }, - "native_control_path": "run-harness" + } }, { "id": "fx", @@ -100,7 +171,7 @@ "cli": { "install": "curl -fsSL https://fx.sh/setup.sh | bash", "bin": "fx", - "min_version": "0.0.5" + "min_version": "0.0.12" }, "invoke": "fx ask --json --auto --no-save", "round_trip": true, @@ -111,6 +182,28 @@ "mcp": "native", "max_turns": "native", "claude_md": "native", + "default_model": "default", + "credentials": [ + { + "secret": "AI_GATEWAY_API_KEY", + "kind": "api_key", + "auth_mode": "native-key", + "label": "Vercel AI Gateway key", + "get_url": "https://vercel.com/docs/ai-gateway", + "aeon_cmd": "./aeon auth --harness fx --key " + }, + { + "secret": "VERCEL_OIDC_TOKEN", + "kind": "oidc", + "auth_mode": "native-key", + "label": "Vercel OIDC token", + "get_url": "https://vercel.com/docs/oidc", + "login_cmd": "vercel env pull", + "expires": "about 12h (short-lived; best where Vercel provisions it for you)", + "refresh": "pull a fresh token with vercel env pull and set it again" + } + ], + "native_control_path": "run-harness", "auth": { "native_oauth": [], "native_key": [ @@ -118,14 +211,13 @@ "VERCEL_OIDC_TOKEN" ], "openrouter": false - }, - "native_control_path": "run-harness" + } }, { "id": "grok", "label": "Grok Build", "cli": { - "install": "npm i -g @xai-official/grok", + "install": "npm i -g @xai-official/grok@1.0.46", "bin": "grok", "min_version": "1.0.46" }, @@ -138,6 +230,37 @@ "mcp": "native+trust", "max_turns": "native", "claude_md": "native", + "default_model": "grok-4.7", + "credentials": [ + { + "secret": "GROK_CREDENTIALS", + "kind": "oauth_capture", + "auth_mode": "native-oauth", + "label": "X account login (grok login)", + "get_url": "https://x.ai/grok", + "login_cmd": "grok login --device-auth", + "aeon_cmd": "./aeon auth --harness grok", + "cred_paths": [ + ".grok/auth.json" + ], + "expires": "access token about 6h; xAI rotates the refresh token on every refresh", + "refresh": "scripts/run-grok.sh refreshes before each run and writes the rotated token back to GROK_CREDENTIALS, which needs GH_SECRETS_PAT or GH_GLOBAL", + "aux_secrets": [ + "GH_SECRETS_PAT", + "GH_GLOBAL" + ] + }, + { + "secret": "XAI_API_KEY", + "kind": "api_key", + "auth_mode": "native-key", + "label": "xAI API key", + "prefix": "xai-", + "get_url": "https://console.x.ai", + "aeon_cmd": "./aeon auth --harness grok --key " + } + ], + "native_control_path": "run-grok.sh", "auth": { "native_oauth": [ "GROK_CREDENTIALS" @@ -145,9 +268,8 @@ "native_key": [ "XAI_API_KEY" ], - "openrouter": true - }, - "native_control_path": "run-grok.sh" + "openrouter": false + } }, { "id": "hermes", @@ -155,7 +277,7 @@ "cli": { "install": "curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash", "bin": "hermes", - "min_version": "latest" + "min_version": "2026.9.24" }, "invoke": "hermes --usage-file -z prompt", "round_trip": true, @@ -166,20 +288,46 @@ "mcp": "native", "max_turns": "native", "claude_md": "native", + "default_model": "default", + "credentials": [ + { + "secret": "HERMES_AUTH", + "kind": "oauth_capture", + "auth_mode": "native-oauth", + "label": "Nous Portal login", + "get_url": "https://portal.nousresearch.com", + "login_cmd": "hermes auth add nous --type oauth", + "aeon_cmd": "./aeon auth --harness hermes", + "cred_paths": [ + ".hermes/auth.json", + ".hermes/config.yaml" + ], + "refresh": "re-run ./aeon auth --harness hermes if a run reports a login error" + }, + { + "secret": "OPENROUTER_API_KEY", + "kind": "api_key", + "auth_mode": "openrouter", + "label": "OpenRouter key (one key covers most harnesses)", + "prefix": "sk-or-", + "get_url": "https://openrouter.ai/settings/keys", + "aeon_cmd": "./aeon secrets set OPENROUTER_API_KEY --stdin" + } + ], + "native_control_path": "run-harness", "auth": { "native_oauth": [ "HERMES_AUTH" ], "native_key": [], "openrouter": true - }, - "native_control_path": "run-harness" + } }, { "id": "kimi", "label": "Kimi Code", "cli": { - "install": "", + "install": "npm i -g --ignore-scripts @moonshot-ai/kimi-code@2.1.1", "bin": "kimi", "min_version": "2.1.1" }, @@ -192,6 +340,42 @@ "mcp": "native+overlay", "max_turns": "timeout", "claude_md": "native", + "default_model": "moonshotai/kimi-k2.6", + "credentials": [ + { + "secret": "KIMI_AUTH", + "kind": "oauth_capture", + "auth_mode": "native-oauth", + "label": "Kimi (Moonshot) login", + "get_url": "https://www.kimi.com", + "login_cmd": "kimi login", + "aeon_cmd": "./aeon auth --harness kimi", + "cred_paths": [ + ".kimi-code/credentials", + ".kimi-code/config.toml" + ], + "refresh": "re-run ./aeon auth --harness kimi if a run reports a login error" + }, + { + "secret": "MOONSHOT_API_KEY", + "kind": "api_key", + "auth_mode": "native-key", + "label": "Moonshot API key", + "prefix": "sk-", + "get_url": "https://platform.moonshot.ai/console/api-keys", + "aeon_cmd": "./aeon auth --harness kimi --key " + }, + { + "secret": "OPENROUTER_API_KEY", + "kind": "api_key", + "auth_mode": "openrouter", + "label": "OpenRouter key (one key covers most harnesses)", + "prefix": "sk-or-", + "get_url": "https://openrouter.ai/settings/keys", + "aeon_cmd": "./aeon secrets set OPENROUTER_API_KEY --stdin" + } + ], + "native_control_path": "run-harness", "auth": { "native_oauth": [ "KIMI_AUTH" @@ -200,14 +384,13 @@ "MOONSHOT_API_KEY" ], "openrouter": true - }, - "native_control_path": "run-harness" + } }, { "id": "pi", "label": "Pi", "cli": { - "install": "npm i -g --ignore-scripts @earendil-works/pi-coding-agent", + "install": "npm i -g --ignore-scripts @earendil-works/pi-coding-agent@0.99.2", "bin": "pi", "min_version": "0.99.2" }, @@ -220,21 +403,63 @@ "mcp": "native", "max_turns": "timeout", "claude_md": "native", + "default_model": "deepseek/deepseek-v4-flash", + "credentials": [ + { + "secret": "ANTHROPIC_API_KEY", + "kind": "api_key", + "auth_mode": "native-key", + "label": "Anthropic API key", + "prefix": "sk-ant-api", + "get_url": "https://console.anthropic.com/settings/keys", + "aeon_cmd": "./aeon auth --harness pi --key " + }, + { + "secret": "ANTHROPIC_OAUTH_TOKEN", + "kind": "oauth_token", + "auth_mode": "native-key", + "label": "Claude subscription token", + "prefix": "sk-ant-oat", + "get_url": "https://claude.ai", + "login_cmd": "claude setup-token", + "aeon_cmd": "./aeon auth --harness pi --key ", + "expires": "about 1 year (claude setup-token mints a long-lived token)" + }, + { + "secret": "OPENAI_API_KEY", + "kind": "api_key", + "auth_mode": "native-key", + "label": "OpenAI API key", + "prefix": "sk-", + "get_url": "https://platform.openai.com/api-keys", + "aeon_cmd": "./aeon auth --harness pi --key " + }, + { + "secret": "OPENROUTER_API_KEY", + "kind": "api_key", + "auth_mode": "openrouter", + "label": "OpenRouter key (one key covers most harnesses)", + "prefix": "sk-or-", + "get_url": "https://openrouter.ai/settings/keys", + "aeon_cmd": "./aeon secrets set OPENROUTER_API_KEY --stdin" + } + ], + "native_control_path": "run-harness", "auth": { "native_oauth": [], "native_key": [ "ANTHROPIC_API_KEY", + "ANTHROPIC_OAUTH_TOKEN", "OPENAI_API_KEY" ], "openrouter": true - }, - "native_control_path": "run-harness" + } }, { "id": "vibe", "label": "Mistral Vibe", "cli": { - "install": "", + "install": "pipx install mistral-vibe==2.25.8", "bin": "vibe", "min_version": "2.25.8" }, @@ -247,14 +472,34 @@ "mcp": "native", "max_turns": "native", "claude_md": "native", + "default_model": "mistralai/mistral-medium-3-5", + "credentials": [ + { + "secret": "MISTRAL_API_KEY", + "kind": "api_key", + "auth_mode": "native-key", + "label": "Mistral API key", + "get_url": "https://console.mistral.ai/api-keys", + "aeon_cmd": "./aeon auth --harness vibe --key " + }, + { + "secret": "OPENROUTER_API_KEY", + "kind": "api_key", + "auth_mode": "openrouter", + "label": "OpenRouter key (one key covers most harnesses)", + "prefix": "sk-or-", + "get_url": "https://openrouter.ai/settings/keys", + "aeon_cmd": "./aeon secrets set OPENROUTER_API_KEY --stdin" + } + ], + "native_control_path": "run-harness", "auth": { "native_oauth": [], "native_key": [ "MISTRAL_API_KEY" ], "openrouter": true - }, - "native_control_path": "run-harness" + } } ] } diff --git a/plugin/skills/aeon/references/ci.md b/plugin/skills/aeon/references/ci.md index aa576576a44..dd9039925fb 100644 --- a/plugin/skills/aeon/references/ci.md +++ b/plugin/skills/aeon/references/ci.md @@ -16,7 +16,7 @@ Thirteen `ci-*.yml` workflows. Twelve are **path-filtered** and fire on `pull_re | `ci-readme-catalog` | `.github/README.md`, `catalog/*.json`, `docs/skill-packs.md`, `docs/aeon-setup.md`, `docs/examples/README.md`, the hero SVG | README skill tables and skill counts match the catalog | `node scripts/validate-readme-catalog.mjs` | | `ci-tests` | `scripts/**`, `bin/**`, `aeon.yml`, `.github/workflows/aeon.yml`, `harness-adapter/adapters/**` | the `scripts/tests/` suites + config validation | see below | | `ci-shellcheck` | `aeon`, `scripts/**`, `bin/**`, `harness-adapter/**`, `skills/**/*.sh` | shellcheck on the tracked shell surface | `bash scripts/lint-shell.sh` | -| `ci-harnesses-json` | `harness-adapter/adapters/**`, `harness-adapter/bin/generate-harnesses-json`, `harness-adapter/harnesses.json` | committed harness manifest == fresh regen | `harness-adapter/bin/generate-harnesses-json` | +| `ci-harnesses-json` | `harness-adapter/adapters/**`, `harness-adapter/bin/generate-harnesses-json`, `harness-adapter/harnesses.json`, `harness-adapter/gateways.json` | committed harness + gateway manifests == fresh regen | `harness-adapter/bin/generate-harnesses-json` | | `ci-capabilities-parity` | `bin/install-skill-pack`, `docs/CAPABILITIES.md` | capabilities taxonomy in sync across both | `bash scripts/check-capabilities-parity.sh` | | `ci-skill-packs` | `catalog/skill-packs.json`, `docs/community-skill-packs.md`, `bin/install-skill-pack`, `skills/security/trusted-sources.txt` | community registry well-formed + matches the Listed packs table in `docs/community-skill-packs.md`; no unbacked `trust_level: trusted` | `node scripts/validate-skill-packs.mjs` | | `ci-agents-md` | `CLAUDE.md`, `STRATEGY.md`, `AGENTS.md`, `scripts/gen-agents-md.js` | `AGENTS.md` regenerated from `CLAUDE.md` (with `STRATEGY.md` inlined) | `node scripts/gen-agents-md.js --check` | diff --git a/scripts/resolve-harness.sh b/scripts/resolve-harness.sh index 0595c64844e..0fa3b5c37f2 100755 --- a/scripts/resolve-harness.sh +++ b/scripts/resolve-harness.sh @@ -31,7 +31,8 @@ # or `eval` after review): # HARNESS claude | grok | codex | pi | vibe | kimi | fx | cursor | hermes # AUTH_MODE native-oauth | native-key | openrouter | gateway (claude only: -# a key from aeon.yml's gateway: block, see below) +# a key from aeon.yml's gateway: block, see below) | none +# (cursor with no CURSOR_API_KEY: it has no fallback) # HARNESS_MODEL the model label for logs/records ("(native:…)" on native auth) # MODEL_ARG what to pass as `run-harness --model`, or empty for "the # harness's own staged config decides" (always empty on claude: @@ -92,7 +93,10 @@ case "$HARNESS" in codex) if [ -n "${CODEX_AUTH:-}" ]; then AUTH_MODE="native-oauth"; elif [ -n "${OPENAI_API_KEY:-}" ]; then AUTH_MODE="native-key"; fi ;; kimi) if [ -n "${KIMI_AUTH:-}" ]; then AUTH_MODE="native-oauth"; elif [ -n "${MOONSHOT_API_KEY:-}" ]; then AUTH_MODE="native-key"; fi ;; hermes) if [ -n "${HERMES_AUTH:-}" ]; then AUTH_MODE="native-oauth"; fi ;; - cursor) if [ -n "${CURSOR_API_KEY:-}" ]; then AUTH_MODE="native-key"; fi ;; + # cursor has no OpenRouter path either (install-harness.sh fails closed without + # CURSOR_API_KEY), so with no key it is labelled `none` rather than a fallback + # that does not exist. Label only: the run still stops at the install step. + cursor) if [ -n "${CURSOR_API_KEY:-}" ]; then AUTH_MODE="native-key"; else AUTH_MODE="none"; fi ;; vibe) if [ -n "${MISTRAL_API_KEY:-}" ]; then AUTH_MODE="native-key"; fi ;; pi) if [ -n "${ANTHROPIC_API_KEY:-}" ] || [ -n "${ANTHROPIC_OAUTH_TOKEN:-}" ] || [ -n "${OPENAI_API_KEY:-}" ]; then AUTH_MODE="native-key"; fi ;; # fx has no OpenRouter path at all (confirmed: no mention anywhere in its diff --git a/scripts/tests/test_credential_manifest.sh b/scripts/tests/test_credential_manifest.sh new file mode 100644 index 00000000000..ca934a18a46 --- /dev/null +++ b/scripts/tests/test_credential_manifest.sh @@ -0,0 +1,317 @@ +#!/usr/bin/env bash +# The credential manifest (harness-adapter/harnesses.json `credentials` + +# `default_model`, and harness-adapter/gateways.json) is a read-only MIRROR of +# decisions that live in code. This suite fails the moment the mirror and the +# code disagree, so `aeon init`, bin/onboard and the dashboard can trust it. +# +# Held against, per harness: +# 1. scripts/resolve-harness.sh - credential precedence (parsed statically AND +# exercised: each credential alone must yield its auth_mode, and a more +# preferred one must win over a less preferred one), plus default_model. +# 2. apps/dashboard/lib/harness-auth.ts - authSecrets order and the OAuth +# capture's secret + credPaths (read-only parse; that file is not edited here). +# 3. .github/workflows/aeon.yml - every credential is bound in the steps that +# read it (Resolve harness, Install harness CLI / Run). +# 4. scripts/install-harness.sh + scripts/resolve-harness.sh - an +# OPENROUTER_API_KEY entry exists only where a real OpenRouter path does. +# 5. the install sites - cli.min_version (and the pinned install command) match +# the version the workflows actually install. +# And for the gateway cascade (gateways.json): +# 6. scripts/llm-gateway.sh - same ids in the default GATEWAY_ORDER, same +# secret(s) per provider, a route arm for each, base URL host in that arm. +# 7. apps/dashboard/lib/gateway-registry.ts - same slugs, labels, secret names +# and key prefixes; constants.ts CLAUDE_AUTH_SECRETS starts with claude's +# own credentials in order. +# +# Run: bash scripts/tests/test_credential_manifest.sh +set -euo pipefail + +ROOT=$(cd "$(dirname "$0")/../.." && pwd) + +python3 - "$ROOT" <<'PY' +import json +import os +import re +import subprocess +import sys +import tempfile +from urllib.parse import urlparse + +import yaml + +root = sys.argv[1] +read = lambda rel: open(os.path.join(root, rel), encoding="utf-8").read() + +manifest = json.loads(read("harness-adapter/harnesses.json")) +gateways = json.loads(read("harness-adapter/gateways.json"))["gateways"] +H = {h["id"]: h for h in manifest["harnesses"]} +resolve_src = read("scripts/resolve-harness.sh") +install_src = read("scripts/install-harness.sh") +gateway_src = read("scripts/llm-gateway.sh") +harness_auth_src = read("apps/dashboard/lib/harness-auth.ts") +registry_src = read("apps/dashboard/lib/gateway-registry.ts") +constants_src = read("apps/dashboard/lib/constants.ts") +workflow = yaml.safe_load(read(".github/workflows/aeon.yml")) + +errors = [] +checks = 0 + + +def check(ok, msg): + global checks + checks += 1 + if not ok: + errors.append(msg) + + +def secrets_of(h): + return [c["secret"] for c in H[h]["credentials"]] + + +def native_secrets_of(h): + return [c["secret"] for c in H[h]["credentials"] if c["auth_mode"] != "openrouter"] + + +# --- 1a. resolve-harness.sh precedence, parsed -------------------------------- +block = re.search(r'^AUTH_MODE="openrouter"\ncase "\$HARNESS" in\n(.*?)\n^esac', resolve_src, re.S | re.M) +check(block is not None, "resolve-harness.sh: could not find the AUTH_MODE case block") +order = {} +if block: + body = block.group(1) + arms = list(re.finditer(r'^ ([a-z]+)\)', body, re.M)) + for i, m in enumerate(arms): + end = arms[i + 1].start() if i + 1 < len(arms) else len(body) + arm = body[m.start():end] + if m.group(1) == "claude": + # Only the auto-pick secrets decide claude's label; the gateway pin is aeon.yml. + arm = arm[arm.index('if [ "$GW_PROVIDER" = "auto" ]'):] + names = [] + for n in re.findall(r'\$\{([A-Z][A-Z0-9_]*):-\}', arm): + if n not in names: + names.append(n) + order[m.group(1)] = names +check(set(order) == set(H), f"resolve-harness.sh AUTH_MODE arms {sorted(order)} != manifest harnesses {sorted(H)}") +for h in H: + if h in order: + check(native_secrets_of(h) == order[h], + f"{h}: manifest credential order {native_secrets_of(h)} != resolve-harness.sh precedence {order[h]}") + +# --- 1b. resolve-harness.sh precedence, exercised ------------------------------ +ALL_CRED_VARS = sorted({c["secret"] for h in H.values() for c in h["credentials"]} + | {s for g in gateways for s in g["secrets"]}) + + +def resolve(harness, env_extra): + with tempfile.TemporaryDirectory() as ws: + with open(os.path.join(ws, "aeon.yml"), "w") as f: + f.write(f"model: claude-sonnet-5-5\nharness: {harness}\nskills:\n heartbeat: {{ enabled: true }}\n") + env = {k: v for k, v in os.environ.items() if k not in ALL_CRED_VARS + and k not in ("INPUT_HARNESS", "INPUT_MODEL", "HARNESS_MODEL", "GATEWAY_ORDER")} + env.update(env_extra) + out = subprocess.run(["bash", os.path.join(root, "scripts/resolve-harness.sh")], cwd=ws, env=env, + capture_output=True, text=True, check=True).stdout + return dict(line.split("=", 1) for line in out.splitlines() if "=" in line) + + +for h, spec in H.items(): + creds = spec["credentials"] + for c in creds: + got = resolve(h, {c["secret"]: "x"})["AUTH_MODE"] + check(got == c["auth_mode"], f"{h}: {c['secret']} alone resolves AUTH_MODE={got}, manifest says {c['auth_mode']}") + for i, a in enumerate(creds): + for b in creds[i + 1:]: + if a["auth_mode"] == b["auth_mode"]: + continue + got = resolve(h, {a["secret"]: "x", b["secret"]: "x"})["AUTH_MODE"] + check(got == a["auth_mode"], + f"{h}: with {a['secret']} and {b['secret']} both set resolve picks {got}, but the manifest ranks {a['secret']} first") + +# --- 1c. default_model mirrors DEFAULT_HM ------------------------------------- +dcase = re.search(r'case "\$HARNESS" in\n(.*?)\nesac\nHM=', resolve_src, re.S) +check(dcase is not None, "resolve-harness.sh: could not find the DEFAULT_HM case block") +defaults = dict(re.findall(r'^\s*([a-z0-9]+)\)\s*DEFAULT_HM="([^"]+)"', dcase.group(1), re.M)) if dcase else {} +claude_default = re.search(r'CONFIG_MODEL:-(claude-[a-z0-9-]+)\}', resolve_src) +grok_default = re.search(r'case "\$MODEL" in claude-\*\|default\|""\) MODEL="([^"]+)"', read(".github/workflows/aeon.yml")) +for h, spec in H.items(): + want = spec["default_model"] + if h in defaults: + check(want == defaults[h], f"{h}: default_model {want} != resolve-harness.sh DEFAULT_HM {defaults[h]}") + elif h == "claude": + check(claude_default and want == claude_default.group(1), + f"claude: default_model {want} != resolve-harness.sh claude fallback {claude_default and claude_default.group(1)}") + elif h == "grok": + # grok never consumes DEFAULT_HM: aeon.yml's Run step swaps a claude-* id + # for grok's own default. + check(grok_default and want == grok_default.group(1), + f"grok: default_model {want} != aeon.yml grok fallback {grok_default and grok_default.group(1)}") + else: + # Not in the case block -> the generic fallback, which the harness never + # consumes (MODEL_ARG stays empty), so the harness's own default runs. + check(want == "default", f"{h}: has no DEFAULT_HM arm, so default_model must be 'default' (got {want})") + for c in spec["credentials"]: + check(resolve(h, {c["secret"]: "x"})["MODEL_ARG"] == "", + f"{h}: default_model is 'default' but resolve forwards a model with {c['secret']}") + +# --- 2. harness-auth.ts authSecrets + OAuth capture ---------------------------- +specs = re.search(r'const HARNESS_AUTH_SPECS = \{(.*?)\n\} satisfies', harness_auth_src, re.S) +check(specs is not None, "harness-auth.ts: could not find HARNESS_AUTH_SPECS") +covered = [] +if specs: + body = specs.group(1) + entries = list(re.finditer(r'^ ([a-z]+): \{', body, re.M)) + for i, m in enumerate(entries): + h = m.group(1) + covered.append(h) + end = entries[i + 1].start() if i + 1 < len(entries) else len(body) + entry = body[m.start():end] + auth = re.search(r'authSecrets: \[([^\]]*)\]', entry) + ts_secrets = re.findall(r"'([A-Z0-9_]+)'", auth.group(1)) if auth else [] + check(h in H, f"harness-auth.ts covers {h}, which the manifest does not list") + if h not in H: + continue + check(secrets_of(h) == ts_secrets, f"{h}: manifest credentials {secrets_of(h)} != harness-auth.ts authSecrets {ts_secrets}") + oauth = re.search(r"oauth: \{.*?secret: '([A-Z0-9_]+)'", entry, re.S) + paths = re.search(r"credPaths: \[([^\]]*)\]", entry) + cap = [c for c in H[h]["credentials"] if c["kind"] == "oauth_capture"] + if oauth: + check(len(cap) == 1 and cap[0]["secret"] == oauth.group(1), + f"{h}: harness-auth.ts oauth secret {oauth.group(1)} is not the manifest's oauth_capture credential") + if cap and paths: + check(cap[0].get("cred_paths") == re.findall(r"'([^']+)'", paths.group(1)), + f"{h}: cred_paths {cap[0].get('cred_paths')} != harness-auth.ts credPaths") +check(sorted(covered) == sorted(set(H) - {"claude", "grok"}), + f"harness-auth.ts covers {sorted(covered)}; expected every harness but claude/grok") + +# --- 3. aeon.yml binds every credential where it is read ----------------------- +steps = {} +for job in workflow["jobs"].values(): + for st in job.get("steps", []): + if st.get("name") in ("Resolve harness", "Install harness CLI", "Run"): + steps[st["name"]] = set((st.get("env") or {}).keys()) +for name in ("Resolve harness", "Install harness CLI", "Run"): + check(name in steps, f"aeon.yml: no '{name}' step") +for h, spec in H.items(): + for c in spec["credentials"]: + s = c["secret"] + if c["auth_mode"] != "openrouter": + check(s in steps.get("Resolve harness", set()), + f"aeon.yml 'Resolve harness' env lacks {s} ({h} cannot be detected on it)") + where = steps.get("Run", set()) if h == "claude" else steps.get("Install harness CLI", set()) | steps.get("Run", set()) + check(s in where, f"aeon.yml: {s} ({h}) is not bound in the step that stages/runs {h}") + for aux in c.get("aux_secrets", []): + check(aux in steps.get("Install harness CLI", set()), + f"aeon.yml 'Install harness CLI' env lacks {aux} ({h} {s} refresh)") +# Gateway secrets must reach the Run step, where llm-gateway.sh is sourced. +# Known gap, kept visible: XAI_API_KEY was dropped from the Run step's shared env +# (per-skill least privilege; it is injected only for skills that list it in +# `requires:`), so the `grok` GATEWAY resolves only for those skills. The grok +# HARNESS is unaffected (Install harness CLI binds it). This entry must be +# removed the day the binding comes back - the test fails if it does. +RUN_ENV_KNOWN_GAPS = {"XAI_API_KEY"} +for g in gateways: + for s in g["secrets"]: + if s in RUN_ENV_KNOWN_GAPS: + check(s not in steps.get("Run", set()), f"aeon.yml Run env now binds {s}: drop it from RUN_ENV_KNOWN_GAPS") + continue + check(s in steps.get("Run", set()), f"aeon.yml 'Run' env lacks gateway secret {s} ({g['id']})") + +# --- 4. OPENROUTER_API_KEY only where a real OpenRouter path exists ------------ +def install_arm(h): + lines = install_src.split("\n") + start = next((i for i, l in enumerate(lines) if re.match(rf'^ (?:[a-z|"]+\|)?{h}(?:\|[a-z|"]+)?\)', l)), None) + if start is None: + return "" + out = [] + for l in lines[start + 1:]: + if re.match(r'^ [a-z*|"]+\)', l) or l.startswith("esac"): + break + if not l.lstrip().startswith("#"): + out.append(l) + return "\n".join(out) + + +or_model = re.search(r'if \[ "\$AUTH_MODE" = "openrouter" \]; then\n case "\$HARNESS" in\n(.*?)\n esac', resolve_src, re.S) +or_model_harnesses = set(re.findall(r'^\s*([a-z]+)\)', or_model.group(1), re.M)) if or_model else set() +for h in H: + has_or = "OPENROUTER_API_KEY" in secrets_of(h) + real = bool(re.search(r'openrouter|OPENROUTER_API_KEY', install_arm(h))) or h in or_model_harnesses + if h == "claude": + real = False # claude reaches OpenRouter as a gateway (gateways.json), not a credential + check(has_or == real, f"{h}: manifest {'lists' if has_or else 'omits'} OPENROUTER_API_KEY but the install/resolve path {'has' if real else 'has no'} OpenRouter route") + +# --- 5. CLI pins match the install sites --------------------------------------- +def first(pattern, text, group=1): + m = re.search(pattern, text, re.M) + return m.group(group) if m else None + + +pins = { + "claude": first(r'@anthropic-ai/claude-code@([0-9][0-9.]*)', read(".github/workflows/aeon.yml")), + "grok": first(r'^GROK_CLI_VERSION="\$\{GROK_CLI_VERSION:-([0-9][0-9.]*)\}"$', read("scripts/run-grok.sh")), + "codex": first(r'@openai/codex@([0-9][0-9.]*)', install_src), + "pi": first(r'@earendil-works/pi-coding-agent@([0-9][0-9.]*)', install_src), + "kimi": first(r'@moonshot-ai/kimi-code@([0-9][0-9.]*)', install_src), + "vibe": first(r'mistral-vibe==([0-9][0-9.]*)', install_src), + "fx": first(r'^FX_PIN=v?([0-9][0-9.]*)', install_src), + "cursor": first(r'^CURSOR_PIN=(\S+)', install_src), + "hermes": first(r'^HERMES_PIN=\S+\s+# hermes-agent release v(\S+)', install_src), +} +for h, pin in pins.items(): + check(pin is not None, f"{h}: could not read the install pin") + mv = H[h]["cli"]["min_version"] + check(mv == pin, f"{h}: cli.min_version {mv} != install pin {pin}") + inst = H[h]["cli"]["install"] + check(bool(inst), f"{h}: cli.install is empty") + if inst.startswith(("npm ", "pipx ")): + check(inst.endswith(("@" + str(pin), "==" + str(pin))), f"{h}: cli.install '{inst}' is not pinned to {pin}") + +# --- 6. gateways.json vs llm-gateway.sh --------------------------------------- +gw_ids = [g["id"] for g in gateways] +default_order = first(r'\$\{GATEWAY_ORDER:-([a-z ]+)\}', gateway_src) +check(default_order is not None and gw_ids == default_order.split(), + f"gateways.json order {gw_ids} != llm-gateway.sh default GATEWAY_ORDER {default_order}") +present = dict(re.findall(r'^ ([a-z]+)\)\s+(\[ -n .*?\]) ;;$', gateway_src, re.M)) +route = gateway_src[gateway_src.index("# --- route"):] +route_arms = list(re.finditer(r'^ ([a-z|"]+)\)', route, re.M)) +for g in gateways: + gid = g["id"] + check(gid in present, f"llm-gateway.sh aeon_present has no arm for {gid}") + if gid in present: + check(re.findall(r'\$\{([A-Z][A-Z0-9_]*)', present[gid]) == g["secrets"], + f"{gid}: gateways.json secrets {g['secrets']} != llm-gateway.sh aeon_present {present[gid]}") + arm = next((m for m in route_arms if gid in m.group(1).split("|")), None) + check(arm is not None, f"llm-gateway.sh has no route arm for {gid}") + if arm and g["transport"] != "native": + i = route_arms.index(arm) + text = route[arm.start():route_arms[i + 1].start() if i + 1 < len(route_arms) else len(route)] + check(urlparse(g["base_url"]).netloc in text, f"{gid}: base_url host {urlparse(g['base_url']).netloc} not in its llm-gateway.sh arm") + sidecar = "start_ccr_sidecar" in text + check(sidecar == (g["transport"] == "sidecar"), f"{gid}: transport {g['transport']} but sidecar={sidecar} in llm-gateway.sh") + +# --- 7. gateways.json vs gateway-registry.ts + constants.ts -------------------- +reg = {} +for m in re.finditer(r"^ ([a-z]+): \{ label: '([^']+)', secretName: '([A-Z0-9_]+)', prefixes: \[([^\]]*)\]", registry_src, re.M): + reg[m.group(1)] = (m.group(2), m.group(3), re.findall(r"'([^']*)'", m.group(4))) +non_native = [g for g in gateways if g["transport"] != "native"] +check(set(reg) == {g["id"] for g in non_native}, + f"gateway-registry.ts slugs {sorted(reg)} != gateways.json non-native ids {sorted(g['id'] for g in non_native)}") +for g in non_native: + if g["id"] in reg: + label, secret, prefixes = reg[g["id"]] + check(label == g["label"], f"{g['id']}: registry label '{label}' != gateways.json '{g['label']}'") + check(secret == g["secrets"][0], f"{g['id']}: registry secretName {secret} != gateways.json {g['secrets'][0]}") + check(prefixes == g["prefixes"], f"{g['id']}: registry prefixes {prefixes} != gateways.json {g['prefixes']}") +cas = first(r"^export const CLAUDE_AUTH_SECRETS = \[([^\]]*)\]", constants_src) +check(cas is not None and re.findall(r"'([A-Z0-9_]+)'", cas) == secrets_of("claude"), + f"constants.ts CLAUDE_AUTH_SECRETS does not start with claude's credentials {secrets_of('claude')}") +native = [g for g in gateways if g["transport"] == "native"] +check([s for g in native for s in g["secrets"]] == secrets_of("claude"), + "gateways.json native tier must be exactly claude's own credentials, in order") + +if errors: + print("credential manifest tests FAILED:", file=sys.stderr) + for e in errors: + print(f" - {e}", file=sys.stderr) + sys.exit(1) +print(f"credential manifest tests passed ({checks} checks, {len(H)} harnesses, {len(gateways)} gateways)") +PY diff --git a/scripts/tests/test_generate_harnesses_json.sh b/scripts/tests/test_generate_harnesses_json.sh index 5ece09aa213..7253e4e8198 100644 --- a/scripts/tests/test_generate_harnesses_json.sh +++ b/scripts/tests/test_generate_harnesses_json.sh @@ -42,7 +42,7 @@ c=$(jq -r '.count' "$GENERATED"); n=$(jq -r '.harnesses | length' "$GENERATED") && pass "count=9 matches array length" || bad "count($c) != length($n) or != 9" # 5. every harness carries the required capability keys -req='["id","label","cli","invoke","round_trip","token_usage","cost","read_only","structured_output","mcp","max_turns","claude_md","auth","native_control_path"]' +req='["id","label","cli","invoke","round_trip","token_usage","cost","read_only","structured_output","mcp","max_turns","claude_md","default_model","credentials","auth","native_control_path"]' missing="$(jq -r --argjson req "$req" '.harnesses[] | select((($req) - (keys)) | length > 0) | .id' "$GENERATED")" [ -z "$missing" ] && pass "all harnesses carry required keys" || bad "missing keys on: $missing" @@ -62,13 +62,52 @@ check_enum mcp native native+trust native+inline-toml native+overlay unsupported authbad="$(jq -r '.harnesses[] | select((.auth.openrouter|type != "boolean") or (.auth.native_oauth|type != "array") or (.auth.native_key|type != "array")) | .id' "$GENERATED")" [ -z "$authbad" ] && pass "auth block shape valid" || bad "bad auth block on: $authbad" -# 8. committed manifest is not stale (generated timestamp aside) +# 7b. auth is derived from the ordered credential list, never hand-written +derived="$(jq -r '.harnesses[] | select(.auth != { + native_oauth: [.credentials[] | select(.auth_mode == "native-oauth") | .secret], + native_key: [.credentials[] | select(.auth_mode == "native-key") | .secret], + openrouter: ([.credentials[] | select(.auth_mode == "openrouter")] | length > 0) }) | .id' "$GENERATED")" +[ -z "$derived" ] && pass "auth summary matches the credential list" || bad "auth not derived from credentials on: $derived" + +# 7c. OPENROUTER_API_KEY, where present, is the last resort +orlast="$(jq -r '.harnesses[] | select(([.credentials[].secret] | index("OPENROUTER_API_KEY")) as $i | $i != null and $i != ((.credentials | length) - 1)) | .id' "$GENERATED")" +[ -z "$orlast" ] && pass "OPENROUTER_API_KEY is always the last credential" || bad "OPENROUTER_API_KEY not last on: $orlast" + +# 7d. a hand-written auth key in an adapter is rejected (it would shadow the derivation), +# and so is a credential with an unknown kind +cp -R "$WORK/ha" "$WORK/ha2" +sed -i.bak 's/"claude_md": "native+imports",/"claude_md": "native+imports", "auth": {},/' "$WORK/ha2/adapters/claude.sh" +if "$WORK/ha2/bin/generate-harnesses-json" >/dev/null 2>&1; then bad "generator accepted a hand-written auth key" +else pass "generator rejects a hand-written auth key"; fi +rm -rf "$WORK/ha2" +cp -R "$WORK/ha" "$WORK/ha2" +sed -i.bak 's/"kind": "api_key"/"kind": "password"/' "$WORK/ha2/adapters/vibe.sh" +if "$WORK/ha2/bin/generate-harnesses-json" >/dev/null 2>&1; then bad "generator accepted an unknown credential kind" +else pass "generator rejects an unknown credential kind"; fi +rm -rf "$WORK/ha2" + +# 7e. gateways.json: valid, non-empty, unique ids, claude points at it +GW="$WORK/ha/gateways.json" +if jq -e '(.gateways | length) == .count and .count > 0 and ([.gateways[].id] | length) == ([.gateways[].id] | unique | length)' "$GW" >/dev/null 2>&1; then + pass "gateways.json is valid with unique ids" +else + bad "gateways.json malformed" +fi +[ "$(jq -r '.harnesses[] | select(.id == "claude") | .gateways' "$GENERATED")" = "gateways.json" ] \ + && pass "claude points at gateways.json" || bad "claude harness missing gateways pointer" + +# 8. committed manifests are not stale (generated timestamp aside) norm() { sed -E 's/"generated": *"[^"]*"/"generated":""/' "$1"; } if diff <(norm "$COMMITTED") <(norm "$GENERATED") >/dev/null 2>&1; then pass "committed harnesses.json matches a fresh regen" else bad "committed harnesses.json is stale - run harness-adapter/bin/generate-harnesses-json and commit" fi +if diff <(norm "$ROOT/harness-adapter/gateways.json") <(norm "$GW") >/dev/null 2>&1; then + pass "committed gateways.json matches a fresh regen" +else + bad "committed gateways.json is stale - run harness-adapter/bin/generate-harnesses-json and commit" +fi [ "$fail" = 0 ] && echo "PASS" || echo "SOME TESTS FAILED" exit $fail diff --git a/scripts/tests/test_resolve_harness.sh b/scripts/tests/test_resolve_harness.sh index 99a576b723e..78b536b5ae2 100644 --- a/scripts/tests/test_resolve_harness.sh +++ b/scripts/tests/test_resolve_harness.sh @@ -94,6 +94,8 @@ mkfixture hermes mkfixture cursor [ "$(get AUTH_MODE "" CURSOR_API_KEY=xx)" = "native-key" ] \ && pass "cursor: CURSOR_API_KEY → native-key" || bad "cursor native-key" +[ "$(get AUTH_MODE "" OPENROUTER_API_KEY=xx)" = "none" ] \ + && pass "cursor: no CURSOR_API_KEY → none (no OpenRouter fallback)" || bad "cursor none label (got '$(get AUTH_MODE "" OPENROUTER_API_KEY=xx)')" mkfixture glm [ "$(get HARNESS)" = "claude" ] \ && pass "glm: dead harness name → claude" || bad "glm dead name (got '$(get HARNESS)')" From 9a335b4071447e5eebd735e3398861d88136a8c2 Mon Sep 17 00:00:00 2001 From: Aaron Elijah Mars <61592645+aaronjmars@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:07:14 -0400 Subject: [PATCH 02/20] feat(cli): aeon init, GH_GLOBAL scope check, manifest-driven onboard ./aeon init takes a clone of aeonfun/aeon to a running instance and is safe to re-run (each step checks first, then prints what it fixed): 1. gh installed, signed in, token has repo + workflow (gh auth login --web -s workflow / gh auth refresh) 2. instance repo created from the template, not a fork (forks start with Actions off); this folder is switched to it with aeonfun/aeon kept as upstream, refusing when that could lose work; --dir clones elsewhere. Never adopts aeonfun/aeon, aaronjmars/aeon or any name that redirects. 3. gh repo set-default to the instance 4. Actions enabled and allowed to open PRs; the default token permission and an existing allowed_actions choice are left as they are; workflows GitHub left off (disabled_fork / disabled_inactivity) are re-enabled 5. GH_GLOBAL from the gh token, only when it has repo + workflow 6. model: harness menu and credential list from harnesses.json, reusing the aeon auth flows (login capture, setup-token, pasted key, gateways) 7. Telegram: bot token, then a /start nonce deep link found by polling getUpdates without an offset; 409 or timeout falls back to manual paste 8. summary checklist, then optionally the dashboard Also: - captureGithubToken (dashboard route + aeon auth --github) refuses a token without repo + workflow, or a fine-grained one it cannot inspect - aeon auth --harness grok (X login capture or xAI key) - aeon auth refuses to write secrets while gh points at the template - bin/onboard: instance + gh default checks, model check from the manifest (gateway-only setups pass), one secret list per run, --remote dispatches heartbeat (the onboard skill never existed) - codex manifest note: the capture eventually expires --- apps/cli/src/commands/auth.ts | 24 +- apps/cli/src/commands/init.ts | 755 +++++++++++++++++++++++++ apps/cli/src/grok.ts | 39 ++ apps/cli/src/index.ts | 6 + apps/cli/src/manifest.ts | 71 +++ apps/cli/src/output.ts | 17 +- apps/dashboard/lib/github-auth.test.ts | 36 +- apps/dashboard/lib/github-auth.ts | 57 +- bin/onboard | 156 +++-- harness-adapter/adapters/codex.sh | 2 +- harness-adapter/gateways.json | 2 +- harness-adapter/harnesses.json | 6 +- 12 files changed, 1115 insertions(+), 56 deletions(-) create mode 100644 apps/cli/src/commands/init.ts create mode 100644 apps/cli/src/grok.ts create mode 100644 apps/cli/src/manifest.ts diff --git a/apps/cli/src/commands/auth.ts b/apps/cli/src/commands/auth.ts index 9e950f5d98e..4cc80f41d57 100644 --- a/apps/cli/src/commands/auth.ts +++ b/apps/cli/src/commands/auth.ts @@ -4,7 +4,8 @@ import { normalizeAuthConfig } from '../../../dashboard/lib/auth-provider.ts' import { captureGithubToken } from '../../../dashboard/lib/github-auth.ts' import { HARNESS_AUTH } from '../../../dashboard/lib/harness-auth.ts' import { driveTtyLogin, captureHarnessCreds, setHarnessApiKey } from '../../../dashboard/lib/harness-auth-server.ts' -import { emit, c, fail, isDryRun, requireGh } from '../output.ts' +import { grokLogin, storeGrokKey } from '../grok.ts' +import { emit, c, fail, isDryRun, requireGh, requireInstanceRepo } from '../output.ts' const USAGE = `aeon auth — set how the agent authenticates in CI @@ -13,8 +14,12 @@ Claude harness (default): aeon auth --key Set an Anthropic / gateway key (provider auto-detected) aeon auth Same as --key (positional) aeon auth --github Copy this machine's gh token into GH_GLOBAL + (needs the repo + workflow scopes: + gh auth refresh -h github.com -s repo,workflow) -Other harnesses (--harness codex|kimi|pi|vibe|fx|cursor|hermes): +Other harnesses (--harness grok|codex|kimi|pi|vibe|fx|cursor|hermes): + aeon auth --harness grok Log in with your X account, store as GROK_CREDENTIALS + aeon auth --harness grok --key Store an xAI API key instead aeon auth --harness codex Log in with ChatGPT (browser), store as CODEX_AUTH aeon auth --harness kimi Log in with Moonshot (device code), store as KIMI_AUTH aeon auth --harness codex --key Store an OpenAI key instead of the ChatGPT login @@ -26,7 +31,7 @@ Other harnesses (--harness codex|kimi|pi|vibe|fx|cursor|hermes): (codex, kimi, pi, vibe and hermes also run on the shared OPENROUTER_API_KEY - set that in Settings.) Options: - --harness claude-code | codex | kimi | pi | vibe | fx | cursor | hermes (omit for claude-code) + --harness claude-code | grok | codex | kimi | pi | vibe | fx | cursor | hermes (omit for claude-code) --github Copy \`gh auth token\` into GH_GLOBAL --provider Force a gateway (bankr, openrouter, venice, …) — Claude only --base-url Custom HTTPS base URL (API-key auth only) — Claude only @@ -38,6 +43,7 @@ const CLAUDE_HARNESS = new Set(['claude-code', 'claude']) export async function authCommand(argv: string[]) { if (argv.includes('-h') || argv.includes('--help')) { console.log(USAGE); return } requireGh() + if (!isDryRun()) requireInstanceRepo() let values: { key?: string; provider?: string; 'base-url'?: string; oauth?: boolean; harness?: string; github?: boolean } let positionals: string[] @@ -57,6 +63,18 @@ export async function authCommand(argv: string[]) { catch (e) { fail(e instanceof Error ? e.message : 'failed to copy GitHub token') } return emit(result, () => console.log(c.green('✓ ') + `GitHub: copied gh token as ${result.secret}`)) } + // --- grok: X-account OAuth capture or an xAI key (not in HARNESS_AUTH) --- + if (values.harness === 'grok') { + const key = (values.key ?? positionals[0] ?? '').trim() + if (isDryRun()) return emit({ dryRun: true, harness: 'grok', method: key ? 'api-key' : 'oauth', secret: key ? 'XAI_API_KEY' : 'GROK_CREDENTIALS' }, () => + console.log(c.yellow('dry-run: ') + (key ? 'grok key -> secret XAI_API_KEY' : 'would run `grok login --device-auth` -> secret GROK_CREDENTIALS'))) + let res: { secret: string } + try { res = key ? await storeGrokKey(key) : grokLogin() } catch (e) { fail(e instanceof Error ? e.message : 'grok auth failed') } + return emit({ ok: true, harness: 'grok', method: key ? 'api-key' : 'oauth', secret: res.secret }, () => + console.log(c.green('✓ ') + `grok: stored as ${res.secret}. Select the harness with \`aeon config set harness grok\`.` + + (key ? '' : '\n The X login rotates its refresh token; set GH_GLOBAL (aeon auth --github) so each run can save the new one.'))) + } + // --- Non-Claude harnesses: native OAuth capture or a provider key --- // `--harness claude-code` (or `claude`) falls through to the Claude path below. if (values.harness && !CLAUDE_HARNESS.has(values.harness)) { diff --git a/apps/cli/src/commands/init.ts b/apps/cli/src/commands/init.ts new file mode 100644 index 00000000000..4eb4607ca30 --- /dev/null +++ b/apps/cli/src/commands/init.ts @@ -0,0 +1,755 @@ +// `aeon init` - one command from "I have the code" to "my agent runs on a +// schedule". Every step CHECKS first and only fixes what is missing, so it is +// safe to re-run at any point (a half-finished init just picks up where it +// stopped). Steps: +// 1. GitHub CLI: installed, signed in, token carries repo + workflow +// 2. Your instance repo: created from the aeonfun/aeon TEMPLATE (not a fork: +// forks start with Actions disabled), and this folder pointed at it +// 3. gh default repo = your instance (secrets never land on aeonfun/aeon) +// 4. Actions enabled + Actions may open PRs (the default token is left as is) +// 5. GH_GLOBAL from your gh token (only when it has repo + workflow) +// 6. Model: pick a harness and connect one of its credentials (manifest-driven) +// 7. Telegram (optional): bot token + chat id via a /start deep link +// 8. Summary checklist, then optionally start the dashboard +import { parseArgs } from 'node:util' +import { spawnSync } from 'node:child_process' +import { existsSync, readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { randomBytes } from 'node:crypto' +import { createInterface } from 'node:readline/promises' +import { REPO_ROOT } from '../../../dashboard/lib/gh.ts' +import { GH_GLOBAL_SCOPES, captureGithubToken, ghTokenScopes, missingScopes } from '../../../dashboard/lib/github-auth.ts' +import { listSecretNames, setSecret } from '../../../dashboard/lib/secrets-catalog.ts' +import { configureAuth } from '../../../dashboard/lib/auth.ts' +import { HARNESS_AUTH } from '../../../dashboard/lib/harness-auth.ts' +import { captureHarnessCreds, driveTtyLogin, setHarnessApiKey } from '../../../dashboard/lib/harness-auth-server.ts' +import { syncHarness } from '../../../dashboard/lib/gateway.ts' +import { parseConfig } from '../../../dashboard/lib/config.ts' +import { HARNESSES, type Harness } from '../../../dashboard/lib/types.ts' +import { loadGateways, loadHarnesses, runnableSecrets, type Credential, type Gateway, type HarnessManifest } from '../manifest.ts' +import { grokLogin, storeGrokKey } from '../grok.ts' +import { c, fail, isDryRun, isUpstreamRepo } from '../output.ts' + +const USAGE = `aeon init - set up your own Aeon instance, end to end (safe to re-run) + +Usage: ./aeon init [options] + +Options: + --name Name of your instance repo (default: aeon) + --private Create it private (default: public - public repos get + free GitHub Actions minutes; private ones spend your plan's) + --dir Clone your instance into and continue there, + instead of turning this folder into it + --harness Preselect the agent: ${HARNESSES.join(' | ')} + --no-telegram Skip the Telegram step + --no-dashboard Do not offer to start the dashboard at the end + -y, --yes Accept every default (still asks for keys it cannot guess) + --dry-run Show what each step would do, change nothing + +Each step prints a check (${'✓'}) or what it fixed. Run bin/onboard any time for a +read-only health check.` + +const TEMPLATE = 'aeonfun/aeon' + +type Status = 'ok' | 'fixed' | 'warn' | 'fail' | 'skip' +interface Row { step: string; status: Status; detail: string } + +interface Opts { + name: string + private: boolean + dir?: string + harness?: string + telegram: boolean + dashboard: boolean + yes: boolean +} + +// --- output ----------------------------------------------------------------- +const rows: Row[] = [] +const ICON: Record = { + ok: c.green('✓'), fixed: c.green('✓'), warn: c.yellow('!'), fail: c.red('✗'), skip: c.dim('-'), +} +function report(step: string, status: Status, detail: string, fix?: string) { + rows.push({ step, status, detail }) + console.log(` ${ICON[status]} ${detail}`) + if (fix && status !== 'ok' && status !== 'fixed') console.log(` ${c.cyan('fix:')} ${fix}`) +} +const heading = (n: number, title: string) => console.log(`\n${c.bold(`${n}. ${title}`)}`) +const note = (s: string) => console.log(c.dim(` ${s}`)) +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) + +// --- prompts ---------------------------------------------------------------- +const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY) + +async function ask(question: string, def = ''): Promise { + if (!interactive) return def + const rl = createInterface({ input: process.stdin, output: process.stdout }) + try { + const a = (await rl.question(` ${question}${def ? c.dim(` [${def}]`) : ''} `)).trim() + return a || def + } finally { + rl.close() + } +} + +async function confirm(opts: Opts, question: string, def = true): Promise { + if (opts.yes || !interactive) return def + const a = (await ask(`${question} ${c.dim(def ? '[Y/n]' : '[y/N]')}`)).toLowerCase() + if (!a) return def + return a.startsWith('y') +} + +// Read a key without echoing it. Raw mode, so a pasted key never lands in the +// terminal scrollback; bracketed-paste markers are stripped if the terminal +// sends them. +function askSecret(question: string): Promise { + if (!interactive) return Promise.resolve('') + return new Promise((done) => { + process.stdout.write(` ${question} `) + const stdin = process.stdin + stdin.setRawMode(true) + stdin.resume() + stdin.setEncoding('utf8') + let buf = '' + const submit = () => { + stdin.setRawMode(false) + stdin.pause() + stdin.off('data', onData) + process.stdout.write(buf ? c.dim(' (hidden, received)\n') : '\n') + done(buf.split('\u001b[200~').join('').split('\u001b[201~').join('').trim()) + } + const onData = (chunk: string) => { + for (const ch of chunk) { + if (ch === '\r' || ch === '\n') return submit() + if (ch === '\u0003') { stdin.setRawMode(false); process.stdout.write('\n'); process.exit(130) } + if (ch === '\u007f' || ch === '\b') { buf = buf.slice(0, -1); continue } + buf += ch + } + } + stdin.on('data', onData) + }) +} + +// --- gh / git helpers ----------------------------------------------------------- +interface Run { ok: boolean; out: string; err: string } +function run(cmd: string, args: string[], cwd = REPO_ROOT): Run { + const r = spawnSync(cmd, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) + return { ok: r.status === 0 && !r.error, out: (r.stdout ?? '').trim(), err: (r.stderr ?? '').trim() || (r.error?.message ?? '') } +} +const gh = (...args: string[]) => run('gh', args) +const git = (...args: string[]) => run('git', args) +function interactiveRun(cmd: string, args: string[]): boolean { + const r = spawnSync(cmd, args, { cwd: REPO_ROOT, stdio: 'inherit' }) + return r.status === 0 && !r.error +} + +// owner/repo of a GitHub remote URL (https or ssh), or null. +export function githubSlug(url: string): string | null { + const m = url.trim().match(/github\.com[:/]+([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/) + return m ? `${m[1]}/${m[2]}` : null +} + +function originSlug(): string | null { + const r = git('remote', 'get-url', 'origin') + return r.ok ? githubSlug(r.out) : null +} + +// --- steps ------------------------------------------------------------------ +async function stepGh(): Promise { + heading(1, 'GitHub CLI') + const v = run('gh', ['--version']) + if (!v.ok) { + report('gh', 'fail', 'GitHub CLI (gh) is not installed', + 'macOS: brew install gh | Linux/Windows: https://github.com/cli/cli#installation - then re-run ./aeon init') + finish(false) + } + report('gh', 'ok', `gh installed (${v.out.split('\n')[0].replace(/^gh version /, '').split(' ')[0]})`) + + if (!gh('auth', 'status').ok) { + if (isDryRun()) { + report('gh auth', 'warn', 'not signed in', 'would run: gh auth login --web -s workflow') + return '' + } + console.log(c.dim(' Signing in to GitHub (a browser window opens; the workflow scope lets Aeon update its own workflows).')) + if (!interactiveRun('gh', ['auth', 'login', '--web', '-s', 'workflow'])) { + report('gh auth', 'fail', 'GitHub sign-in did not finish', 'gh auth login --web -s workflow') + finish(false) + } + } + const login = gh('api', 'user', '-q', '.login').out + if (!login) { + report('gh auth', 'fail', 'gh is signed in but cannot read your account', 'gh auth login --web -s workflow') + finish(false) + } + + let scopes = ghTokenScopes() + let missing = scopes ? missingScopes(scopes) : [] + if (scopes && missing.length) { + const envToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN + if (envToken) { + report('gh scopes', 'warn', `signed in as ${login}, but the GH_TOKEN in your shell lacks ${missing.join(' + ')}`, + 'unset GH_TOKEN GITHUB_TOKEN, then re-run ./aeon init') + return login + } + if (isDryRun()) { + report('gh scopes', 'warn', `signed in as ${login}; token lacks ${missing.join(' + ')}`, + `would run: gh auth refresh -h github.com -s ${GH_GLOBAL_SCOPES.join(',')}`) + return login + } + console.log(c.dim(` Adding the ${missing.join(' + ')} scope to your gh login (approve in the browser).`)) + interactiveRun('gh', ['auth', 'refresh', '-h', 'github.com', '-s', GH_GLOBAL_SCOPES.join(',')]) + scopes = ghTokenScopes() + missing = scopes ? missingScopes(scopes) : [] + } + if (!scopes) { + report('gh scopes', 'warn', `signed in as ${login}; token scopes cannot be read (fine-grained token?)`, + 'gh auth login --web -s workflow (a gh login token works everywhere Aeon needs one)') + } else if (missing.length) { + report('gh scopes', 'warn', `signed in as ${login}; token still lacks ${missing.join(' + ')}`, + `gh auth refresh -h github.com -s ${GH_GLOBAL_SCOPES.join(',')}`) + } else { + report('gh scopes', 'ok', `signed in as ${login} (scopes include repo + workflow)`) + } + return login +} + +// Is `slug` an Aeon repo (has aeon.yml at its root)? +function isAeonRepo(slug: string): boolean { + return gh('api', `repos/${slug}/contents/aeon.yml`, '--silent').ok +} + +async function stepRepo(opts: Opts, login: string): Promise { + heading(2, 'Your instance repo') + const here = originSlug() + if (here && !isUpstreamRepo(here) && existsSync(join(REPO_ROOT, 'aeon.yml'))) { + report('repo', 'ok', `this folder is your instance: ${here}`) + if (opts.name !== 'aeon' && !here.endsWith(`/${opts.name}`)) note(`--name ${opts.name} ignored: already inside ${here}`) + return here + } + if (!login) { + report('repo', 'fail', 'cannot pick a repo owner without a GitHub login', 'finish step 1, then re-run ./aeon init') + finish(false) + } + + const target = `${login}/${opts.name}` + note(here ? `This folder is a copy of ${here}, the shared template. Your agent needs its own repo.` : 'This folder has no GitHub remote yet.') + // A renamed/transferred repo answers under its old name too (GitHub + // redirects), so compare the name GitHub resolves, not the one we asked for: + // never adopt the template, or anything that redirects to another repo. + const view = gh('repo', 'view', target, '--json', 'nameWithOwner', '-q', '.nameWithOwner') + if (isUpstreamRepo(target) || (view.ok && (isUpstreamRepo(view.out) || view.out.toLowerCase() !== target.toLowerCase()))) { + report('repo', 'fail', `${target} is ${view.ok && view.out.toLowerCase() !== target.toLowerCase() ? `a redirect to ${view.out}` : 'the Aeon template itself'}, not a new instance`, + './aeon init --name (e.g. --name my-aeon)') + finish(false) + } + if (view.ok) { + if (!isAeonRepo(target)) { + report('repo', 'fail', `${target} already exists and is not an Aeon repo (no aeon.yml)`, './aeon init --name ') + finish(false) + } + report('repo', 'ok', `found your instance ${target}`) + } else { + const vis = opts.private ? 'private' : 'public' + note(opts.private + ? 'Private: GitHub Actions minutes come out of your plan (Free includes 2,000/month).' + : 'Public: GitHub Actions minutes are free. Secrets stay secret either way; pass --private to keep the code private.') + if (!(await confirm(opts, `Create ${target} (${vis}) from the ${TEMPLATE} template?`))) { + report('repo', 'fail', 'no instance repo', `./aeon init --name (or gh repo create ${target} --template ${TEMPLATE} --${vis})`) + finish(false) + } + if (isDryRun()) { + report('repo', 'skip', `would run: gh repo create ${target} --template ${TEMPLATE} --${vis}`) + if (opts.dir) report('clone', 'skip', `would clone ${target} into ${resolve(opts.dir)} and continue there`) + else report('folder', 'skip', `would make ${target} this folder's origin (${here ?? 'no remote'} kept as upstream) and check out its main`) + return target + } + // A template copy, not a fork: forks start with Actions disabled and + // inherit nothing useful, which is the #1 "my agent never ran" cause. + const created = gh('repo', 'create', target, '--template', TEMPLATE, `--${vis}`, '--description', 'My Aeon agent') + if (!created.ok) { + report('repo', 'fail', `could not create ${target}: ${created.err.split('\n')[0]}`, `gh repo create ${target} --template ${TEMPLATE} --${vis}`) + finish(false) + } + report('repo', 'fixed', `created ${target} from the ${TEMPLATE} template`) + } + + if (opts.dir) return cloneElsewhere(opts, target) + return adoptThisFolder(opts, here, target) +} + +// Clone the instance into --dir and hand over to that checkout's own +// `./aeon init`, which finds itself inside an instance and carries on. +async function cloneElsewhere(opts: Opts, target: string): Promise { + const dir = resolve(opts.dir!) + if (isDryRun()) { + report('clone', 'skip', `would clone ${target} into ${dir} and continue there`) + finish(true) + } + if (!existsSync(join(dir, 'aeon.yml'))) { + let cloned: Run = { ok: false, out: '', err: '' } + for (let i = 0; i < 10 && !cloned.ok; i++) { + // The template copy is filled in asynchronously; give it a moment. + if (i) await sleep(3000) + cloned = run('gh', ['repo', 'clone', target, dir], process.cwd()) + } + if (!cloned.ok) { + report('clone', 'fail', `could not clone ${target}: ${cloned.err.split('\n')[0]}`, `gh repo clone ${target} ${dir}`) + finish(false) + } + } + report('clone', 'fixed', `cloned ${target} into ${dir}; continuing there`) + const args = ['init', ...(opts.yes ? ['--yes'] : []), ...(opts.harness ? ['--harness', opts.harness] : []), + ...(opts.telegram ? [] : ['--no-telegram']), ...(opts.dashboard ? [] : ['--no-dashboard'])] + const env = { ...process.env, AEON_REPO_ROOT: dir } + const r = spawnSync(join(dir, 'aeon'), args, { cwd: dir, stdio: 'inherit', env }) + process.exit(r.status ?? 1) +} + +// Turn this checkout of the template into the operator's instance: aeonfun/aeon +// becomes the `upstream` remote, the new repo becomes `origin`, and main tracks +// it. Refuses when that could lose work (uncommitted edits or local commits). +async function adoptThisFolder(opts: Opts, here: string | null, target: string): Promise { + const dirty = git('status', '--porcelain', '--untracked-files=no') + if (dirty.out) { + report('folder', 'fail', 'this folder has uncommitted changes, so it was not switched over', + `commit or stash them and re-run, or: ./aeon init --dir ../${opts.name}`) + finish(false) + } + const local = git('rev-list', '--count', 'HEAD', '--not', '--remotes') + if (local.ok && local.out !== '0') { + report('folder', 'fail', `this folder has ${local.out} local commit(s) not on any remote, so it was not switched over`, + `push them somewhere first, or: ./aeon init --dir ../${opts.name}`) + finish(false) + } + if (isDryRun()) { + report('folder', 'skip', `would make ${target} this folder's origin (${here ?? 'no remote'} kept as upstream) and check out its main`) + return target + } + const remotes = git('remote').out.split('\n') + if (here) { + if (remotes.includes('upstream')) git('remote', 'remove', 'origin') + else git('remote', 'rename', 'origin', 'upstream') + } + const add = git('remote', 'add', 'origin', `https://github.com/${target}.git`) + if (!add.ok) { + report('folder', 'fail', `could not add the origin remote: ${add.err}`, `git remote add origin https://github.com/${target}.git`) + finish(false) + } + const branch = gh('repo', 'view', target, '--json', 'defaultBranchRef', '-q', '.defaultBranchRef.name').out || 'main' + let fetched = false + for (let i = 0; i < 10 && !fetched; i++) { + if (i) await sleep(3000) + fetched = git('fetch', 'origin').ok && git('rev-parse', '--verify', '--quiet', `refs/remotes/origin/${branch}`).ok + } + if (!fetched) { + report('folder', 'fail', `${target} has no ${branch} branch yet`, 'wait a minute for GitHub to copy the template, then re-run ./aeon init') + finish(false) + } + const co = git('checkout', '-B', branch, `origin/${branch}`) + if (!co.ok) { + report('folder', 'fail', `could not check out ${target}: ${co.err.split('\n')[0]}`, `git checkout -B ${branch} origin/${branch}`) + finish(false) + } + git('branch', `--set-upstream-to=origin/${branch}`, branch) + report('folder', 'fixed', `this folder now tracks ${target}${here ? ` (${here} kept as the upstream remote)` : ''}`) + return target +} + +function stepDefaultRepo(slug: string) { + heading(3, 'Default repo for gh') + if (isUpstreamRepo(slug)) { + report('set-default', 'fail', `refusing to target ${slug}, the shared template`, './aeon init from your own instance') + finish(false) + } + const current = gh('repo', 'set-default', '--view').out + if (current.toLowerCase() === slug.toLowerCase()) { + report('set-default', 'ok', `gh default repo is ${slug}`) + return + } + if (isDryRun()) { + report('set-default', 'skip', `would run: gh repo set-default ${slug}${current ? ` (currently ${current})` : ''}`) + return + } + const r = gh('repo', 'set-default', slug) + if (!r.ok) { + report('set-default', 'fail', `could not set the default repo: ${r.err.split('\n')[0]}`, `gh repo set-default ${slug}`) + finish(false) + } + report('set-default', 'fixed', `gh default repo set to ${slug}${current ? ` (was ${current})` : ''} - secrets now land on your repo`) +} + +function stepActions(slug: string) { + heading(4, 'GitHub Actions') + const settings = `https://github.com/${slug}/settings/actions` + const perms = gh('api', `repos/${slug}/actions/permissions`) + const wf = gh('api', `repos/${slug}/actions/permissions/workflow`) + let p: { enabled?: boolean; allowed_actions?: string } = {} + let w: { default_workflow_permissions?: string; can_approve_pull_request_reviews?: boolean } = {} + try { p = JSON.parse(perms.out || '{}') } catch { /* unreadable: treat as unset */ } + try { w = JSON.parse(wf.out || '{}') } catch { /* unreadable: treat as unset */ } + // Every aeon workflow declares its own `permissions:`, so the repo's default + // token (read or write) is left exactly as it is. What a run needs from the + // repo settings is only: Actions on, and "Allow GitHub Actions to create and + // approve pull requests" (install-skill ships installs as auto-merged PRs). + // An operator's allowed_actions choice ('selected' / 'local_only') is kept; + // 'all' is set only when nothing is set yet. + const actionsOk = p.enabled === true + const prOk = w.can_approve_pull_request_reviews === true + + if (actionsOk && prOk) { + report('actions', 'ok', `Actions enabled (${p.allowed_actions ?? 'default'} actions), workflows may open PRs`) + } else if (isDryRun()) { + const todo = [ + ...(actionsOk ? [] : [`enable Actions${p.allowed_actions ? '' : ' (all actions)'}`]), + ...(prOk ? [] : ['allow Actions to create and approve PRs']), + ].join(' + ') + report('actions', 'skip', `would ${todo} (workflow token stays ${w.default_workflow_permissions ?? 'as is'})`) + } else { + const a = actionsOk || gh('api', '-X', 'PUT', `repos/${slug}/actions/permissions`, '-F', 'enabled=true', + ...(p.allowed_actions ? [] : ['-f', 'allowed_actions=all'])).ok + // The endpoint takes both fields; send the current default token back + // unchanged so only the PR switch moves. + const b = prOk || gh('api', '-X', 'PUT', `repos/${slug}/actions/permissions/workflow`, + ...(w.default_workflow_permissions ? ['-f', `default_workflow_permissions=${w.default_workflow_permissions}`] : []), + '-F', 'can_approve_pull_request_reviews=true').ok + const done = [...(actionsOk ? [] : ['enabled Actions']), ...(prOk ? [] : ['allowed Actions to open PRs'])].join(' and ') + if (a && b) report('actions', 'fixed', `${done} (workflow token left ${w.default_workflow_permissions ?? 'as is'})`) + else report('actions', 'fail', 'could not change the Actions settings (needs admin on the repo)', + `open ${settings}: enable Actions and tick "Allow GitHub Actions to create and approve pull requests"`) + } + + // Forks ship with every workflow disabled ("disabled_fork"), and GitHub + // disables schedules after 60 idle days. Re-enable only those two states: + // a workflow the operator turned off by hand stays off. + const list = gh('api', `repos/${slug}/actions/workflows`, '-q', '.workflows[] | select(.state == "disabled_fork" or .state == "disabled_inactivity") | "\\(.id) \\(.path)"') + const off = list.ok && list.out ? list.out.split('\n') : [] + if (!off.length) return + if (isDryRun()) { report('workflows', 'skip', `would enable ${off.length} workflow(s) GitHub left off`); return } + const failed = off.filter((l) => !gh('api', '-X', 'PUT', `repos/${slug}/actions/workflows/${l.split(' ')[0]}/enable`).ok) + if (failed.length) report('workflows', 'fail', `${failed.length} workflow(s) still disabled`, `open https://github.com/${slug}/actions and enable them`) + else report('workflows', 'fixed', `enabled ${off.length} workflow(s) GitHub had left off`) +} + +function secretNames(): Set | null { + try { return new Set(listSecretNames()) } catch { return null } +} + +async function stepGhGlobal(opts: Opts, secrets: Set | null) { + heading(5, 'GH_GLOBAL (your GitHub token for runs)') + note('Lets skills read other repos, open PRs and save refreshed logins back as secrets.') + if (secrets?.has('GH_GLOBAL')) { + report('GH_GLOBAL', 'ok', 'GH_GLOBAL is set') + return + } + const scopes = ghTokenScopes() + const missing = scopes ? missingScopes(scopes) : [...GH_GLOBAL_SCOPES] + if (missing.length) { + report('GH_GLOBAL', 'warn', `not set; your gh token lacks ${missing.join(' + ')}, so it was not copied`, + `gh auth refresh -h github.com -s ${GH_GLOBAL_SCOPES.join(',')} && ./aeon auth --github (or a classic PAT with repo + workflow: ./aeon secrets set GH_GLOBAL --stdin)`) + return + } + if (!(await confirm(opts, 'Store your gh token as GH_GLOBAL? (scopes: repo, workflow; gh tokens do not expire)'))) { + report('GH_GLOBAL', 'skip', 'GH_GLOBAL not set (optional)', './aeon auth --github') + return + } + if (isDryRun()) { report('GH_GLOBAL', 'skip', 'would copy `gh auth token` into GH_GLOBAL'); return } + try { + captureGithubToken() + report('GH_GLOBAL', 'fixed', 'stored your gh token as GH_GLOBAL') + } catch (e) { + report('GH_GLOBAL', 'fail', e instanceof Error ? e.message : 'could not store GH_GLOBAL', './aeon auth --github') + } +} + +function currentHarness(): Harness { + try { + const h = parseConfig(readFileSync(join(REPO_ROOT, 'aeon.yml'), 'utf8')).harness + return (HARNESSES as string[]).includes(h) ? (h as Harness) : 'claude' + } catch { + return 'claude' + } +} + +function cliInstalled(bin: string): boolean { + return !spawnSync(bin, ['--version'], { stdio: 'ignore' }).error +} + +// Store a pasted key for `h` under the credential's secret, going through the +// same lib paths the dashboard uses so gateway/harness side effects match. +async function storeKey(h: HarnessManifest, cred: Credential | null, gateway: Gateway | null, key: string): Promise { + if (gateway) return (await configureAuth({ key, provider: gateway.id })).secret ?? gateway.secrets[0] + if (!cred) throw new Error('nothing to store') + if (h.id === 'claude') return (await configureAuth({ key })).secret ?? cred.secret + if (h.id === 'grok') return (await storeGrokKey(key)).secret + const spec = HARNESS_AUTH[h.id] + if (spec?.apiKey && cred.auth_mode === 'native-key' && (spec.apiKey.detect || spec.apiKey.secret === cred.secret)) { + return setHarnessApiKey(h.id, key).secret + } + await setSecret(cred.secret, key) + return cred.secret +} + +// Run the credential's login flow (no key to paste). +async function runLogin(h: HarnessManifest, cred: Credential): Promise { + if (h.id === 'claude' || cred.secret === 'CLAUDE_CODE_OAUTH_TOKEN') { + if (!cliInstalled('claude')) throw new Error(`Claude Code is not installed. Install it: ${h.cli.install}`) + return (await configureAuth({})).secret ?? cred.secret + } + if (h.id === 'grok') return grokLogin().secret + if (!cliInstalled(h.cli.bin)) throw new Error(`${h.cli.bin} is not installed. Install it: ${h.cli.install}`) + driveTtyLogin(h.id) + return captureHarnessCreds(h.id).secret +} + +async function stepModel(opts: Opts, secrets: Set | null) { + heading(6, 'Model') + const harnesses = loadHarnesses() + const gateways = loadGateways() + const byId = new Map(harnesses.map((h) => [h.id, h])) + const configured = currentHarness() + const have = (h: HarnessManifest) => runnableSecrets(h, gateways).find((s) => secrets?.has(s)) + + const ready = byId.get(configured) + const usedBy = ready && have(ready) + if (usedBy && !opts.harness) { + report('model', 'ok', `${ready.label} is connected (${usedBy})`) + if (!(await confirm(opts, 'Connect a different model?', false))) return + } + + console.log('') + harnesses.forEach((h, i) => { + const how = h.credentials.map((cr) => cr.label.replace(/ \(.*\)$/, '')).join(', ') + console.log(` ${String(i + 1).padStart(2)}. ${h.label}${h.id === configured ? c.dim(' (current)') : ''} ${c.dim(`- ${how}${h.gateways ? ', or a gateway key' : ''}`)}`) + }) + note('One OpenRouter key (https://openrouter.ai/settings/keys) works for most of these:') + note(`${harnesses.filter((h) => h.gateways || h.credentials.some((cr) => cr.secret === 'OPENROUTER_API_KEY')).map((h) => h.id).join(', ')}.`) + + let pick = opts.harness ? byId.get(opts.harness === 'claude-code' ? 'claude' : opts.harness) : undefined + if (opts.harness && !pick) fail(`unknown harness '${opts.harness}'. One of: ${harnesses.map((h) => h.id).join(', ')}`) + if (!pick) { + const def = String(harnesses.findIndex((h) => h.id === configured) + 1) + const ans = await ask('Which agent should run your skills?', def) + pick = harnesses[Number(ans) - 1] ?? byId.get(ans) + } + if (!pick) { + report('model', 'warn', 'no agent picked', './aeon init --harness ') + return + } + const h = pick + + // The harness's own credentials (most preferred first), then for claude the + // gateway keys it can route through. + const options: { cred: Credential | null; gw: Gateway | null; label: string; how: string; url: string }[] = [ + ...h.credentials.map((cr) => ({ cred: cr, gw: null, label: cr.label, how: cr.aeon_cmd ?? `./aeon secrets set ${cr.secret} --stdin`, url: cr.get_url })), + ...(h.gateways ? gateways.filter((g) => g.transport !== 'native').map((g) => ({ + cred: null, gw: g, label: `${g.label} gateway key`, how: `./aeon auth --key --provider ${g.id}`, url: g.get_url, + })) : []), + ] + console.log(`\n ${c.bold(h.label)} can sign in with:`) + options.forEach((o, i) => console.log(` ${String(i + 1).padStart(2)}. ${o.label} ${c.dim(`- get one: ${o.url}`)}\n ${c.dim(o.how)}`)) + + const ans = await ask('Which one?', '1') + const choice = options[Number(ans) - 1] + if (!choice) { + report('model', 'warn', `${h.label}: nothing connected`, options[0].how) + return + } + + const isLogin = choice.cred !== null && (choice.cred.kind === 'oauth_capture' || (choice.cred.kind === 'oauth_token' && Boolean(choice.cred.login_cmd) && h.id === 'claude')) + if (isDryRun()) { + report('model', 'skip', `would ${isLogin ? `run ${choice.cred?.login_cmd}` : 'ask for the key'} and store ${choice.gw?.secrets[0] ?? choice.cred?.secret}${h.id !== configured ? `, then set harness: ${h.id}` : ''}`) + return + } + if (!interactive && !isLogin) { + report('model', 'warn', `${h.label}: not connected (no terminal to paste a key into)`, choice.how) + return + } + + let stored: string + try { + if (isLogin) { + console.log(c.dim(` Running ${choice.cred!.login_cmd} - approve in the browser.`)) + stored = await runLogin(h, choice.cred!) + } else { + const key = await askSecret(`Paste your ${choice.label}:`) + if (!key) { + report('model', 'warn', `${h.label}: no key entered`, choice.how) + return + } + const prefixes = choice.gw ? choice.gw.prefixes : choice.cred?.prefix ? [choice.cred.prefix] : [] + if (prefixes.length && !prefixes.some((p) => key.startsWith(p)) && + !(await confirm(opts, `That key does not start with ${prefixes.join(' / ')}. Store it anyway?`, false))) { + report('model', 'warn', `${h.label}: key not stored`, choice.how) + return + } + stored = await storeKey(h, choice.cred, choice.gw, key) + } + } catch (e) { + report('model', 'fail', `${h.label}: ${e instanceof Error ? e.message : 'connect failed'}`, choice.how) + return + } + report('model', 'fixed', `${h.label} connected (stored as ${stored})`) + const aux = choice.cred?.aux_secrets + if (aux?.length && !aux.some((s) => secrets?.has(s))) { + note(`${choice.cred!.refresh ?? 'This login needs a secrets-write token to stay alive'}: set ${aux.join(' or ')} (step 5).`) + } + if (h.id !== configured) { + try { + const sync = await syncHarness(h.id as Harness) + report('harness', 'fixed', `aeon.yml now runs ${h.id}${sync.synced ? ' (pushed)' : ` (saved locally, not pushed: ${sync.reason ?? 'unknown'})`}`) + } catch (e) { + report('harness', 'fail', `could not set harness: ${e instanceof Error ? e.message : String(e)}`, `./aeon config set harness ${h.id}`) + } + } +} + +// --- Telegram --------------------------------------------------------------- +interface TgUpdate { message?: { text?: string; chat?: { id?: number } } } + +async function tg(token: string, method: string): Promise<{ status: number; ok: boolean; result?: T; description?: string }> { + const res = await fetch(`https://api.telegram.org/bot${token}/${method}`) + const body = (await res.json().catch(() => ({}))) as { ok?: boolean; result?: T; description?: string } + return { status: res.status, ok: Boolean(body.ok), result: body.result, description: body.description } +} + +async function manualChatId(why: string): Promise { + note(why) + note('Message @userinfobot on Telegram; it replies with your numeric id.') + return ask('Paste your chat id (blank to skip):') +} + +async function stepTelegram(opts: Opts, secrets: Set | null) { + heading(7, 'Telegram (optional)') + if (!opts.telegram) { report('telegram', 'skip', 'skipped (--no-telegram)'); return } + if (secrets?.has('TELEGRAM_BOT_TOKEN') && secrets.has('TELEGRAM_CHAT_ID')) { + report('telegram', 'ok', 'TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID are set') + return + } + if (!interactive) { + report('telegram', 'skip', 'not set (needs a terminal)', './aeon init (interactive), or set TELEGRAM_BOT_TOKEN + TELEGRAM_CHAT_ID with ./aeon secrets set') + return + } + if (!(await confirm(opts, 'Get your agent\'s reports on Telegram?', true))) { + report('telegram', 'skip', 'Telegram not set up (optional)', './aeon init') + return + } + if (isDryRun()) { report('telegram', 'skip', 'would ask for a bot token, then link your chat with a /start deep link'); return } + + note('In Telegram, message @BotFather, send /newbot, and copy the token it gives you.') + const token = await askSecret('Bot token:') + if (!token) { report('telegram', 'skip', 'no bot token entered', './aeon init'); return } + let me + try { me = await tg<{ username?: string }>(token, 'getMe') } catch (e) { + report('telegram', 'fail', `could not reach Telegram: ${e instanceof Error ? e.message : String(e)}`, './aeon init') + return + } + if (!me.ok || !me.result?.username) { + report('telegram', 'fail', `Telegram rejected that token${me.description ? ` (${me.description})` : ''}`, 'copy the token from @BotFather again and re-run ./aeon init') + return + } + const bot = me.result.username + try { await setSecret('TELEGRAM_BOT_TOKEN', token) } catch (e) { + report('telegram', 'fail', `could not save TELEGRAM_BOT_TOKEN: ${e instanceof Error ? e.message : String(e)}`, './aeon secrets set TELEGRAM_BOT_TOKEN --stdin') + return + } + report('telegram', 'fixed', `saved the token for @${bot}`) + + // Link the chat: a one-time nonce in a /start deep link, found by polling + // getUpdates WITHOUT an offset, so nothing is acknowledged or consumed and + // the workflow's own poller still sees every message. + const nonce = randomBytes(6).toString('hex') + console.log(`\n Open this link and tap Start: ${c.cyan(`https://t.me/${bot}?start=${nonce}`)}`) + let chatId = '' + const deadline = Date.now() + 180_000 + while (!chatId && Date.now() < deadline) { + let up + try { up = await tg(token, 'getUpdates') } catch { up = null } + if (up && up.status === 409) { + chatId = await manualChatId('This bot has a webhook set, so its updates cannot be read here.') + break + } + const hit = up?.result?.find((u) => u.message?.text?.trim() === `/start ${nonce}`) + if (hit?.message?.chat?.id !== undefined) chatId = String(hit.message.chat.id) + else await sleep(2000) + } + if (!chatId) chatId = await manualChatId('Did not see the /start message within 3 minutes.') + if (!/^-?\d+$/.test(chatId)) { + report('telegram', 'warn', 'TELEGRAM_CHAT_ID not set', './aeon secrets set TELEGRAM_CHAT_ID --stdin') + return + } + try { + await setSecret('TELEGRAM_CHAT_ID', chatId) + report('telegram', 'fixed', `linked chat ${chatId} - reports will arrive from @${bot}`) + } catch (e) { + report('telegram', 'fail', `could not save TELEGRAM_CHAT_ID: ${e instanceof Error ? e.message : String(e)}`, './aeon secrets set TELEGRAM_CHAT_ID --stdin') + } +} + +// --- summary ---------------------------------------------------------------- +function printSummary() { + console.log(`\n${c.bold('Summary')}`) + for (const r of rows) console.log(` ${ICON[r.status]} ${r.step.padEnd(12)} ${c.dim(r.detail)}`) +} + +// Every exit path: print the checklist, exit 0 only when nothing failed. +function finish(completed: boolean): never { + printSummary() + const failed = rows.filter((r) => r.status === 'fail').length + if (!completed || failed) { + console.log(`\n${c.yellow('Not finished.')} Fix the ${c.red('✗')} items above, then re-run ${c.bold('./aeon init')} - it skips what is already done.`) + } else if (isDryRun()) { + console.log(`\n${c.yellow('Dry run finished:')} nothing was changed. Run ${c.bold('./aeon init')} to do it.`) + } else if (rows.some((r) => r.status === 'warn')) { + console.log(`\n${c.yellow('Almost there.')} Fix the ${c.yellow('!')} items above (or re-run ${c.bold('./aeon init')}); ${c.bold('bin/onboard')} re-checks everything.`) + } else { + console.log(`\n${c.green('Your agent is set up.')} Skills run on their schedule in GitHub Actions; ${c.bold('bin/onboard')} re-checks everything.`) + } + process.exit(failed || !completed ? 1 : 0) +} + +export async function initCommand(argv: string[]) { + if (argv.includes('-h') || argv.includes('--help')) { console.log(USAGE); return } + let values: { name?: string; private?: boolean; dir?: string; harness?: string; 'no-telegram'?: boolean; 'no-dashboard'?: boolean; yes?: boolean } + try { + ;({ values } = parseArgs({ args: argv, options: { + name: { type: 'string' }, private: { type: 'boolean' }, dir: { type: 'string' }, harness: { type: 'string' }, + 'no-telegram': { type: 'boolean' }, 'no-dashboard': { type: 'boolean' }, yes: { type: 'boolean', short: 'y' }, + } })) + } catch (e) { fail(e instanceof Error ? e.message : 'bad arguments') } + const name = values.name ?? 'aeon' + if (!/^[A-Za-z0-9._-]+$/.test(name)) fail(`--name must be a plain repo name (got '${name}')`) + const opts: Opts = { + name, private: Boolean(values.private), dir: values.dir, harness: values.harness, + telegram: !values['no-telegram'], dashboard: !values['no-dashboard'], yes: Boolean(values.yes), + } + + console.log(c.bold('Aeon setup') + c.dim(` - each step checks first and only fixes what is missing; safe to re-run${isDryRun() ? ' (dry run: nothing changes)' : ''}`)) + const login = await stepGh() + const slug = await stepRepo(opts, login) + stepDefaultRepo(slug) + // Every write below goes through `gh` (default repo, just set) or git + // (origin). Both must point at the instance, never the template. + const origin = originSlug() + if (!isDryRun() && (!origin || isUpstreamRepo(origin) || origin.toLowerCase() !== slug.toLowerCase())) { + report('origin', 'fail', `this folder's origin is ${origin ?? 'unset'}, not ${slug}`, `git remote set-url origin https://github.com/${slug}.git`) + finish(false) + } + stepActions(slug) + const secrets = secretNames() + if (!secrets) note('Could not list the repo secrets; the steps below will offer to set everything.') + await stepGhGlobal(opts, secrets) + await stepModel(opts, secrets) + await stepTelegram(opts, secretNames() ?? secrets) + + const failed = rows.some((r) => r.status === 'fail') + // --yes means unattended, and the dashboard is a foreground server: never + // start it implicitly. + if (!failed && opts.dashboard && interactive && !opts.yes && !isDryRun() && + (await confirm(opts, 'Start the dashboard now?', true))) { + printSummary() + const r = spawnSync(join(REPO_ROOT, 'aeon'), [], { cwd: REPO_ROOT, stdio: 'inherit' }) + process.exit(r.status ?? 0) + } + if (!failed && opts.dashboard) note('Start the dashboard any time with ./aeon') + finish(true) +} \ No newline at end of file diff --git a/apps/cli/src/grok.ts b/apps/cli/src/grok.ts new file mode 100644 index 00000000000..51e73ea5f1f --- /dev/null +++ b/apps/cli/src/grok.ts @@ -0,0 +1,39 @@ +// Grok Build (`grok`) credential capture for the CLI - the terminal twin of the +// dashboard's "Connect X account" (app/api/grok-auth/route.ts): run +// `grok login --device-auth`, then tar+base64 ~/.grok/auth.json into the +// GROK_CREDENTIALS secret, which scripts/run-grok.sh restores before each run. +// grok is not in lib/harness-auth.ts (its route predates that registry), so +// `aeon auth --harness grok` and `aeon init` share this instead. +import { execFileSync, spawnSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { ghSecretSet } from '../../dashboard/lib/gh.ts' +import { syncGatewayProvider } from '../../dashboard/lib/gateway.ts' + +const AUTH_FILE = '.grok/auth.json' + +// Interactive: grok prints the device URL and opens the browser itself. +export function grokLogin(): { secret: string } { + const res = spawnSync('grok', ['login', '--device-auth'], { stdio: 'inherit' }) + if (res.error) { + throw new Error((res.error as NodeJS.ErrnoException).code === 'ENOENT' + ? 'grok CLI not found. Install it: npm i -g @xai-official/grok' + : res.error.message) + } + if (res.status !== 0) throw new Error(`grok login exited ${res.status}`) + const home = homedir() + if (!existsSync(join(home, AUTH_FILE))) { + throw new Error('Login finished but no ~/.grok/auth.json was found. Run `grok login` in a terminal, then try again.') + } + const archive = execFileSync('tar', ['czf', '-', '-C', home, AUTH_FILE], { maxBuffer: 8 * 1024 * 1024 }) + ghSecretSet('GROK_CREDENTIALS', archive.toString('base64')) + return { secret: 'GROK_CREDENTIALS' } +} + +// XAI_API_KEY is also the Grok gateway secret, so keep the gateway on auto. +export async function storeGrokKey(key: string): Promise<{ secret: string }> { + ghSecretSet('XAI_API_KEY', key.trim()) + await syncGatewayProvider() + return { secret: 'XAI_API_KEY' } +} diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 2ff43ef989a..131745b9369 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -13,11 +13,16 @@ import { soulCommand } from './commands/soul.ts' import { packsCommand } from './commands/packs.ts' import { mcpCommand } from './commands/mcp.ts' import { telegramCommand } from './commands/telegram.ts' +import { initCommand } from './commands/init.ts' const USAGE = `${c.bold('aeon')} — command-line control of this Aeon repo Usage: aeon [subcommand] [options] +Setup: + init Create your instance + connect GitHub, a model and Telegram + (interactive, safe to re-run; see \`aeon init --help\`) + Read: skills ls| Skill roster + per-skill detail runs ls|logs Recent workflow runs + a run's log/summary @@ -55,6 +60,7 @@ const COMMANDS: Partial void | Promise> packs: packsCommand, mcp: mcpCommand, telegram: telegramCommand, + init: initCommand, } async function main() { diff --git a/apps/cli/src/manifest.ts b/apps/cli/src/manifest.ts new file mode 100644 index 00000000000..94bfa55ebc1 --- /dev/null +++ b/apps/cli/src/manifest.ts @@ -0,0 +1,71 @@ +// Typed reader for the generated credential manifests: +// harness-adapter/harnesses.json per-harness credentials (most preferred +// first, exactly as scripts/resolve-harness.sh +// picks them) + default_model +// harness-adapter/gateways.json the claude harness's LLM-gateway cascade +// Both are generated by harness-adapter/bin/generate-harnesses-json and held to +// the runtime scripts by scripts/tests/test_credential_manifest.sh, so commands +// read them instead of keeping a third copy of "which secret does what". +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +// The manifests describe THIS checkout's code, so read them from beside the CLI +// rather than from AEON_REPO_ROOT (which may point at another, older repo). +const CLI_REPO = fileURLToPath(new URL('../../..', import.meta.url)) + +export type CredentialKind = 'oauth_capture' | 'oauth_token' | 'api_key' | 'oidc' + +export interface Credential { + secret: string + kind: CredentialKind + auth_mode: 'native-oauth' | 'native-key' | 'openrouter' + label: string + prefix?: string + get_url: string + login_cmd?: string + aeon_cmd?: string + cred_paths?: string[] + expires?: string + refresh?: string + aux_secrets?: string[] +} + +export interface HarnessManifest { + id: string + label: string + cli: { install: string; bin: string; min_version: string } + default_model: string + credentials: Credential[] + gateways?: string +} + +export interface Gateway { + id: string + label: string + secrets: string[] + prefixes: string[] + transport: 'native' | 'anthropic-compatible' | 'sidecar' + base_url: string + get_url: string +} + +function readJson(rel: string, root: string): T { + return JSON.parse(readFileSync(join(root, rel), 'utf8')) as T +} + +export function loadHarnesses(root = CLI_REPO): HarnessManifest[] { + return readJson<{ harnesses: HarnessManifest[] }>('harness-adapter/harnesses.json', root).harnesses +} + +export function loadGateways(root = CLI_REPO): Gateway[] { + return readJson<{ gateways: Gateway[] }>('harness-adapter/gateways.json', root).gateways +} + +// Every secret that, once set, lets `harness` run: its own credentials, plus +// (claude only) every gateway key in the cascade. +export function runnableSecrets(harness: HarnessManifest, gateways: Gateway[]): string[] { + const own = harness.credentials.map((c) => c.secret) + const gw = harness.gateways ? gateways.flatMap((g) => g.secrets) : [] + return [...new Set([...own, ...gw])] +} diff --git a/apps/cli/src/output.ts b/apps/cli/src/output.ts index 860e09a02b9..282f35dcd17 100644 --- a/apps/cli/src/output.ts +++ b/apps/cli/src/output.ts @@ -1,7 +1,7 @@ // Output helpers shared by every command. A single `--json` switch flips all // human rendering to machine-parseable JSON so the CLI drops into scripts. -import { ghAvailable } from '../../dashboard/lib/gh.ts' +import { ghAvailable, ghArgsRepo } from '../../dashboard/lib/gh.ts' let jsonMode = false export function setJsonMode(on: boolean) { jsonMode = on } @@ -68,3 +68,18 @@ export function fail(message: string, code = 1): never { export function requireGh(): void { if (!ghAvailable()) fail('GitHub CLI not authenticated. Run: gh auth login') } + +// The upstream template, never an operator's instance. A secret or push aimed +// here means `gh repo set-default` was never run on a fresh clone, so the write +// would land on (or 403 against) aeonfun/aeon instead of the operator's repo. +export const UPSTREAM_REPOS = new Set(['aeonfun/aeon', 'aaronjmars/aeon']) +export const isUpstreamRepo = (slug: string) => UPSTREAM_REPOS.has(slug.toLowerCase()) + +// Guard for commands that write secrets: refuse when gh resolves to upstream. +export function requireInstanceRepo(): void { + const args = ghArgsRepo() + const repo = args[1] ?? '' + if (repo && isUpstreamRepo(repo)) { + fail(`gh points at ${repo}, the Aeon template - not your instance. Run \`./aeon init\` (or \`gh repo set-default /\`) first.`) + } +} diff --git a/apps/dashboard/lib/github-auth.test.ts b/apps/dashboard/lib/github-auth.test.ts index 2ad060e31b3..7091ab946f7 100644 --- a/apps/dashboard/lib/github-auth.test.ts +++ b/apps/dashboard/lib/github-auth.test.ts @@ -1,7 +1,7 @@ import { describe, it } from 'node:test' import { strict as assert } from 'node:assert' -import { parseGhAuthToken } from './github-auth' +import { assertGhGlobalScopes, missingScopes, parseGhAuthToken, parseOAuthScopes } from './github-auth' describe('parseGhAuthToken', () => { it('accepts GitHub CLI OAuth tokens', () => { @@ -19,3 +19,37 @@ describe('parseGhAuthToken', () => { assert.throws(() => parseGhAuthToken('not-a-token'), /Could not read a GitHub token/) }) }) + +describe('GH_GLOBAL scope check', () => { + const headers = (scopes: string | null) => [ + 'HTTP/2.0 200 OK', + 'Access-Control-Expose-Headers: ETag, X-OAuth-Scopes, X-Accepted-OAuth-Scopes', + 'X-Accepted-Oauth-Scopes: ', + ...(scopes === null ? [] : [`X-Oauth-Scopes: ${scopes}`]), + '', + '{"login":"someone","note":"X-Oauth-Scopes: repo, workflow"}', + ].join('\r\n') + + it('reads the granted scopes from the response headers only', () => { + assert.deepEqual(parseOAuthScopes(headers('gist, read:org, repo, workflow')), ['gist', 'read:org', 'repo', 'workflow']) + assert.deepEqual(parseOAuthScopes(headers('')), []) + assert.equal(parseOAuthScopes(headers(null)), null) + }) + + it('names the missing scopes', () => { + assert.deepEqual(missingScopes(['repo', 'workflow', 'gist']), []) + assert.deepEqual(missingScopes(['repo', 'read:org']), ['workflow']) + assert.deepEqual(missingScopes([]), ['repo', 'workflow']) + }) + + it('accepts a token with repo + workflow and refuses one without', () => { + assert.doesNotThrow(() => assertGhGlobalScopes('gho_x', ['repo', 'workflow'])) + assert.throws(() => assertGhGlobalScopes('gho_x', ['repo']), /missing the workflow scope.*gh auth refresh -h github.com -s repo,workflow/) + assert.throws(() => assertGhGlobalScopes('ghp_x', ['gist']), /repo \+ workflow scopes/) + }) + + it('refuses a token whose scopes cannot be read', () => { + assert.throws(() => assertGhGlobalScopes('github_pat_x', null), /fine-grained/) + assert.throws(() => assertGhGlobalScopes('gho_x', null), /Could not read this token's scopes/) + }) +}) diff --git a/apps/dashboard/lib/github-auth.ts b/apps/dashboard/lib/github-auth.ts index fc59fde9339..4a44548cb85 100644 --- a/apps/dashboard/lib/github-auth.ts +++ b/apps/dashboard/lib/github-auth.ts @@ -3,6 +3,13 @@ import { ghSecretSet } from './gh' export const GH_GLOBAL_SECRET = 'GH_GLOBAL' +// GH_GLOBAL is the instance's one GitHub token (docs/CONFIGURATION.md "Cross-repo +// access"): classic-style scopes `repo` (cross-repo + private reads/writes, +// advisories, writing Actions secrets back) and `workflow` (pushes that touch +// .github/workflows/). A token without both fails later, deep inside a run, so +// it is refused here instead. +export const GH_GLOBAL_SCOPES = ['repo', 'workflow'] as const + // GitHub CLI OAuth (gho_), classic PAT (ghp_), fine-grained PAT (github_pat_). // All work as GH_TOKEN in Actions. Reject anything else so we never stash an // Actions installation token (ghs_) or leftover stdout. @@ -16,10 +23,53 @@ export function parseGhAuthToken(raw: string): string { return token } +// The scopes in an `X-OAuth-Scopes` response header, from `gh api -i user` +// output (HTTP headers, blank line, body). null when the header is absent: a +// fine-grained PAT or GitHub App token, whose permissions cannot be read back. +export function parseOAuthScopes(raw: string): string[] | null { + const head = raw.split(/\r?\n\r?\n/, 1)[0] ?? '' + const line = head.split(/\r?\n/).find((l) => /^x-oauth-scopes:/i.test(l)) + if (line === undefined) return null + return line.slice(line.indexOf(':') + 1).split(',').map((s) => s.trim()).filter(Boolean) +} + +// Which of `required` the granted scopes do not cover. `repo` is the only +// umbrella scope that matters here (it implies its repo:* children, never +// `workflow`), so this is a plain set difference. +export function missingScopes(granted: readonly string[], required: readonly string[] = GH_GLOBAL_SCOPES): string[] { + return required.filter((s) => !granted.includes(s)) +} + +// The scopes of the token `gh` is using right now, or null when they cannot be +// read (fine-grained token, or the API call failed). +export function ghTokenScopes(): string[] | null { + try { + const raw = execFileSync('gh', ['api', '-i', 'user'], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }) + return parseOAuthScopes(raw) + } catch { + return null + } +} + +// Throw a fix-it error unless `token` (with these readable `scopes`) is fit to +// be GH_GLOBAL. Pure, so the route, the CLI and tests share one rule. +export function assertGhGlobalScopes(token: string, scopes: string[] | null): void { + if (scopes === null) { + throw new Error(token.startsWith('github_pat_') + ? 'This is a fine-grained token, whose permissions cannot be checked. GH_GLOBAL needs a classic token with the repo and workflow scopes: create one at https://github.com/settings/tokens (Tokens (classic)) and paste it with Set.' + : 'Could not read this token\'s scopes. Run `gh auth refresh -h github.com -s repo,workflow` and connect again, or paste a classic PAT (repo + workflow) with Set.') + } + const missing = missingScopes(scopes) + if (missing.length) { + throw new Error(`The gh token is missing the ${missing.join(' + ')} scope${missing.length > 1 ? 's' : ''} GH_GLOBAL needs. Run \`gh auth refresh -h github.com -s ${GH_GLOBAL_SCOPES.join(',')}\`, then connect again.`) + } +} + // Copy the operator's already-authenticated `gh` session into GH_GLOBAL so -// Actions runs with that token. Shared by POST /api/github-auth and -// `aeon auth --github`. No extra browser flow: the dashboard already required -// `gh auth login` to start, and GitHub CLI gho_ tokens do not expire. +// Actions runs with that token. Shared by POST /api/github-auth, +// `aeon auth --github` and `aeon init`. No extra browser flow: the dashboard +// already required `gh auth login` to start, and GitHub CLI gho_ tokens do not +// expire. Refuses a token without the repo + workflow scopes. export function captureGithubToken(): { ok: true; method: 'oauth'; secret: string } { let raw: string try { @@ -28,6 +78,7 @@ export function captureGithubToken(): { ok: true; method: 'oauth'; secret: strin throw new Error('GitHub CLI not authenticated. Run: gh auth login') } const token = parseGhAuthToken(raw) + assertGhGlobalScopes(token, ghTokenScopes()) ghSecretSet(GH_GLOBAL_SECRET, token) return { ok: true, method: 'oauth', secret: GH_GLOBAL_SECRET } } diff --git a/bin/onboard b/bin/onboard index 706c2ae1bc1..5419da4db5f 100755 --- a/bin/onboard +++ b/bin/onboard @@ -1,17 +1,17 @@ #!/usr/bin/env bash set -euo pipefail -# onboard — Validate that this Aeon fork is set up correctly. +# onboard - Validate that this Aeon instance is set up correctly (read-only). # # Runs a series of checks against the local repo and (when authenticated to gh) # the remote secrets/workflows. Prints a colored checklist with a one-line fix -# instruction for every gap. +# instruction for every gap. Changes nothing; ./aeon init is the fixer. # # Usage: # bin/onboard Run every check, print summary, exit 0/1 -# bin/onboard --remote Also run a workflow_dispatch of the onboard skill -# so the same checklist arrives in your notification -# channel. +# bin/onboard --remote After the checks, dispatch one heartbeat run, an +# end-to-end test: it proves Actions, the model +# credential and your notification channel work. # bin/onboard --quiet Suppress per-check output, print summary only # bin/onboard --json Machine-readable JSON output (no colors, no fixes) # bin/onboard --help Show this help @@ -92,15 +92,23 @@ repo_slug() { REPO_SLUG="$(repo_slug)" +GH_READY=false +if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then GH_READY=true; fi + +# One `gh secret list` for the whole run (names only, never values). +SECRET_NAMES="" +SECRETS_READ=false +if [[ "$GH_READY" == "true" && -n "$REPO_SLUG" ]]; then + if SECRET_NAMES="$(gh secret list -R "$REPO_SLUG" --json name --jq '.[].name' 2>/dev/null)"; then + SECRETS_READ=true + fi +fi + has_secret() { - # has_secret — true if the secret is configured on the remote repo. - # Requires `gh` authenticated. Returns 2 if gh isn't available (caller decides). - local name="$1" - if ! command -v gh >/dev/null 2>&1; then return 2; fi - if [[ -z "$REPO_SLUG" ]]; then return 2; fi - if ! gh auth status >/dev/null 2>&1; then return 2; fi - gh secret list -R "$REPO_SLUG" --json name --jq '.[].name' 2>/dev/null \ - | grep -qx "$name" + # has_secret - true if the secret is configured on the remote repo. + # Returns 2 if the secret list could not be read (caller decides). + [[ "$SECRETS_READ" == "true" ]] || return 2 + grep -qx "$1" <<<"$SECRET_NAMES" } # --- header ---------------------------------------------------------------- @@ -113,6 +121,32 @@ if [[ "$JSON" != "true" && "$QUIET" != "true" ]]; then printf "\n\n" fi +# --- 0. this checkout is an instance, and gh targets it ------------------ + +case "$(printf '%s' "$REPO_SLUG" | tr '[:upper:]' '[:lower:]')" in + "") + emit fail "instance repo" "no GitHub remote (origin)" \ + "./aeon init (creates your repo from the aeonfun/aeon template and points this folder at it)" + ;; + aeonfun/aeon|aaronjmars/aeon) + emit fail "instance repo" "origin is $REPO_SLUG, the shared template - not your own instance" \ + "./aeon init (creates your repo from the template; secrets and runs then land on yours)" + ;; + *) + emit pass "instance repo" "$REPO_SLUG" "" + if [[ "$GH_READY" == "true" ]]; then + gh_default="$(gh repo set-default --view 2>/dev/null || true)" + [[ "$gh_default" == "no default"* ]] && gh_default="" + if [[ "$(printf '%s' "$gh_default" | tr '[:upper:]' '[:lower:]')" == "$(printf '%s' "$REPO_SLUG" | tr '[:upper:]' '[:lower:]')" ]]; then + emit pass "gh default repo" "$gh_default" "" + else + emit warn "gh default repo" "${gh_default:-not set} (secrets set from the dashboard/CLI may go elsewhere)" \ + "gh repo set-default $REPO_SLUG" + fi + fi + ;; +esac + # --- 1. workflow files exist ---------------------------------------------- for wf in aeon.yml messages.yml chain-runner.yml; do @@ -172,28 +206,56 @@ else fi fi -# --- 4. authentication secret (Claude) ------------------------------------ - -claude_secret_state="unknown" -if has_secret ANTHROPIC_API_KEY; then - emit pass "auth secret" "ANTHROPIC_API_KEY configured" "" - claude_secret_state="ok" -elif has_secret CLAUDE_CODE_OAUTH_TOKEN; then - emit pass "auth secret" "CLAUDE_CODE_OAUTH_TOKEN configured" "" - claude_secret_state="ok" +# --- 4. model credential (from the credential manifest) ------------------- +# Which secrets can run the configured harness comes from the generated +# manifest (harness-adapter/harnesses.json `credentials`, most preferred first, +# mirroring scripts/resolve-harness.sh) plus, for claude, every gateway key in +# harness-adapter/gateways.json - so a gateway-only setup (OpenRouter, Bankr, ...) +# passes instead of being told it has no Anthropic key. + +HARNESS="$(grep -E '^harness:' aeon.yml 2>/dev/null | head -1 | sed 's/^harness:[[:space:]]*//; s/[[:space:]]*#.*$//' | tr -d "\"' " || true)" +HARNESS="${HARNESS:-claude}" +MANIFEST="harness-adapter/harnesses.json" +GATEWAYS="harness-adapter/gateways.json" + +if ! command -v jq >/dev/null 2>&1 || [[ ! -f "$MANIFEST" ]]; then + emit warn "model credential" "cannot read $MANIFEST (needs jq)" \ + "Install jq, or check Settings -> Secrets and variables -> Actions for the $HARNESS credential (docs/harnesses.md lists them)." +elif ! jq -e --arg h "$HARNESS" '.harnesses[] | select(.id == $h)' "$MANIFEST" >/dev/null 2>&1; then + emit fail "model credential" "aeon.yml harness '$HARNESS' is not a known harness" \ + "Set harness: to one of $(jq -r '[.harnesses[].id] | join(", ")' "$MANIFEST") (./aeon config set harness )." else - case "$?" in - 1) - emit fail "auth secret" "neither ANTHROPIC_API_KEY nor CLAUDE_CODE_OAUTH_TOKEN configured" \ - "gh secret set ANTHROPIC_API_KEY --body 'sk-ant-...' -R $REPO_SLUG (or run 'claude setup-token' and set CLAUDE_CODE_OAUTH_TOKEN)" - claude_secret_state="missing" - ;; - *) - emit warn "auth secret" "could not verify (gh not authenticated for $REPO_SLUG)" \ - "Authenticate with 'gh auth login' to verify, or check Settings → Secrets and variables → Actions for ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN." - claude_secret_state="unverified" - ;; - esac + # "\t