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
-You need **Node.js 20+**, the **[GitHub CLI](https://cli.github.com/) (`gh`)** authenticated (`gh auth login`), and **your own copy** - click **Use this template** on [the repo page](https://github.com/aeonfun/aeon) (keep it public; Actions minutes are free), or `gh repo fork aeonfun/aeon --clone`.
+You need **Node.js 20+** and the **[GitHub CLI](https://cli.github.com/) (`gh`)**. Then:
```bash
-git clone https://github.com//aeon # skip if you used `gh repo fork --clone`
-cd aeon
-gh repo set-default /aeon # a fork defaults to upstream aeonfun/aeon otherwise
-./aeon
+git clone https://github.com/aeonfun/aeon && cd aeon
+./aeon init
```
-Open [localhost:5555](http://localhost:5555) and follow the dashboard: **Authenticate** (any of nine [harnesses](../docs/harnesses.md)) → **add a channel** → **pick skills** → **Run**. That's it - Aeon runs unattended. Everything is also an `./aeon` command ([CLI](../apps/cli/README.md)) or a `/aeon` chat command ([setup skill](../docs/aeon-setup.md), installable as a [Claude Code or Codex plugin](../docs/aeon-setup.md#install)).
+`./aeon init` does the rest and tells you what it did at each step: signs you in to GitHub, creates **your own repo** from the template (public by default - Actions minutes are free; `--private` if you prefer), points this folder and `gh` at it, turns on GitHub Actions, stores your GitHub token for runs, connects a model (any of nine [harnesses](../docs/harnesses.md): a Claude subscription, an API key, or one OpenRouter key) and, if you want, links Telegram. It is safe to re-run: every step checks first and skips what is already done. Then `./aeon` opens the dashboard at [localhost:5555](http://localhost:5555) to **pick skills** and **Run**. `bin/onboard` re-checks the whole setup any time (read-only).
+
+
+Prefer to do it by hand?
+
+1. Click **Use this template** on [the repo page](https://github.com/aeonfun/aeon) (keep it public; Actions minutes are free). Use the template rather than **Fork**: a fork starts with **GitHub Actions disabled** and its schedules never fire until you enable workflows in its Actions tab.
+2. Clone it and point `gh` at it - without this, secrets you set land on `aeonfun/aeon` instead of your repo:
+
+ ```bash
+ gh auth login --web -s workflow
+ git clone https://github.com//aeon && cd aeon
+ gh repo set-default /aeon
+ ./aeon
+ ```
+
+3. In the dashboard: **Authenticate** -> **add a channel** -> **pick skills** -> **Run**.
+
+Everything is also an `./aeon` command ([CLI](../apps/cli/README.md)) or a `/aeon` chat command ([setup skill](../docs/aeon-setup.md), installable as a [Claude Code or Codex plugin](../docs/aeon-setup.md#install)). For skills that reach other repos, add a classic PAT with `repo` + `workflow` as `GH_GLOBAL` ([details](../docs/CONFIGURATION.md#cross-repo-access)).
+
+No admin rights / can't install gh?
diff --git a/apps/cli/README.md b/apps/cli/README.md
index 11c92634ee5..9d143c422dd 100644
--- a/apps/cli/README.md
+++ b/apps/cli/README.md
@@ -23,6 +23,12 @@ require the full dashboard app to be installed.
The shortest way to run it is the **repo-root `./aeon`**: bare `./aeon` launches
the web dashboard, and `./aeon …` runs this CLI.
+Setting up a new instance? `./aeon init` does it end to end from a clone of
+`aeonfun/aeon`: creates your repo from the template, points this folder and `gh`
+at it, enables Actions, stores `GH_GLOBAL`, connects a model (from the credential
+manifest in `harness-adapter/harnesses.json`) and links Telegram. Every step checks
+first, so it is safe to re-run; `./aeon init --dry-run` shows what it would do.
+
```sh
./aeon skills ls # ← same as ./apps/cli/aeon skills ls
./aeon skills disable heartbeat --dry-run
@@ -69,7 +75,9 @@ runs pick the change up), call `gh` (secrets/auth), or dispatch a workflow.
| `aeon skills rm --yes` | delete the skill dir + config entry |
| `aeon skills run [--var\|--model]` | dispatch a run (`gh workflow run aeon.yml`) |
| `aeon secrets set --stdin` · `aeon secrets rm ` | manage secrets via `gh` |
+| `aeon init [--name\|--private\|--dir\|--harness\|--yes]` | set up an instance end to end (interactive, idempotent) |
| `aeon auth --harness claude-code \| --key [--provider\|--base-url]` | set Claude auth (`--oauth` still works) |
+| `aeon auth --harness [--key ]` | another harness's login or key (`grok` included); `--github` copies the gh token to `GH_GLOBAL` (needs `repo` + `workflow`) |
| `aeon sync [--status]` | commit + push local changes |
| `aeon config set model\|harness\|gateway ` | set top-level config |
| `aeon strategy set --stdin\|--file` · `aeon strategy build ""` | write / regenerate STRATEGY.md |
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
index cdd238ecdaf..805fa49023b 100644
--- a/docs/CONFIGURATION.md
+++ b/docs/CONFIGURATION.md
@@ -146,7 +146,7 @@ cp docs/examples/mcp/.mcp.json.example .mcp.json # then edit, commit, push
The example ships two working servers — `github` (uses the runner's built-in `GITHUB_TOKEN`) and `sequential-thinking` (no-auth stdio). On the next run the runner loads `.mcp.json` and auto-allows every server's tools, so a skill can just say *"use the github MCP server to …"*. Reference a server's secret with `${VAR}` (never commit the value) and set it in the dashboard — the runner resolves it from the repo's secrets with zero workflow editing, and skips a server (with a warning) when its secret is missing rather than breaking the skill.
-Or skip the file entirely: the dashboard's **MCP** tab writes `.mcp.json` for you, lists **Featured** servers ([Base](https://mcp.base.org), [Robinhood Trading](https://agent.robinhood.com), [glim.sh](https://glim.sh), [Executor](https://executor.sh), [Finance District](https://wallet-mcp.fd.xyz), [PostHog](https://posthog.com)) for one-click install, and tells you which secret each server needs. The featured servers are OAuth-gated: **Connect** opens your browser to authorize, then keeps the tokens fresh across headless runs — including saving rotated refresh tokens, which needs a secrets-write PAT (`GH_SECRETS_PAT`). Flow, PAT setup, and limits: [`docs/mcp-oauth.md`](mcp-oauth.md). Each featured server has a matching skill (`base-mcp`, `robinhood-mcp`, `glim-mcp`, `executor-mcp`, `finance-district-mcp`, and the scheduled `posthog-errors` digest) — dispatch it with a `var` to use the server from a run. Saving or connecting a server in the dashboard also splices any secret name its config references into the run workflows' allowlist automatically ([`apps/dashboard/lib/workflow-secrets.ts`](../apps/dashboard/lib/workflow-secrets.ts)), so a freshly connected server works on the very next headless run with no hand-editing of `aeon.yml`. (Editing the workflow file needs the `workflow` scope on the dashboard's GitHub token; without it the server still connects and the dashboard flags that the allowlist push did not land.)
+Or skip the file entirely: the dashboard's **MCP** tab writes `.mcp.json` for you, lists **Featured** servers ([Base](https://mcp.base.org), [Robinhood Trading](https://agent.robinhood.com), [glim.sh](https://glim.sh), [Executor](https://executor.sh), [Finance District](https://wallet-mcp.fd.xyz), [PostHog](https://posthog.com)) for one-click install, and tells you which secret each server needs. The featured servers are OAuth-gated: **Connect** opens your browser to authorize, then keeps the tokens fresh across headless runs - including saving rotated refresh tokens, which needs a secrets-write token (`GH_GLOBAL`, or the optional `GH_SECRETS_PAT`). Flow, PAT setup, and limits: [`docs/mcp-oauth.md`](mcp-oauth.md). Each featured server has a matching skill (`base-mcp`, `robinhood-mcp`, `glim-mcp`, `executor-mcp`, `finance-district-mcp`, and the scheduled `posthog-errors` digest) - dispatch it with a `var` to use the server from a run. Saving or connecting a server in the dashboard also splices any secret name its config references into the run workflows' allowlist automatically ([`apps/dashboard/lib/workflow-secrets.ts`](../apps/dashboard/lib/workflow-secrets.ts)), so a freshly connected server works on the very next headless run with no hand-editing of `aeon.yml`. (Editing the workflow file needs the `workflow` scope on the dashboard's GitHub token; without it the server still connects and the dashboard flags that the allowlist push did not land.)
## Cross-repo access
@@ -157,6 +157,10 @@ One classic PAT covers the whole instance - no separate read or secrets PAT is n
- **`repo`** - cross-repo and private-repo read/write, repository security advisories + private vulnerability reports (the disclosure/PVR skills), and writing Actions secrets back (the OAuth-MCP / Grok refresh path).
- **`workflow`** - required only for skills that push changes under `.github/workflows/` (`aeon-update`, `spawn-instance`, `auto-workflow`); without it those pushes 403.
+The quickest way to set it: `./aeon init` (or `./aeon auth --github`) copies your `gh` login token into `GH_GLOBAL`, after checking it carries `repo` + `workflow` (`gh auth refresh -h github.com -s repo,workflow` adds them). A gh login token works like a classic PAT and does not expire.
+
+**`GH_SECRETS_PAT` is optional.** The two paths that write rotated logins back as secrets (MCP OAuth refresh, grok's X login) try `GH_SECRETS_PAT` first and fall back to `GH_GLOBAL`. Set it only to keep that secrets-write power on a separate token (a fine-grained PAT with **Secrets: read/write** on this repo).
+
`read:org` and `admin:org` are **not** needed - listing an org's repos (e.g. fleet KPIs) works on `repo` scope plus org membership, and nothing administers an org. If your org enforces SAML SSO, authorize the token for the org from the token page. **Classic** (not fine-grained) is recommended: the repository-advisories / PVR API is unreliable with fine-grained PATs.
## Durable state without the churn
diff --git a/docs/harnesses.md b/docs/harnesses.md
index c817958ddc8..5c765f7d584 100644
--- a/docs/harnesses.md
+++ b/docs/harnesses.md
@@ -31,7 +31,7 @@ CI) rather than the shared `OPENROUTER_API_KEY`.
Each one runs on its own provider login (see **Native auth** below); a single
shared **`OPENROUTER_API_KEY`** is the zero-setup alternative for codex, pi,
-vibe, and kimi at once. Their model picker offers OpenRouter ids rather than the
+vibe, kimi and hermes at once (and, as a gateway, for claude). Their model picker offers OpenRouter ids rather than the
`claude-*`/`grok-*` ids, and the model you pick is what actually runs. Each of
these harnesses carries its own curated list (`CODEX_MODELS` /
`VIBE_MODELS` / `PI_MODELS` / `KIMI_MODELS`): **codex**
@@ -61,8 +61,8 @@ login locally, store the session as a repo secret, restore it on the runner):
| `kimi` | **Moonshot** device login | `aeon auth --harness kimi` (or **Connect Kimi**) → `KIMI_AUTH`. Or `--key` → `MOONSHOT_API_KEY` |
| `pi` | provider API key | `aeon auth --harness pi --key ` → the matching `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` (auto-detected) |
| `vibe` | Mistral key | `aeon auth --harness vibe --key ` → `MISTRAL_API_KEY` (vibe's default provider) |
-| `fx` | Vercel AI Gateway key (or `VERCEL_OIDC_TOKEN`) | set `AI_GATEWAY_API_KEY` as a repo secret. **No `aeon auth` flow and no OpenRouter fallback** — see below. |
-| `cursor` | Cursor API key | set `CURSOR_API_KEY` as a repo secret; headless entry point is `agent -p --trust` because every CI run starts with a fresh home. `--force` remains write-mode only. |
+| `fx` | Vercel AI Gateway key (or `VERCEL_OIDC_TOKEN`) | `aeon auth --harness fx --key ` -> `AI_GATEWAY_API_KEY`. **No login flow and no OpenRouter fallback** - see below. |
+| `cursor` | Cursor API key | `aeon auth --harness cursor --key ` -> `CURSOR_API_KEY`; headless entry point is `agent -p --trust` because every CI run starts with a fresh home. `--force` remains write-mode only. |
| `hermes` | Nous Portal OAuth | `aeon auth --harness hermes` → `HERMES_AUTH`; the adapter restores `~/.hermes/auth.json`. |
Which one runs is decided at dispatch by **which secret is set**, native first,
@@ -80,6 +80,30 @@ retries once on the account default and warns
model that actually ran, read from codex's session rollout. The workflow's *Install harness CLI* step restores/configures the
selected provider; the CLI + dashboard flows share `lib/harness-auth-server.ts`.
+### Credentials at a glance
+
+Generated from the credential manifest ([`harness-adapter/harnesses.json`](../harness-adapter/harnesses.json),
+`credentials` + `default_model`), which CI holds to `scripts/resolve-harness.sh`. Each harness
+uses the **first** secret in its list that is set; set any one. `./aeon init` walks you
+through the same list, and `bin/onboard` checks it.
+
+| harness | precedence (first set wins) | default model |
+|---------|-----------------------------|---------------|
+| `claude` | 1. `CLAUDE_CODE_OAUTH_TOKEN` - Claude subscription (Pro/Max) (login token; [get](https://claude.ai), `./aeon auth --harness claude-code`) 2. `ANTHROPIC_API_KEY` - Anthropic API key (pay as you go) (API key; [get](https://console.anthropic.com/settings/keys), `./aeon auth --key `) then any [gateway key](CONFIGURATION.md#llm-gateways) (openrouter, bankr, usepod, venice, surplus, grok, glm, hivemindos) | `claude-sonnet-5-5` |
+| `codex` | 1. `CODEX_AUTH` - ChatGPT login (Plus/Pro) (login capture; [get](https://chatgpt.com), `./aeon auth --harness codex`) 2. `OPENAI_API_KEY` - OpenAI API key (API key; [get](https://platform.openai.com/api-keys), `./aeon auth --harness codex --key `) 3. `OPENROUTER_API_KEY` - OpenRouter key (one key covers most harnesses) (API key; [get](https://openrouter.ai/settings/keys), `./aeon secrets set OPENROUTER_API_KEY --stdin`) | `openai/gpt-6-luna` |
+| `cursor` | 1. `CURSOR_API_KEY` - Cursor API key (API key; [get](https://cursor.com/dashboard), `./aeon auth --harness cursor --key `) | `auto` |
+| `fx` | 1. `AI_GATEWAY_API_KEY` - Vercel AI Gateway key (API key; [get](https://vercel.com/docs/ai-gateway), `./aeon auth --harness fx --key `) 2. `VERCEL_OIDC_TOKEN` - Vercel OIDC token (OIDC token; [get](https://vercel.com/docs/oidc), `vercel env pull`) | the harness's own default |
+| `grok` | 1. `GROK_CREDENTIALS` - X account login (grok login) (login capture; [get](https://x.ai/grok), `./aeon auth --harness grok`) 2. `XAI_API_KEY` - xAI API key (API key; [get](https://console.x.ai), `./aeon auth --harness grok --key `) | `grok-4.7` |
+| `hermes` | 1. `HERMES_AUTH` - Nous Portal login (login capture; [get](https://portal.nousresearch.com), `./aeon auth --harness hermes`) 2. `OPENROUTER_API_KEY` - OpenRouter key (one key covers most harnesses) (API key; [get](https://openrouter.ai/settings/keys), `./aeon secrets set OPENROUTER_API_KEY --stdin`) | the harness's own default |
+| `kimi` | 1. `KIMI_AUTH` - Kimi (Moonshot) login (login capture; [get](https://www.kimi.com), `./aeon auth --harness kimi`) 2. `MOONSHOT_API_KEY` - Moonshot API key (API key; [get](https://platform.moonshot.ai/console/api-keys), `./aeon auth --harness kimi --key `) 3. `OPENROUTER_API_KEY` - OpenRouter key (one key covers most harnesses) (API key; [get](https://openrouter.ai/settings/keys), `./aeon secrets set OPENROUTER_API_KEY --stdin`) | `moonshotai/kimi-k2.6` |
+| `pi` | 1. `ANTHROPIC_API_KEY` - Anthropic API key (API key; [get](https://console.anthropic.com/settings/keys), `./aeon auth --harness pi --key `) 2. `ANTHROPIC_OAUTH_TOKEN` - Claude subscription token (login token; [get](https://claude.ai), `./aeon auth --harness pi --key `) 3. `OPENAI_API_KEY` - OpenAI API key (API key; [get](https://platform.openai.com/api-keys), `./aeon auth --harness pi --key `) 4. `OPENROUTER_API_KEY` - OpenRouter key (one key covers most harnesses) (API key; [get](https://openrouter.ai/settings/keys), `./aeon secrets set OPENROUTER_API_KEY --stdin`) | `deepseek/deepseek-v4-flash` |
+| `vibe` | 1. `MISTRAL_API_KEY` - Mistral API key (API key; [get](https://console.mistral.ai/api-keys), `./aeon auth --harness vibe --key `) 2. `OPENROUTER_API_KEY` - OpenRouter key (one key covers most harnesses) (API key; [get](https://openrouter.ai/settings/keys), `./aeon secrets set OPENROUTER_API_KEY --stdin`) | `mistralai/mistral-medium-3-5` |
+
+Notes: `GROK_CREDENTIALS` needs `GH_GLOBAL` (or the optional `GH_SECRETS_PAT`) to save its
+rotating refresh token after each run (see Token accounting below). `cursor` and `fx` have no
+OpenRouter fallback; with no key `cursor` reports `AUTH_MODE=none` and stops at install.
+
+
**`fx` breaks the "OpenRouter last" rule above** — it's the one harness with no
OpenRouter fallback at all (confirmed: fx has no OpenRouter integration
anywhere in its own docs). `install-harness.sh` fails closed at the *Install
@@ -172,8 +196,10 @@ has been revoked"), so a *static* capture self-destructs ~6h after Connect. To f
that, `scripts/run-grok.sh` (§2b) refreshes the access token from the refresh token
before each run and **persists the rotated `auth.json` back to the `GROK_CREDENTIALS`
secret**. Persisting a secret needs a secrets-write credential - the default
-`GITHUB_TOKEN` cannot - so set a fine-grained PAT with **Secrets: read/write** as
-`GH_SECRETS_PAT` (or `GH_GLOBAL`). Without the PAT, grok
+`GITHUB_TOKEN` cannot - so set `GH_GLOBAL` (the instance's classic PAT with `repo` +
+`workflow`, see [Cross-repo access](CONFIGURATION.md#cross-repo-access); `./aeon init`
+sets it from your gh login). `GH_SECRETS_PAT` is optional and tried first, for
+keeping secrets-write on its own token. Without either, grok
warns loudly and auth breaks one run after the first post-expiry refresh. **After
adding the PAT, re-connect the X account once** to seed a valid refresh token (a token
already consumed by a prior run can't be revived by the PAT alone). Concurrent grok
diff --git a/docs/mcp-oauth.md b/docs/mcp-oauth.md
index 34c8d2b2df3..c43a0b3ef5d 100644
--- a/docs/mcp-oauth.md
+++ b/docs/mcp-oauth.md
@@ -54,8 +54,8 @@ discovery notes) and their companion skills:
The glim and Executor loops were verified end-to-end on a live instance
(Connect → per-run refresh → PAT-persisted rotation → refresh off the persisted
-token, no re-connect): every catalog provider rotates, so treat `GH_SECRETS_PAT`
-as **required** in practice, not optional.
+token, no re-connect): every catalog provider rotates, so treat a secrets-write
+token (`GH_GLOBAL`, or the optional `GH_SECRETS_PAT`) as **required** in practice.
## Code map
@@ -80,9 +80,12 @@ as **required** in practice, not optional.
the old one is invalidated the moment it's used, so unless the replacement is
saved the *next* headless run's refresh fails (`no access_token` / `invalid_grant`)
and auth breaks one run later. Persisting a secret needs a **secrets-write
- credential** — the default `GITHUB_TOKEN` cannot write secrets. To make refresh
- durable for rotating providers, add a fine-grained PAT with **Secrets: read/write**
- on this repo as the secret **`GH_SECRETS_PAT`** (or a repo-wide `GH_GLOBAL`);
+ credential** - the default `GITHUB_TOKEN` cannot write secrets. To make refresh
+ durable for rotating providers, set **`GH_GLOBAL`**, the instance's one classic
+ PAT with `repo` + `workflow` ([Cross-repo access](CONFIGURATION.md#cross-repo-access);
+ `./aeon init` sets it from your gh login). **`GH_SECRETS_PAT`** is optional and
+ tried first: a fine-grained PAT with **Secrets: read/write** on this repo, for
+ keeping secrets-write on its own token;
`scripts/mcp-oauth-refresh.sh` then saves each rotated refresh token back to its
`MCP__OAUTH` secret and warns loudly when it can't. **After adding the PAT,
re-connect the affected server once** to seed a valid refresh token — a refresh
diff --git a/plugin/skills/aeon/SKILL.md b/plugin/skills/aeon/SKILL.md
index bb204dc96ee..2a98d778da9 100644
--- a/plugin/skills/aeon/SKILL.md
+++ b/plugin/skills/aeon/SKILL.md
@@ -50,47 +50,34 @@ Anything it prints is on disk but unconfigured. **Orientation — what's install
Goal: one real notification in their phone, fast. Do not configure a schedule first.
-1. **Get a repo. Ask public or private before you run anything** — it changes the command, and switching later means moving the repo.
-
- **Public** (recommend this): Actions minutes are free, and upstream skill updates arrive with one command.
-
- ```bash
- gh repo fork aeonfun/aeon --clone && cd aeon
- gh repo set-default /aeon # REQUIRED — see below
- ```
-
- **Private**: a fork of a public repo is always public, so a private instance is a mirror, not a fork.
+1. **Run `./aeon init`.** Ask public or private first (public: Actions minutes are free; private: `--private`, minutes bill against the account quota, 2,000/mo on Free). Then, from a clone of the template:
```bash
- gh repo create --private
- git clone --bare https://github.com/aeonfun/aeon.git
- git -C aeon.git push --mirror https://github.com//.git
- rm -rf aeon.git && git clone https://github.com//.git && cd
- git remote add upstream https://github.com/aeonfun/aeon.git
- gh repo set-default / # REQUIRED — see below
+ git clone https://github.com/aeonfun/aeon && cd aeon
+ ./aeon init # add --private, --name , --harness as needed
```
- Say both costs out loud before they pick private: Actions minutes bill against the account quota (2,000/mo on Free — scheduled skills burn it), and updates come from `git fetch upstream && git merge upstream/main` instead of `gh repo sync`.
+ It is idempotent and prints a check or a fix per step: signs in to GitHub with the `workflow` scope, creates `/` from the **template** (not a fork: forks start with Actions disabled), points this folder at it (`aeonfun/aeon` stays as the `upstream` remote), runs `gh repo set-default`, enables Actions and lets them open PRs (the default token permission is left as is), offers to store the gh token as `GH_GLOBAL` (only if it has `repo` + `workflow`), connects a model from the credential manifest, and links Telegram with a `/start` deep link. Re-run it any time; `bin/onboard` is the read-only check. `--dry-run` shows every step without changing anything.
- **Pin the default repo before any other command — both paths.** Both end up with an `upstream` remote (`gh repo fork --clone` adds one for you), and with no default pinned **`gh` prefers `upstream` over `origin`**. Everything in Aeon routes through `gh -R $(gh repo view …)`, so an unpinned checkout silently writes secrets to and dispatches runs against `aeonfun/aeon` instead of their instance — with no error, because the commands genuinely succeed on the wrong repo. Verify:
+ **If they set things up by hand, pin the default repo before any other command.** With an `upstream` remote and no default pinned, **`gh` prefers `upstream` over `origin`**, so secrets and runs silently land on `aeonfun/aeon`. Fix and verify:
```bash
+ gh repo set-default /
gh repo view --json nameWithOwner -q .nameWithOwner # must print THEIR repo
```
- Everything after this step is identical either way.
-2. **Auth a model.** At least one is required. Fastest is `./aeon auth --harness claude-code` (Claude Pro/Max, opens a browser), or `./aeon auth --key `, which detects the provider **from the key prefix** — `sk-ant-oat` (OAuth), `sk-or-` (OpenRouter), `bk_` (Bankr), `inf_` (Surplus), `xai-` (Grok); anything else lands in `ANTHROPIC_API_KEY`.
+2. **Auth a model** (if `init` skipped it). At least one is required. The choices per harness, in the order the workflow uses them, are in `harness-adapter/harnesses.json` (`credentials`) and the table in `docs/harnesses.md`. Fastest is `./aeon auth --harness claude-code` (Claude Pro/Max, opens a browser), or `./aeon auth --key `, which detects the provider **from the key prefix**: `sk-ant-oat` (OAuth), `sk-or-` (OpenRouter), `bk_` (Bankr), `inf_` (Surplus), `xai-` (Grok); anything else lands in `ANTHROPIC_API_KEY`.
- **UsePod and Venice keys have no prefix** and are undetectable, so a bare `--key` files them as a plain Anthropic key and the run fails later with a confusing auth error. They must be named:
+ **UsePod, Venice, GLM and HivemindOS keys have no prefix** and are undetectable, so a bare `--key` files them as a plain Anthropic key and the run fails later with a confusing auth error. They must be named:
```bash
- ./aeon auth --key --provider usepod # same for venice
+ ./aeon auth --key --provider usepod # same for venice, glm, hivemindos
```
- `--dry-run` prints the resolved `method=… → secret …` without calling `gh` or `claude` — worth running whenever the provider is in doubt.
+ `--dry-run` prints the resolved `method=... -> secret ...` without calling `gh` or `claude`; run it whenever the provider is in doubt.
**Don't assume they have a Claude subscription:** ten providers work, including OpenRouter, Grok, GLM, and crypto-settled gateways. See "Providers and harnesses".
-3. **Wire one channel.** Telegram is the fastest: create a bot with @BotFather, then `./aeon secrets set TELEGRAM_BOT_TOKEN --stdin` and `TELEGRAM_CHAT_ID`. Skip Discord/Slack/email for now — one channel is enough to prove it works.
+3. **Wire one channel** (if `init` skipped it). Telegram is the fastest: `./aeon init` asks for the @BotFather token and links the chat for them; by hand it is `./aeon secrets set TELEGRAM_BOT_TOKEN --stdin` and `TELEGRAM_CHAT_ID`. Skip Discord/Slack/email for now; one channel is enough to prove it works.
4. **Run one skill now.** Pick it with Mode 6 — ask what they want handled, propose one — then `./aeon skills run `. Wait for it, then `./aeon runs logs `. They should get a Telegram message.
5. **Only then, schedule it.** `./aeon skills enable ` and set a time (see Mode 2).
From 8c3cdf28706143bba902afdcbc5f1bb2a964e338 Mon Sep 17 00:00:00 2001
From: Aaron Elijah Mars <61592645+aaronjmars@users.noreply.github.com>
Date: Fri, 2 Oct 2026 16:29:17 -0400
Subject: [PATCH 04/20] fix(init): resumable switch-over, explicit origin
pushes, TTY-safe prompts
Review fixes for aeon init:
- Switch-over fetches and checks out the instance under a temporary remote
first and renames remotes only after that worked, so an interruption can
never leave main tracking the template; a re-run inside an instance
repairs a branch that still tracks something other than origin.
- commitAndPush pushes to origin by name (HEAD:, pull --rebase from
origin) and refuses when origin is the template; the edit stays local.
- --harness on an already-connected harness reports it and only switches
aeon.yml; no login is re-run (grok rotates its capture on login).
- Without a terminal every confirm is "no" unless --yes, and no browser
login or key prompt is started.
- --dir waits for the template copy to have aeon.yml, verifies the clone,
removes only what it created, refuses a non-empty folder, and reports a
failed hand-over.
- Dirty / local-commit checks run before the repo is created and count
commits on every branch.
- Secret checks query the instance with -R, so a dry run from a template
clone never reads aeonfun/aeon.
- Telegram: still no offset; a backlog of 100+ updates goes straight to
the manual chat id.
- GH_GLOBAL scope refusals are a typed error, 400 from the route, with
neutral wording; docs and init note that gh login tokens can be revoked
and recommend a dedicated classic PAT for long-lived instances.
- bin/onboard: --remote help matches what heartbeat does; an unknown
harness warns and checks claude, as resolve-harness.sh runs it.
- test_credential_manifest: key-order independent registry parse, guarded
lookups, oauth capture parity check, clearer CLAUDE_AUTH_SECRETS message.
Tests: apps/cli/test/init-sandbox.sh (fake gh + bare repos, 34 checks, in
ci-apps) and lib/github-push.test.ts (commitAndPush against local repos).
---
.github/workflows/ci-apps.yml | 4 +
apps/cli/src/commands/init.ts | 234 +++++++++++++++-----
apps/cli/test/fake-gh | 88 ++++++++
apps/cli/test/init-sandbox.sh | 137 ++++++++++++
apps/dashboard/app/api/github-auth/route.ts | 7 +-
apps/dashboard/lib/github-auth.test.ts | 5 +-
apps/dashboard/lib/github-auth.ts | 20 +-
apps/dashboard/lib/github-push.fixture.ts | 11 +
apps/dashboard/lib/github-push.test.ts | 69 ++++++
apps/dashboard/lib/github.ts | 23 +-
bin/onboard | 20 +-
docs/CONFIGURATION.md | 2 +-
scripts/tests/test_credential_manifest.sh | 28 ++-
13 files changed, 569 insertions(+), 79 deletions(-)
create mode 100755 apps/cli/test/fake-gh
create mode 100755 apps/cli/test/init-sandbox.sh
create mode 100644 apps/dashboard/lib/github-push.fixture.ts
create mode 100644 apps/dashboard/lib/github-push.test.ts
diff --git a/.github/workflows/ci-apps.yml b/.github/workflows/ci-apps.yml
index 018c5ebf43e..45c1ba82edc 100644
--- a/.github/workflows/ci-apps.yml
+++ b/.github/workflows/ci-apps.yml
@@ -106,6 +106,10 @@ jobs:
- name: Lint (eslint, borrows the dashboard install)
run: npm run lint
working-directory: apps/cli
+ # `aeon init` end to end against a fake gh (a directory of bare repos):
+ # template switch-over, re-run, resume, --dir, refusals. No network.
+ - name: aeon init sandbox tests
+ run: bash apps/cli/test/init-sandbox.sh
mcp-server:
name: mcp-server — build
diff --git a/apps/cli/src/commands/init.ts b/apps/cli/src/commands/init.ts
index 4eb4607ca30..4b4f7a9dbd1 100644
--- a/apps/cli/src/commands/init.ts
+++ b/apps/cli/src/commands/init.ts
@@ -13,13 +13,13 @@
// 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 { existsSync, readFileSync, readdirSync, rmSync } 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 { 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'
@@ -92,8 +92,11 @@ async function ask(question: string, def = ''): Promise {
}
}
+// Every confirm guards a change. Without a terminal nobody can answer, so the
+// answer is "no" unless --yes said to take the defaults.
async function confirm(opts: Opts, question: string, def = true): Promise {
- if (opts.yes || !interactive) return def
+ if (!interactive) return opts.yes ? def : false
+ if (opts.yes) return def
const a = (await ask(`${question} ${c.dim(def ? '[Y/n]' : '[y/N]')}`)).toLowerCase()
if (!a) return def
return a.startsWith('y')
@@ -170,6 +173,10 @@ async function stepGh(): Promise {
report('gh auth', 'warn', 'not signed in', 'would run: gh auth login --web -s workflow')
return ''
}
+ if (!interactive) {
+ report('gh auth', 'fail', 'not signed in to GitHub (the browser sign-in needs a terminal)', 'gh auth login --web -s workflow, then re-run ./aeon init')
+ finish(false)
+ }
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')
@@ -191,9 +198,9 @@ async function stepGh(): Promise {
'unset GH_TOKEN GITHUB_TOKEN, then re-run ./aeon init')
return login
}
- if (isDryRun()) {
+ if (isDryRun() || !interactive) {
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(',')}`)
+ `${isDryRun() ? '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).`))
@@ -224,6 +231,7 @@ async function stepRepo(opts: Opts, login: string): Promise {
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}`)
+ await ensureTracksOrigin(here)
return here
}
if (!login) {
@@ -242,6 +250,10 @@ async function stepRepo(opts: Opts, login: string): Promise {
'./aeon init --name (e.g. --name my-aeon)')
finish(false)
}
+ // Switching this folder over must not cost any work, so check that BEFORE
+ // creating anything on GitHub.
+ if (!opts.dir) preflightFolder(opts)
+
if (view.ok) {
if (!isAeonRepo(target)) {
report('repo', 'fail', `${target} already exists and is not an Aeon repo (no aeon.yml)`, './aeon init --name ')
@@ -254,7 +266,8 @@ async function stepRepo(opts: Opts, login: string): Promise {
? '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})`)
+ report('repo', 'fail', interactive ? 'no instance repo' : 'no instance repo (no terminal to confirm; pass --yes to create it)',
+ `./aeon init --yes (or gh repo create ${target} --template ${TEMPLATE} --${vis})`)
finish(false)
}
if (isDryRun()) {
@@ -274,7 +287,17 @@ async function stepRepo(opts: Opts, login: string): Promise {
}
if (opts.dir) return cloneElsewhere(opts, target)
- return adoptThisFolder(opts, here, target)
+ return adoptThisFolder(here, target)
+}
+
+// GitHub fills a template copy in asynchronously: the repo exists (and clones
+// "successfully", empty) a few seconds before its files do. Wait for aeon.yml.
+async function waitForContent(target: string): Promise {
+ for (let i = 0; i < 20; i++) {
+ if (i) await sleep(3000)
+ if (isAeonRepo(target)) return true
+ }
+ return false
}
// Clone the instance into --dir and hand over to that checkout's own
@@ -286,75 +309,154 @@ async function cloneElsewhere(opts: Opts, target: string): Promise {
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 (existsSync(dir) && readdirSync(dir).length) {
+ report('clone', 'fail', `${dir} exists, is not empty and is not an Aeon checkout`, `pick an empty or new folder: ./aeon init --dir `)
+ finish(false)
+ }
+ if (!(await waitForContent(target))) {
+ report('clone', 'fail', `${target} is still empty (GitHub has not finished copying the template)`, 'wait a minute, then re-run the same ./aeon init command')
+ finish(false)
+ }
+ let cloned = false
+ let err = ''
+ for (let i = 0; i < 5 && !cloned; i++) {
if (i) await sleep(3000)
- cloned = run('gh', ['repo', 'clone', target, dir], process.cwd())
+ const r = run('gh', ['repo', 'clone', target, dir], process.cwd())
+ cloned = r.ok && existsSync(join(dir, 'aeon.yml'))
+ err = r.err.split('\n')[0] || (r.ok ? 'the clone had no aeon.yml' : '')
+ // Only ever remove what this run created.
+ if (!cloned) rmSync(dir, { recursive: true, force: true })
}
- if (!cloned.ok) {
- report('clone', 'fail', `could not clone ${target}: ${cloned.err.split('\n')[0]}`, `gh repo clone ${target} ${dir}`)
+ if (!cloned) {
+ report('clone', 'fail', `could not clone ${target}: ${err}`, `gh repo clone ${target} ${dir}`)
finish(false)
}
}
+ const launcher = join(dir, 'aeon')
+ if (!existsSync(launcher)) {
+ report('clone', 'fail', `${dir} has no ./aeon launcher`, `cd ${dir} && git pull, then ./aeon init`)
+ finish(false)
+ }
report('clone', 'fixed', `cloned ${target} into ${dir}; continuing there`)
+ printSummary()
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 })
+ const r = spawnSync(launcher, args, { cwd: dir, stdio: 'inherit', env })
+ if (r.error) {
+ console.error(c.red('error: ') + `could not start ${launcher}: ${r.error.message}. Run it yourself: cd ${dir} && ./aeon init`)
+ process.exit(1)
+ }
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 {
+// The checks that make switching this folder over safe: no uncommitted edits
+// and no local commits that exist on no remote (any branch).
+function preflightFolder(opts: Opts) {
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')
+ const local = git('rev-list', '--count', '--branches', '--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)
}
+}
+
+const TEMP_REMOTE = 'aeon-instance'
+
+// Turn this checkout of the template into the operator's instance. Ordered so
+// an interruption at any point is safe and resumable: the instance is fetched
+// and checked out under a temporary remote FIRST, and the remotes are renamed
+// (template -> upstream, instance -> origin) only after that worked. A re-run
+// always ends with the branch tracking origin/.
+async function adoptThisFolder(here: string | null, target: string): Promise {
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`)
+ const remotes = () => git('remote').out.split('\n').filter(Boolean)
+ if (remotes().includes(TEMP_REMOTE)) git('remote', 'remove', TEMP_REMOTE)
+ const url = `https://github.com/${target}.git`
+ const add = git('remote', 'add', TEMP_REMOTE, url)
if (!add.ok) {
- report('folder', 'fail', `could not add the origin remote: ${add.err}`, `git remote add origin https://github.com/${target}.git`)
+ report('folder', 'fail', `could not add a remote for ${target}: ${add.err}`, `git remote add origin ${url}`)
+ finish(false)
+ }
+ if (!(await waitForContent(target))) {
+ report('folder', 'fail', `${target} is still empty (GitHub has not finished copying the template)`, 'wait a minute, then re-run ./aeon init')
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++) {
+ for (let i = 0; i < 5 && !fetched; i++) {
if (i) await sleep(3000)
- fetched = git('fetch', 'origin').ok && git('rev-parse', '--verify', '--quiet', `refs/remotes/origin/${branch}`).ok
+ fetched = git('fetch', TEMP_REMOTE).ok && git('rev-parse', '--verify', '--quiet', `refs/remotes/${TEMP_REMOTE}/${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')
+ report('folder', 'fail', `could not fetch ${target} (${branch})`, 'check your network, then re-run ./aeon init')
finish(false)
}
- const co = git('checkout', '-B', branch, `origin/${branch}`)
+ const co = git('checkout', '-B', branch, `${TEMP_REMOTE}/${branch}`)
if (!co.ok) {
- report('folder', 'fail', `could not check out ${target}: ${co.err.split('\n')[0]}`, `git checkout -B ${branch} origin/${branch}`)
+ report('folder', 'fail', `could not check out ${target}: ${co.err.split('\n')[0]}`, 're-run ./aeon init')
+ finish(false)
+ }
+ // Only now move the remotes. The old origin (the template) becomes upstream;
+ // if an upstream already exists it is kept and the template origin dropped.
+ if (remotes().includes('origin')) {
+ if (!remotes().includes('upstream')) git('remote', 'rename', 'origin', 'upstream')
+ else if (here && isUpstreamRepo(here)) git('remote', 'remove', 'origin')
+ else git('remote', 'rename', 'origin', 'origin-previous')
+ }
+ const ren = git('remote', 'rename', TEMP_REMOTE, 'origin')
+ if (!ren.ok) {
+ report('folder', 'fail', `could not rename the ${TEMP_REMOTE} remote to origin: ${ren.err}`, 're-run ./aeon init')
finish(false)
}
git('branch', `--set-upstream-to=origin/${branch}`, branch)
+ const up = git('rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}').out
+ if (up !== `origin/${branch}`) {
+ report('folder', 'fail', `${branch} tracks ${up || 'nothing'}, not origin/${branch}`, `git branch --set-upstream-to=origin/${branch} ${branch}`)
+ finish(false)
+ }
report('folder', 'fixed', `this folder now tracks ${target}${here ? ` (${here} kept as the upstream remote)` : ''}`)
return target
}
+// Already inside the instance: make sure the current branch tracks origin, not
+// the template (what an interrupted switch-over from an older init, or a hand
+// setup, can leave behind). Pushes from init go to origin by name regardless.
+async function ensureTracksOrigin(slug: string) {
+ const cur = git('rev-parse', '--abbrev-ref', 'HEAD').out
+ const up = git('rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}')
+ if (up.ok && up.out.startsWith('origin/')) return
+ if (!cur || cur === 'HEAD') {
+ report('folder', 'warn', 'no branch is checked out (detached HEAD)', 'git checkout main')
+ return
+ }
+ const branch = gh('repo', 'view', slug, '--json', 'defaultBranchRef', '-q', '.defaultBranchRef.name').out || 'main'
+ const was = up.ok ? up.out : 'nothing'
+ if (cur !== branch) {
+ report('folder', 'warn', `you are on ${cur} (tracking ${was}); scheduled runs read ${branch}`, `git checkout ${branch}`)
+ return
+ }
+ if (isDryRun()) {
+ report('folder', 'skip', `would point ${branch} at origin/${branch} (now tracking ${was})`)
+ return
+ }
+ if (!git('fetch', 'origin').ok || !git('rev-parse', '--verify', '--quiet', `refs/remotes/origin/${branch}`).ok) {
+ report('folder', 'fail', `could not fetch origin/${branch} from ${slug}`, `git fetch origin && git branch --set-upstream-to=origin/${branch} ${branch}`)
+ finish(false)
+ }
+ git('branch', `--set-upstream-to=origin/${branch}`, branch)
+ report('folder', 'fixed', `${branch} now tracks origin/${branch} (was ${was})`)
+}
+
function stepDefaultRepo(slug: string) {
heading(3, 'Default repo for gh')
if (isUpstreamRepo(slug)) {
@@ -430,8 +532,13 @@ function stepActions(slug: string) {
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 }
+// Secret names on the instance itself (`-R slug`), never on whatever gh would
+// infer: in a dry run from a template clone that would be aeonfun/aeon. A repo
+// that does not exist yet (dry run before create) simply has none.
+function secretNames(slug: string): Set | null {
+ const r = gh('secret', 'list', '-R', slug, '--json', 'name', '-q', '.[].name')
+ if (r.ok) return new Set(r.out.split('\n').filter(Boolean))
+ return isDryRun() ? new Set() : null
}
async function stepGhGlobal(opts: Opts, secrets: Set | null) {
@@ -448,7 +555,9 @@ async function stepGhGlobal(opts: Opts, secrets: Set | null) {
`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)'))) {
+ note('Good for getting started. GitHub revokes a gh login token after a year unused, or when you have more than 10 for the')
+ note('same app and scopes, so for an instance that runs for a long time use a dedicated classic PAT (repo + workflow).')
+ if (!(await confirm(opts, 'Store your gh token as GH_GLOBAL now? (scopes: repo, workflow)'))) {
report('GH_GLOBAL', 'skip', 'GH_GLOBAL not set (optional)', './aeon auth --github')
return
}
@@ -501,6 +610,18 @@ async function runLogin(h: HarnessManifest, cred: Credential): Promise {
return captureHarnessCreds(h.id).secret
}
+// Point aeon.yml's harness: at `id` (commit + push to origin via the lib).
+async function switchHarness(id: string) {
+ if (isDryRun()) { report('harness', 'skip', `would set harness: ${id} in aeon.yml`); return }
+ try {
+ const sync = await syncHarness(id as Harness)
+ report('harness', sync.synced ? 'fixed' : 'warn', `aeon.yml now runs ${id}${sync.synced ? ' (pushed)' : ` (saved locally, not pushed: ${sync.reason ?? 'unknown'})`}`,
+ sync.synced ? undefined : './aeon sync')
+ } catch (e) {
+ report('harness', 'fail', `could not set harness: ${e instanceof Error ? e.message : String(e)}`, `./aeon config set harness ${id}`)
+ }
+}
+
async function stepModel(opts: Opts, secrets: Set | null) {
heading(6, 'Model')
const harnesses = loadHarnesses()
@@ -509,11 +630,18 @@ async function stepModel(opts: Opts, secrets: Set | null) {
const configured = currentHarness()
const have = (h: HarnessManifest) => runnableSecrets(h, gateways).find((s) => secrets?.has(s))
- const ready = byId.get(configured)
+ const asked = opts.harness ? byId.get(opts.harness === 'claude-code' ? 'claude' : opts.harness) : undefined
+ if (opts.harness && !asked) fail(`unknown harness '${opts.harness}'. One of: ${harnesses.map((h) => h.id).join(', ')}`)
+
+ // Already connected? Then do not run a login again: some captures rotate on
+ // every login (grok), so a needless re-login can break a working setup.
+ // --harness that is connected only switches aeon.yml to it.
+ const ready = asked ?? byId.get(configured)
const usedBy = ready && have(ready)
- if (usedBy && !opts.harness) {
+ if (ready && usedBy) {
report('model', 'ok', `${ready.label} is connected (${usedBy})`)
- if (!(await confirm(opts, 'Connect a different model?', false))) return
+ if (ready.id !== configured) await switchHarness(ready.id)
+ if (asked || !(await confirm(opts, 'Connect a different model?', false))) return
}
console.log('')
@@ -524,8 +652,11 @@ async function stepModel(opts: Opts, secrets: Set | null) {
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(', ')}`)
+ let pick = asked
+ if (!pick && !interactive && !isDryRun()) {
+ report('model', 'warn', 'no model connected (choosing one needs a terminal)', './aeon init (in a terminal), or ./aeon init --harness ')
+ return
+ }
if (!pick) {
const def = String(harnesses.findIndex((h) => h.id === configured) + 1)
const ans = await ask('Which agent should run your skills?', def)
@@ -560,8 +691,9 @@ async function stepModel(opts: Opts, secrets: Set | null) {
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)
+ // Logins open a browser and keys are pasted: both need someone at a terminal.
+ if (!interactive) {
+ report('model', 'warn', `${h.label}: not connected (${isLogin ? 'the login' : 'pasting a key'} needs a terminal)`, choice.how)
return
}
@@ -593,14 +725,7 @@ async function stepModel(opts: Opts, secrets: Set | null) {
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}`)
- }
- }
+ if (h.id !== configured) await switchHarness(h.id)
}
// --- Telegram ---------------------------------------------------------------
@@ -668,6 +793,13 @@ async function stepTelegram(opts: Opts, secrets: Set | null) {
chatId = await manualChatId('This bot has a webhook set, so its updates cannot be read here.')
break
}
+ // Without an offset getUpdates returns at most the 100 oldest pending
+ // updates, so a busy bot would hide the /start forever. An offset would
+ // confirm (drop) updates the messages.yml poller still needs, so ask instead.
+ if ((up?.result?.length ?? 0) >= 100) {
+ chatId = await manualChatId('This bot has a backlog of unread updates, so the /start message cannot be picked out 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)
@@ -735,11 +867,11 @@ export async function initCommand(argv: string[]) {
finish(false)
}
stepActions(slug)
- const secrets = secretNames()
+ const secrets = secretNames(slug)
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)
+ await stepTelegram(opts, secretNames(slug) ?? secrets)
const failed = rows.some((r) => r.status === 'fail')
// --yes means unattended, and the dashboard is a foreground server: never
diff --git a/apps/cli/test/fake-gh b/apps/cli/test/fake-gh
new file mode 100755
index 00000000000..1686c9c6990
--- /dev/null
+++ b/apps/cli/test/fake-gh
@@ -0,0 +1,88 @@
+#!/usr/bin/env bash
+# fake-gh - a stand-in for the GitHub CLI used by init-sandbox.sh. "GitHub" is
+# a directory of bare repos at $FAKE_GH_ROOT/github.com//.git
+# (git reaches it through a url.insteadOf rewrite), and every other piece of
+# state (default repo, secrets, Actions settings) lives in $FAKE_GH_STATE.
+# Every call is appended to $FAKE_GH_STATE/calls, so a test can assert both
+# what happened and what did NOT (no login, no repo create, no PUT).
+set -uo pipefail
+ROOT="$FAKE_GH_ROOT/github.com"
+ST="$FAKE_GH_STATE"
+mkdir -p "$ST"
+printf '%s\n' "$*" >> "$ST/calls"
+
+bare() { printf '%s/%s.git' "$ROOT" "$1"; }
+exists() { [ -d "$(bare "$1")" ]; }
+key() { printf '%s' "$1" | tr '/' '_'; }
+# value of `-R ` in the args, else the default repo
+repo_arg() {
+ local prev=""
+ for a in "$@"; do
+ if [ "$prev" = "-R" ]; then printf '%s' "$a"; return; fi
+ prev="$a"
+ done
+ cat "$ST/default" 2>/dev/null
+}
+
+case "${1:-} ${2:-}" in
+ "--version "*) echo "gh version 9.9.9 (fake)" ;;
+ "auth status") exit 0 ;;
+ "auth token") echo "gho_fakesandboxtoken" ;;
+ "auth login"|"auth refresh") echo "fake-gh: interactive auth is not available" >&2; exit 1 ;;
+ "repo create")
+ slug="$3"
+ exists "$slug" && { echo "fake-gh: $slug already exists" >&2; exit 1; }
+ mkdir -p "$(dirname "$(bare "$slug")")"
+ git clone -q --bare "$(bare aeonfun/aeon)" "$(bare "$slug")"
+ echo "https://github.com/$slug" ;;
+ "repo view")
+ slug="${3:-}"
+ case "$slug" in -*|"") slug="$(cat "$ST/default" 2>/dev/null)" ;; esac
+ exists "$slug" || { echo "GraphQL: Could not resolve to a Repository" >&2; exit 1; }
+ case "$*" in
+ *defaultBranchRef*) echo main ;;
+ *) echo "$slug" ;;
+ esac ;;
+ "repo set-default")
+ if [ "${3:-}" = "--view" ]; then
+ cat "$ST/default" 2>/dev/null || echo "no default repository has been set"
+ else
+ printf '%s' "$3" > "$ST/default"
+ fi ;;
+ "repo clone") git clone -q "https://github.com/$3" "$4" ;;
+ "secret list")
+ slug="$(repo_arg "$@")"
+ exists "$slug" || exit 1
+ cat "$ST/secrets-$(key "$slug")" 2>/dev/null; exit 0 ;;
+ "secret set")
+ slug="$(repo_arg "$@")"
+ cat > /dev/null
+ echo "$3" >> "$ST/secrets-$(key "$slug")" ;;
+ "workflow run") exit 0 ;;
+ "api user") echo "tester" ;;
+ "api -i")
+ printf 'HTTP/2.0 200 OK\r\nX-Oauth-Scopes: gist, read:org, repo, workflow\r\n\r\n{"login":"tester"}\n' ;;
+ "api -X")
+ path="$4"
+ slug="$(printf '%s' "$path" | cut -d/ -f2-3)"
+ echo "PUT $*" >> "$ST/puts"
+ case "$path" in
+ */actions/permissions) echo '{"enabled":true,"allowed_actions":"all"}' > "$ST/perms-$(key "$slug")" ;;
+ */actions/permissions/workflow)
+ d="$(printf '%s' "$*" | sed -n 's/.*default_workflow_permissions=\([a-z]*\).*/\1/p')"
+ printf '{"default_workflow_permissions":"%s","can_approve_pull_request_reviews":true}' "${d:-read}" > "$ST/wf-$(key "$slug")" ;;
+ esac ;;
+ "api repos/"*)
+ path="$2"
+ slug="$(printf '%s' "$path" | cut -d/ -f2-3)"
+ exists "$slug" || { echo "HTTP 404" >&2; exit 1; }
+ case "$path" in
+ */contents/aeon.yml) git --git-dir="$(bare "$slug")" cat-file -e HEAD:aeon.yml 2>/dev/null || { echo "HTTP 404" >&2; exit 1; } ;;
+ */actions/permissions) cat "$ST/perms-$(key "$slug")" 2>/dev/null || echo '{"enabled":false}' ;;
+ */actions/permissions/workflow)
+ cat "$ST/wf-$(key "$slug")" 2>/dev/null || echo '{"default_workflow_permissions":"read","can_approve_pull_request_reviews":false}' ;;
+ */actions/workflows) : ;;
+ *) echo '{}' ;;
+ esac ;;
+ *) echo "fake-gh: unhandled: $*" >&2; exit 1 ;;
+esac
diff --git a/apps/cli/test/init-sandbox.sh b/apps/cli/test/init-sandbox.sh
new file mode 100755
index 00000000000..423b323648d
--- /dev/null
+++ b/apps/cli/test/init-sandbox.sh
@@ -0,0 +1,137 @@
+#!/usr/bin/env bash
+# init-sandbox.sh - end-to-end tests for `aeon init` with no network and no real
+# GitHub: `gh` is apps/cli/test/fake-gh (a directory of bare repos plus a state
+# dir), and git reaches "https://github.com/..." through a url.insteadOf
+# rewrite into that directory. Runs the real CLI (apps/cli/aeon) non-interactively.
+#
+# Covers: switching a template clone over (origin = instance, upstream =
+# template, main tracks origin/main, Actions settings changed without touching
+# the default token permission), idempotent re-run, repairing a branch left
+# tracking the template, resuming an interrupted switch-over, refusing to create
+# anything without a terminal or --yes, refusing a dirty folder BEFORE creating
+# the repo, --dir waiting for content and handing over, and --harness on an
+# already-connected harness only switching aeon.yml (no login).
+#
+# Run: bash apps/cli/test/init-sandbox.sh (needs apps/cli deps: npm ci in apps/cli)
+set -uo pipefail
+HERE="$(cd "$(dirname "$0")" && pwd)"
+CLI="$HERE/../aeon"
+T="$(mktemp -d)"
+trap 'rm -rf "$T"' EXIT
+fail=0
+pass() { echo "ok - $1"; }
+bad() { echo "FAIL - $1"; fail=1; }
+
+export FAKE_GH_ROOT="$T/fake" FAKE_GH_STATE="$T/state"
+GH="$FAKE_GH_ROOT/github.com"
+mkdir -p "$T/bin" "$GH/aeonfun"
+ln -s "$HERE/fake-gh" "$T/bin/gh"
+export PATH="$T/bin:$PATH"
+export GIT_CONFIG_GLOBAL="$T/gitconfig" GIT_CONFIG_NOSYSTEM=1
+git config --global url."file://$GH/".insteadOf "https://github.com/"
+git config --global user.email "tester@example.com"
+git config --global user.name "tester"
+git config --global init.defaultBranch main
+git config --global commit.gpgsign false
+unset GH_TOKEN GITHUB_TOKEN GITHUB_REPO
+
+# The template: a tiny Aeon-shaped repo. Its ./aeon is a stub that records the
+# hand-over from `init --dir`.
+mkdir -p "$T/tpl"
+printf 'model: claude-sonnet-5-5\ngateway: { provider: auto }\nskills:\n heartbeat: { enabled: true }\n' > "$T/tpl/aeon.yml"
+printf '#!/usr/bin/env bash\necho "child-init $*" > "%s/child-args"\n' "$T" > "$T/tpl/aeon"
+chmod +x "$T/tpl/aeon"
+git -C "$T/tpl" init -q && git -C "$T/tpl" add -A && git -C "$T/tpl" commit -q -m template
+git clone -q --bare "$T/tpl" "$GH/aeonfun/aeon.git"
+
+clone_template() { # clone_template : a fresh `git clone` of aeonfun/aeon
+ git clone -q https://github.com/aeonfun/aeon "$1"
+}
+init() { # init [args...]: run the real CLI against , no TTY
+ local dir="$1"; shift
+ (cd "$dir" && AEON_REPO_ROOT="$dir" "$CLI" init --no-telegram --no-dashboard "$@" "$T/out" 2>&1
+}
+u() { git -C "$1" rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null; }
+url() { git -C "$1" remote get-url "$2" 2>/dev/null; }
+
+# --- 1. switch a template clone over --------------------------------------------
+W="$T/w1"; clone_template "$W"
+init "$W" --yes; rc=$?
+[ "$rc" = 0 ] && pass "fresh run exits 0" || { bad "fresh run exited $rc"; cat "$T/out"; }
+[ -d "$GH/tester/aeon.git" ] && pass "instance tester/aeon created from the template" || bad "instance not created"
+case "$(url "$W" origin)" in *github.com/tester/aeon.git) pass "origin is the instance" ;; *) bad "origin is $(url "$W" origin)" ;; esac
+case "$(url "$W" upstream)" in *github.com/aeonfun/aeon*) pass "upstream is the template" ;; *) bad "upstream is $(url "$W" upstream)" ;; esac
+[ "$(u "$W")" = "origin/main" ] && pass "main tracks origin/main" || bad "main tracks $(u "$W")"
+git -C "$W" remote | grep -qx aeon-instance && bad "temporary remote left behind" || pass "no temporary remote left"
+[ "$(cat "$FAKE_GH_STATE/default")" = "tester/aeon" ] && pass "gh default repo is the instance" || bad "gh default is $(cat "$FAKE_GH_STATE/default")"
+grep -q "actions/permissions -F enabled=true -f allowed_actions=all" "$FAKE_GH_STATE/puts" && pass "Actions enabled (allowed_actions was unset)" || bad "Actions not enabled"
+grep "actions/permissions/workflow" "$FAKE_GH_STATE/puts" | grep -q "default_workflow_permissions=read -F can_approve_pull_request_reviews=true" \
+ && pass "PR approval on, default token kept at read" || bad "workflow permissions PUT wrong: $(grep workflow "$FAKE_GH_STATE/puts")"
+grep -q "default_workflow_permissions=write" "$FAKE_GH_STATE/puts" && bad "default token was raised to write" || pass "default token never raised to write"
+grep -qx GH_GLOBAL "$FAKE_GH_STATE/secrets-tester_aeon" && pass "GH_GLOBAL stored on the instance (--yes)" || bad "GH_GLOBAL not stored"
+grep -Eq "^auth (login|refresh)" "$FAKE_GH_STATE/calls" && bad "a browser login was started without a terminal" || pass "no login flow without a terminal"
+
+# --- 2. idempotent re-run -----------------------------------------------------------
+puts_before="$(wc -l < "$FAKE_GH_STATE/puts")"
+init "$W" --yes; rc=$?
+[ "$rc" = 0 ] && pass "re-run exits 0" || { bad "re-run exited $rc"; cat "$T/out"; }
+[ "$(wc -l < "$FAKE_GH_STATE/puts")" = "$puts_before" ] && pass "re-run changes no settings" || bad "re-run sent PUTs"
+grep -q "this folder is your instance: tester/aeon" "$T/out" && pass "re-run recognises the instance" || bad "re-run did not recognise the instance"
+
+# --- 3. a branch left tracking the template is repaired --------------------------
+git -C "$W" branch -q --set-upstream-to=upstream/main main
+init "$W" --yes
+[ "$(u "$W")" = "origin/main" ] && pass "main re-pointed at origin/main" || bad "main still tracks $(u "$W")"
+grep -q "main now tracks origin/main (was upstream/main)" "$T/out" && pass "repair reported" || bad "repair not reported"
+
+# --- 4. --harness on a connected harness only switches aeon.yml -----------------
+echo OPENROUTER_API_KEY >> "$FAKE_GH_STATE/secrets-tester_aeon"
+: > "$FAKE_GH_STATE/calls"
+init "$W" --yes --harness codex; rc=$?
+[ "$rc" = 0 ] && pass "--harness codex exits 0" || { bad "--harness codex exited $rc"; cat "$T/out"; }
+grep -q "OpenAI Codex CLI is connected (OPENROUTER_API_KEY)" "$T/out" && pass "codex seen as connected" || bad "codex not seen as connected"
+git --git-dir="$GH/tester/aeon.git" show main:aeon.yml | grep -q '^harness: codex' \
+ && pass "aeon.yml harness: codex pushed to the instance" || bad "harness switch not pushed to origin"
+git --git-dir="$GH/aeonfun/aeon.git" show main:aeon.yml | grep -q '^harness:' && bad "template was pushed to" || pass "template untouched"
+
+# --- 5. resume an interrupted switch-over ------------------------------------------
+# State after a crash mid-adopt: the instance was fetched under the temporary
+# remote and checked out, origin was renamed to upstream, nothing else.
+W="$T/w5"; clone_template "$W"
+"$HERE/fake-gh" repo create tester/aeon5 --template aeonfun/aeon --public >/dev/null
+git -C "$W" remote add aeon-instance https://github.com/tester/aeon5.git
+git -C "$W" fetch -q aeon-instance
+git -C "$W" checkout -q -B main aeon-instance/main
+git -C "$W" remote rename origin upstream
+init "$W" --yes --name aeon5; rc=$?
+[ "$rc" = 0 ] && pass "interrupted switch-over resumes" || { bad "resume exited $rc"; cat "$T/out"; }
+case "$(url "$W" origin)" in *github.com/tester/aeon5.git) pass "resumed: origin is the instance" ;; *) bad "resumed origin is $(url "$W" origin)" ;; esac
+[ "$(u "$W")" = "origin/main" ] && pass "resumed: main tracks origin/main" || bad "resumed: main tracks $(u "$W")"
+
+# --- 6. no terminal and no --yes: nothing is created ------------------------------
+W="$T/w6"; clone_template "$W"
+init "$W" --name aeon6; rc=$?
+[ "$rc" != 0 ] && pass "no --yes without a terminal stops" || bad "ran without confirmation"
+[ -d "$GH/tester/aeon6.git" ] && bad "repo created without confirmation" || pass "no repo created"
+grep -q "pass --yes" "$T/out" && pass "says to pass --yes" || bad "missing --yes hint"
+case "$(url "$W" origin)" in *aeonfun/aeon*) pass "folder left untouched" ;; *) bad "folder changed: $(url "$W" origin)" ;; esac
+
+# --- 7. a dirty folder is refused BEFORE the repo is created ---------------------
+W="$T/w7"; clone_template "$W"
+echo "# local edit" >> "$W/aeon.yml"
+init "$W" --yes --name aeon7; rc=$?
+[ "$rc" != 0 ] && pass "dirty folder stops" || bad "dirty folder was switched over"
+[ -d "$GH/tester/aeon7.git" ] && bad "repo created before the dirty check" || pass "dirty check runs before create"
+
+# --- 8. --dir clones the instance and hands over -----------------------------------
+W="$T/w8"; clone_template "$W"
+init "$W" --yes --name aeon8 --dir "$T/d8"; rc=$?
+[ "$rc" = 0 ] && pass "--dir exits with the child's status" || { bad "--dir exited $rc"; cat "$T/out"; }
+[ -f "$T/d8/aeon.yml" ] && pass "--dir cloned the instance" || bad "--dir clone missing"
+grep -q "child-init init --yes --no-telegram --no-dashboard" "$T/child-args" 2>/dev/null && pass "handed over to the clone's ./aeon init" || bad "no hand-over (args: $(cat "$T/child-args" 2>/dev/null))"
+mkdir -p "$T/d9" && echo keep > "$T/d9/file"
+init "$W" --yes --name aeon8 --dir "$T/d9"; rc=$?
+[ "$rc" != 0 ] && [ -f "$T/d9/file" ] && pass "--dir refuses a non-empty folder and leaves it alone" || bad "--dir on a non-empty folder: rc=$rc"
+
+[ "$fail" = 0 ] && echo "PASS" || echo "SOME TESTS FAILED"
+exit $fail
diff --git a/apps/dashboard/app/api/github-auth/route.ts b/apps/dashboard/app/api/github-auth/route.ts
index 155beae1558..3543f1eca6e 100644
--- a/apps/dashboard/app/api/github-auth/route.ts
+++ b/apps/dashboard/app/api/github-auth/route.ts
@@ -1,11 +1,12 @@
import { NextResponse } from 'next/server'
import { requireGh, errorResponse } from '@/lib/http'
-import { captureGithubToken } from '@/lib/github-auth'
+import { captureGithubToken, GhGlobalScopeError } from '@/lib/github-auth'
// POST /api/github-auth - copy this machine's `gh auth token` into the
// GH_GLOBAL repo secret. Parallel to POST /api/auth (Claude) and
// POST /api/grok-auth, minus a browser flow: gh is already authenticated
-// or the dashboard would have 503'd, and gho_ tokens do not expire.
+// or the dashboard would have 503'd. A token without repo + workflow (or a
+// fine-grained one that cannot be checked) is refused with a 400.
export async function POST() {
try {
const notReady = requireGh()
@@ -13,7 +14,7 @@ export async function POST() {
return NextResponse.json(captureGithubToken())
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : ''
- if (msg.includes('Could not read') || msg.includes('not authenticated')) {
+ if (error instanceof GhGlobalScopeError || msg.includes('Could not read') || msg.includes('not authenticated')) {
return NextResponse.json({ error: msg }, { status: 400 })
}
return errorResponse(error, 'Failed to connect GitHub')
diff --git a/apps/dashboard/lib/github-auth.test.ts b/apps/dashboard/lib/github-auth.test.ts
index 7091ab946f7..12f2c6fd61e 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 { assertGhGlobalScopes, missingScopes, parseGhAuthToken, parseOAuthScopes } from './github-auth'
+import { GhGlobalScopeError, assertGhGlobalScopes, missingScopes, parseGhAuthToken, parseOAuthScopes } from './github-auth'
describe('parseGhAuthToken', () => {
it('accepts GitHub CLI OAuth tokens', () => {
@@ -44,7 +44,8 @@ describe('GH_GLOBAL scope check', () => {
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('gho_x', ['repo']), /missing the workflow scope.*classic PAT with repo \+ workflow.*gh auth refresh -h github.com -s repo,workflow/)
+ assert.throws(() => assertGhGlobalScopes('ghp_x', ['gist']), GhGlobalScopeError)
assert.throws(() => assertGhGlobalScopes('ghp_x', ['gist']), /repo \+ workflow scopes/)
})
diff --git a/apps/dashboard/lib/github-auth.ts b/apps/dashboard/lib/github-auth.ts
index 4a44548cb85..8931b299a63 100644
--- a/apps/dashboard/lib/github-auth.ts
+++ b/apps/dashboard/lib/github-auth.ts
@@ -51,25 +51,33 @@ export function ghTokenScopes(): string[] | null {
}
}
+// A token that is not fit to be GH_GLOBAL: a caller mistake (HTTP 400), not
+// a server failure.
+export class GhGlobalScopeError extends Error {}
+
+const SET_CLASSIC = 'Set GH_GLOBAL to a classic PAT with repo + workflow (https://github.com/settings/tokens, Tokens (classic))'
+
// 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.')
+ throw new GhGlobalScopeError(token.startsWith('github_pat_')
+ ? `This is a fine-grained token, whose permissions cannot be checked. ${SET_CLASSIC}.`
+ : `Could not read this token's scopes. ${SET_CLASSIC}, or run \`gh auth refresh -h github.com -s ${GH_GLOBAL_SCOPES.join(',')}\` and connect again.`)
}
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.`)
+ throw new GhGlobalScopeError(`The gh token is missing the ${missing.join(' + ')} scope${missing.length > 1 ? 's' : ''} GH_GLOBAL needs. ${SET_CLASSIC}, or run \`gh auth refresh -h github.com -s ${GH_GLOBAL_SCOPES.join(',')}\` and 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,
// `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.
+// already required `gh auth login` to start. GitHub revokes a gho_ token after
+// a year unused or once more than 10 exist for the same app and scopes, so the
+// docs recommend a dedicated classic PAT for long-lived instances. Refuses a
+// token without the repo + workflow scopes (GhGlobalScopeError).
export function captureGithubToken(): { ok: true; method: 'oauth'; secret: string } {
let raw: string
try {
diff --git a/apps/dashboard/lib/github-push.fixture.ts b/apps/dashboard/lib/github-push.fixture.ts
new file mode 100644
index 00000000000..001d9392b64
--- /dev/null
+++ b/apps/dashboard/lib/github-push.fixture.ts
@@ -0,0 +1,11 @@
+// Side-effect module for github-push.test.ts: lib/gh reads AEON_REPO_ROOT when
+// it loads, so this must be imported (and evaluated) BEFORE ./github.
+import { mkdtempSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+
+export const base = mkdtempSync(join(tmpdir(), 'aeon-push-'))
+export const work = join(base, 'work')
+process.env.AEON_REPO_ROOT = work
+delete process.env.GITHUB_TOKEN
+delete process.env.GITHUB_REPO
diff --git a/apps/dashboard/lib/github-push.test.ts b/apps/dashboard/lib/github-push.test.ts
new file mode 100644
index 00000000000..ce9c564a422
--- /dev/null
+++ b/apps/dashboard/lib/github-push.test.ts
@@ -0,0 +1,69 @@
+/**
+ * commitAndPush must push to `origin` by name (never to whatever the branch
+ * tracks) and must refuse when origin is the Aeon template. Exercised against
+ * real local git repos: a bare "instance" (origin), a bare "template"
+ * (upstream) and a working clone whose main still tracks upstream - the state
+ * an interrupted template switch-over can leave.
+ */
+import { describe, it, before, after } from 'node:test'
+import { strict as assert } from 'node:assert'
+import { execFileSync } from 'node:child_process'
+import { rmSync, writeFileSync } from 'node:fs'
+import { join } from 'node:path'
+// Must stay above ./github: it pins AEON_REPO_ROOT before lib/gh loads.
+import { base, work } from './github-push.fixture'
+import { commitAndPush, isTemplateRemote } from './github'
+
+const git = (cwd: string, ...args: string[]) =>
+ execFileSync('git', args, { cwd, stdio: 'pipe', env: { ...process.env, GIT_CONFIG_NOSYSTEM: '1' } }).toString().trim()
+
+before(() => {
+ for (const r of ['instance.git', 'template.git']) git(base, 'init', '-q', '--bare', '-b', 'main', r)
+ git(base, 'init', '-q', '-b', 'main', 'work')
+ git(work, 'config', 'user.email', 'test@example.com')
+ git(work, 'config', 'user.name', 'test')
+ git(work, 'config', 'commit.gpgsign', 'false')
+ writeFileSync(join(work, 'aeon.yml'), 'model: a\n')
+ git(work, 'add', 'aeon.yml')
+ git(work, 'commit', '-q', '-m', 'init')
+ git(work, 'remote', 'add', 'origin', join(base, 'instance.git'))
+ git(work, 'remote', 'add', 'upstream', join(base, 'template.git'))
+ git(work, 'push', '-q', 'origin', 'main')
+ git(work, 'push', '-q', 'upstream', 'main')
+ // main tracks the TEMPLATE remote, as after an interrupted switch-over.
+ git(work, 'branch', '--set-upstream-to=upstream/main', 'main')
+})
+
+after(() => rmSync(base, { recursive: true, force: true }))
+
+describe('isTemplateRemote', () => {
+ it('matches the template and its redirect, in any URL form', () => {
+ assert.equal(isTemplateRemote('https://github.com/aeonfun/aeon.git'), true)
+ assert.equal(isTemplateRemote('git@github.com:aeonfun/aeon.git'), true)
+ assert.equal(isTemplateRemote('https://github.com/AaronJMars/aeon'), true)
+ assert.equal(isTemplateRemote('https://github.com/someone/aeon.git'), false)
+ assert.equal(isTemplateRemote('https://github.com/aeonfun/aeon-website.git'), false)
+ })
+})
+
+describe('commitAndPush', () => {
+ it('pushes to origin even when the branch tracks another remote', () => {
+ writeFileSync(join(work, 'aeon.yml'), 'model: b\n')
+ const res = commitAndPush(['aeon.yml'], 'chore: b')
+ assert.deepEqual(res, { synced: true })
+ const head = git(work, 'rev-parse', 'HEAD')
+ assert.equal(git(base, '--git-dir=instance.git', 'rev-parse', 'main'), head)
+ assert.notEqual(git(base, '--git-dir=template.git', 'rev-parse', 'main'), head)
+ })
+
+ it('refuses to commit or push when origin is the template', () => {
+ git(work, 'remote', 'set-url', 'origin', 'https://github.com/aeonfun/aeon.git')
+ const before = git(work, 'rev-parse', 'HEAD')
+ writeFileSync(join(work, 'aeon.yml'), 'model: c\n')
+ const res = commitAndPush(['aeon.yml'], 'chore: c')
+ assert.equal(res.synced, false)
+ assert.match(res.reason ?? '', /template/)
+ assert.equal(git(work, 'rev-parse', 'HEAD'), before)
+ git(work, 'remote', 'set-url', 'origin', join(base, 'instance.git'))
+ })
+})
diff --git a/apps/dashboard/lib/github.ts b/apps/dashboard/lib/github.ts
index 75111e4f188..9a8841de274 100644
--- a/apps/dashboard/lib/github.ts
+++ b/apps/dashboard/lib/github.ts
@@ -38,6 +38,12 @@ export function withFileLock(path: string, fn: () => Promise): Promise
return run
}
+// Is this git remote URL the shared Aeon template (aeonfun/aeon, or the
+// aaronjmars/aeon redirect) rather than an operator's instance?
+export function isTemplateRemote(url: string): boolean {
+ return /github\.com[:/]+(aeonfun|aaronjmars)\/aeon(\.git)?\/?$/i.test(url.trim())
+}
+
/**
* Local-mode auto-sync. After a dashboard edit writes a file to disk, stage
* exactly those paths, commit, and push so the change lands on GitHub
@@ -54,19 +60,30 @@ export function commitAndPush(paths: string[], message: string): CommitResult {
execFileSync('git', args, { stdio: 'pipe', cwd: REPO_ROOT }).toString().trim()
const errMsg = (e: unknown) => (e instanceof Error ? e.message : String(e)).slice(0, 200)
try {
+ // Push to `origin` by name, never to whatever the branch happens to track:
+ // a half-converted clone of the template can still track aeonfun/aeon. And
+ // never push config to the template itself (the operator's edit stays on
+ // disk, reported as not synced).
+ let origin = ''
+ try { origin = git('remote', 'get-url', 'origin') } catch { /* no origin */ }
+ if (!origin) return { synced: false, reason: 'no origin remote - run ./aeon init' }
+ if (isTemplateRemote(origin)) {
+ return { synced: false, reason: `origin is the Aeon template (${origin}), not your instance - run ./aeon init` }
+ }
git('add', '--', ...paths) // stages content changes AND deletions under these paths
let staged = true
try { git('diff', '--cached', '--quiet', '--', ...paths); staged = false } catch { staged = true }
if (!staged) return { synced: true } // nothing changed in these paths
git('commit', '-m', message, '--', ...paths)
+ const branch = git('rev-parse', '--abbrev-ref', 'HEAD')
try {
- git('push')
+ git('push', 'origin', `HEAD:${branch}`)
} catch {
// Most likely behind origin/main (e.g. an Actions bot commit). Rebase onto
// the remote and retry once; abort cleanly if it conflicts.
try {
- git('pull', '--rebase', '--autostash')
- git('push')
+ git('pull', '--rebase', '--autostash', 'origin', branch)
+ git('push', 'origin', `HEAD:${branch}`)
} catch (e) {
try { git('rebase', '--abort') } catch { /* not mid-rebase */ }
return { synced: false, reason: errMsg(e) }
diff --git a/bin/onboard b/bin/onboard
index 5419da4db5f..9f00c533ad7 100755
--- a/bin/onboard
+++ b/bin/onboard
@@ -9,9 +9,11 @@ set -euo pipefail
#
# Usage:
# bin/onboard Run every check, print summary, exit 0/1
-# 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 --remote After the checks, dispatch one heartbeat run (it
+# costs one Actions run and model call). A green run
+# proves Actions and the model credential work. It
+# commits docs/status.md and only notifies when it
+# finds something, so a quiet channel is normal.
# 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
@@ -221,10 +223,13 @@ 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
+ # resolve-harness.sh runs an unknown harness name as claude, so check claude.
+ if ! jq -e --arg h "$HARNESS" '.harnesses[] | select(.id == $h)' "$MANIFEST" >/dev/null 2>&1; then
+ emit warn "harness" "aeon.yml harness '$HARNESS' is not a known harness, so runs fall back to claude" \
+ "Set harness: to one of $(jq -r '[.harnesses[].id] | join(", ")' "$MANIFEST") (./aeon config set harness )."
+ HARNESS=claude
+ fi
# "\t