From c9df2ee8ac9a0d0bc8b8c2a84b82bdf3f9408350 Mon Sep 17 00:00:00 2001 From: Peter Abbondanzo Date: Mon, 10 Aug 2026 22:10:32 -0400 Subject: [PATCH 1/3] Add Muse Spark support --- README.md | 30 ++++++++-- ...odel-runtimes-and-credentials.explainer.md | 18 ++++++ llp/0007-cli-commands-and-ci.explainer.md | 1 + ...option-templates-and-ci-workflows.guide.md | 9 +++ src/__tests__/auth.test.ts | 19 +++++++ src/__tests__/opencode.test.ts | 53 ++++++++++++++++++ src/__tests__/research.test.ts | 3 + src/__tests__/setup-auth.test.ts | 8 +++ src/commands/setup-auth.ts | 7 ++- src/config/load.ts | 3 +- src/core/auth.ts | 1 + src/core/opencode.ts | 55 +++++++++++++++++++ src/research-mcp/wrapper.ts | 4 +- templates/atlantis.yml | 2 + templates/command.yml | 2 + templates/config.jsonc | 7 +++ templates/workflow.yml | 2 + 17 files changed, 217 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index a7f66d8..cb47bd5 100644 --- a/README.md +++ b/README.md @@ -120,15 +120,16 @@ npx @expo/code-review-cli doctor The scaffolded default is **Anthropic via the Claude Code CLI**: locally your `claude` login is enough, and for CI it helps you mint a token with `claude setup-token`. It also handles the alternatives (an OpenAI **API key**, -a **ChatGPT/Codex subscription** sign-in). `doctor` offers to run it whenever a -credential is missing. +a **ChatGPT/Codex subscription** sign-in, or a Meta Model API key for **Muse +Spark**). `doctor` offers to run it whenever a credential is missing. In CI, store the credential as the repo secret the scaffolded workflow forwards (`CLAUDE_CODE_REVIEW_SHARED_API_TOKEN` by default — an `sk-ant-oat…` token from `claude setup-token`, or an `sk-ant-api…` Console key; the CLI reads either). -Prefer **OpenAI** (API key, or a ChatGPT/Codex subscription, or both mixed) or -another provider? See [Other providers & auth modes](#other-providers) below. +Prefer **Muse Spark**, **OpenAI** (API key, or a ChatGPT/Codex subscription, or +both mixed), or another provider? See [Other providers & auth modes](#other-providers) +below. ### Reviewing (already configured) @@ -721,6 +722,27 @@ fallback key; `ecr doctor` diagnoses setup. } } ``` +- **Meta / Muse Spark 1.2 (public Model API).** Set the default model and any + model-pinned agent frontmatter to `meta/muse-spark-1.2`. ECR supplies the fixed + public Responses endpoint and the `@ai-sdk/openai` adapter; the generic + Chat-Completions compatibility adapter is intentionally not used because it + loses Muse's reasoning continuity across tool turns. Standard and Contributor + model ids are supported. + + ```jsonc + "model": "meta/muse-spark-1.2", + "auth": { "providers": { + "meta": { "mode": "api-key", "tokenEnv": "META_API_KEY" } + } } + ``` + + Create the key in the [Meta AI developer portal](https://developer.meta.com/ai/). + Locally, export it as `META_API_KEY`. In each review-running workflow, replace + the Anthropic credential line with + `META_API_KEY: ${{ secrets.META_API_KEY }}`, set the repo variable + `ECR_EXPECTED_TOKEN_ENV=META_API_KEY`, and remove the Claude CLI install step + if no configured agent uses an `anthropic/…` model. Muse runs with high reasoning. + - **OpenAI: ChatGPT/Codex subscription (OAuth) + usage-based API key** — the recommended mix if you review with OpenAI. Default models run on the subscription (zero marginal cost); a metered key covers subscription-excluded diff --git a/llp/0003-model-runtimes-and-credentials.explainer.md b/llp/0003-model-runtimes-and-credentials.explainer.md index fb98d28..6415e65 100644 --- a/llp/0003-model-runtimes-and-credentials.explainer.md +++ b/llp/0003-model-runtimes-and-credentials.explainer.md @@ -96,6 +96,24 @@ POSIX, where `execvp` never searches cwd. This is a knowingly accepted residual, oversight, and must not be silently reintroduced as a "fix" ([observed] `opencode.ts:305-314`). +### Muse Spark via Meta Model API + +Muse Spark uses the existing OpenCode engine, but OpenCode `1.18.4` does not ship a +resolvable `meta` provider. `buildOpencodeConfig` therefore synthesizes one only for an +in-use `meta` API-key entry. Its destination is fixed to `https://api.meta.ai/v1`, its +credential is an environment interpolation (never the key value), and its adapter is +`@ai-sdk/openai` so requests use the Responses API and preserve encrypted reasoning +across tool turns. The generic `@ai-sdk/openai-compatible` adapter is deliberately not +used because its Chat Completions path loses that continuity [observed: +`opencode.ts` `META_MODEL_API_BASE_URL` / `META_MUSE_MODELS` / `buildOpencodeConfig`]. + +The declared model catalog is an allowlist (`muse-spark-1.2` and +`muse-spark-1.2-contributor`), not a reflection of arbitrary config strings. This keeps +the existing preflight honest: a misspelled or not-yet-supported Meta model remains +absent from the server's provider model list and fails once before any pass. Both models +declare their public context/output limits, multimodal inputs, high reasoning, and +encrypted reasoning inclusion [observed: `opencode.ts` `META_MUSE_MODELS`]. + ## Claude Code CLI Containment The Claude Code engine runs each review pass as a single stateless diff --git a/llp/0007-cli-commands-and-ci.explainer.md b/llp/0007-cli-commands-and-ci.explainer.md index f668820..1b25925 100644 --- a/llp/0007-cli-commands-and-ci.explainer.md +++ b/llp/0007-cli-commands-and-ci.explainer.md @@ -137,6 +137,7 @@ doctor resolves auth the way real reviews do: a `routing.jsonc` `defaults.auth` - It hands out only ACCESS tokens; the refresh token never leaves OpenCode's store, because OpenCode's refresh tokens are SINGLE-USE (rotation) and OpenCode is their sole legitimate consumer — a copy in a shell config or CI secret dies on the next rotation and can take the whole sign-in down with it [observed: `src/commands/setup-auth.ts:87-93`]. - The exported shell line single-quotes the token value so a token containing shell metacharacters (`$HOME`, `!`) is never expanded when sourced [observed: `src/commands/setup-auth.ts:127-131`; test in `src/__tests__/setup-auth.test.ts`]. - `anthropic` auth is always satisfied by the Claude Code CLI subscription-login flow regardless of the declared `mode` (oauth vs api-key) — mode is irrelevant for that provider [observed: `src/commands/setup-auth.ts:50-59`]. +- A `meta` API-key entry is a manual-key plan: setup-auth points to the Meta AI developer portal and prints the configured `META_API_KEY` export, while doctor uses the same generic provider readiness check as the real review [observed: `src/commands/setup-auth.ts` `planFromAuth` / manual-key guidance; `src/core/auth.ts` `PROVIDER_KEY_ENV`]. - The two interactive logins run via `spawnSync` with `stdio: "inherit"` and `cwd: os.tmpdir()` — the tmpdir cwd keeps a possibly-untrusted checkout from becoming the login subprocess's working directory, and the `inherit` stdio is why these two calls cannot be converted to `exec.ts`'s capturing `run()` [observed: `src/commands/setup-auth.ts:200-203,256-259`]. ## init and dismiss diff --git a/llp/0009-adoption-templates-and-ci-workflows.guide.md b/llp/0009-adoption-templates-and-ci-workflows.guide.md index 35a0668..cfdb308 100644 --- a/llp/0009-adoption-templates-and-ci-workflows.guide.md +++ b/llp/0009-adoption-templates-and-ci-workflows.guide.md @@ -55,6 +55,15 @@ The three workflows share a posture built entirely around one fact: a reviewer j **Concurrency defaults by auth mode.** The commented `chunk.concurrency` default is 6 for an API key and 3 for a subscription (OAuth) credential, "one account handles many parallel streams poorly, and several PRs may review on the same credential at once" [observed: `templates/config.jsonc:28-30`]. +**Muse Spark is opt-in without weakening the default secret surface.** The root config +documents `meta/muse-spark-1.2` plus a `META_API_KEY` auth entry, and each +review-running workflow shows the corresponding secret line commented out. It is not +forwarded by the default scaffold: adopters replace the Anthropic line and set +`ECR_EXPECTED_TOKEN_ENV=META_API_KEY`, so the model process receives only the +credential actually selected by trusted config. The default remains Anthropic and +`dismiss.yml` still receives no model credential [observed: `templates/config.jsonc`; +`templates/workflow.yml`, `command.yml`, and `atlantis.yml`]. + **Keep the `**/*` catch-all first in `routing.jsonc`.** Scopes match last-match-wins, so a `**/*` catch-all must come first and more specific scopes come after to override it [observed: `templates/routing.jsonc:27-31`; `AGENTS.md:17-18`]. Nothing validates the order — reversing it silently changes which config applies to already-routed files [inferred: no code checks that the `**/*` catch-all comes first, so reversing it silently changes routing]. **`scope-config.jsonc` deliberately omits `auth` and `commentTag`.** It is a *different* template from the root `config.jsonc`, not a copy. A scope config carries no `auth` block — credentials are root-only, and the loader plus the CI guard reject a scope config that declares one [observed: `templates/scope-config.jsonc:1-3`; `AGENTS.md:51`]. It also omits `commentTag`, because a scope's PR-comment marker is always derived as `:` so `ecr ci` and `ecr review --scope --post` target the same comment; declaring one is rejected by the scope schema [observed: `templates/scope-config.jsonc:23-25`]. Reusing the root `config.jsonc` template for a scope — a tempting "simplification" — would break the trust boundary (LLP 0006). diff --git a/src/__tests__/auth.test.ts b/src/__tests__/auth.test.ts index 9dfc979..8591182 100644 --- a/src/__tests__/auth.test.ts +++ b/src/__tests__/auth.test.ts @@ -270,6 +270,25 @@ test("checkProviderAuth: an entry for an IN-USE provider is still enforced", () expect(r.detail).toContain("openai"); }); +test("checkProviderAuth: Meta uses META_API_KEY and rejects cross-provider reuse", () => { + const meta = cfgWithModels(["meta/muse-spark-1.2"], { + mode: "api-key", + provider: "meta", + tokenEnv: "META_API_KEY", + }); + expect(checkProviderAuth(meta, {}).detail).toContain("META_API_KEY"); + expect(checkProviderAuth(meta, { META_API_KEY: "meta-review-key" }).ok).toBe(true); + + const wrongOwner = cfgWithModels(["openai/gpt-5.5"], { + mode: "api-key", + provider: "openai", + tokenEnv: "META_API_KEY", + }); + const result = checkProviderAuth(wrongOwner, { META_API_KEY: "meta-review-key" }); + expect(result.ok).toBe(false); + expect(result.detail).toContain("meta's well-known credential env"); +}); + test("prepareAuth: does not forward a dead unused-provider api-key into its key env", async () => { // An entry for a provider no model uses must not have its tokenEnv copied into the // provider key env — checkProviderAuth skips its guard, so forwarding would diff --git a/src/__tests__/opencode.test.ts b/src/__tests__/opencode.test.ts index 1c05a79..88d1a89 100644 --- a/src/__tests__/opencode.test.ts +++ b/src/__tests__/opencode.test.ts @@ -13,6 +13,7 @@ import { resolveEngineDispatch, resolveOpencodeCli, STACK_VERIFIER_AGENT, + META_MODEL_API_BASE_URL, } from "../core/opencode.js"; import type { OpencodeHandle } from "../core/opencode.js"; import type { ClaudeCodeHandle } from "../core/claude-code.js"; @@ -341,6 +342,58 @@ test("an upstream-alias auth entry synthesizes a provider block with exactly the expect(opencode.provider?.openai).toBeUndefined(); }); +test("Meta auth synthesizes the public Muse Responses provider without embedding its key", () => { + const config = configWith({ + agents: [{ id: "correctness", model: "meta/muse-spark-1.2" }], + coordinatorModel: "meta/muse-spark-1.2", + auth: [{ mode: "api-key", provider: "meta", tokenEnv: "META_API_KEY" }], + }); + const opencode = buildOpencodeConfig(config) as { + provider?: Record< + string, + { + npm: string; + options: { baseURL: string; apiKey: string }; + models: Record }>; + } + >; + }; + const meta = opencode.provider?.meta; + expect(meta).toBeDefined(); + expect(meta!.npm).toBe("@ai-sdk/openai"); + expect(meta!.options).toEqual({ + baseURL: META_MODEL_API_BASE_URL, + apiKey: "{env:META_API_KEY}", + }); + expect(Object.keys(meta!.models)).toEqual(["muse-spark-1.2"]); + expect(meta!.models["muse-spark-1.2"]?.reasoning).toBe(true); + expect(meta!.models["muse-spark-1.2"]?.options).toMatchObject({ + reasoningEffort: "high", + include: ["reasoning.encrypted_content"], + }); +}); + +test("Meta provider does not register an unknown Muse id, so preflight can reject it", () => { + const config = configWith({ + agents: [{ id: "correctness", model: "meta/muse-spark-typo" }], + coordinatorModel: "meta/muse-spark-typo", + auth: [{ mode: "api-key", provider: "meta", tokenEnv: "META_API_KEY" }], + }); + const opencode = buildOpencodeConfig(config) as { + provider?: Record }>; + }; + expect(opencode.provider?.meta?.models).toEqual({}); + expect( + findUnknownModels(["meta/muse-spark-typo"], { meta: ["muse-spark-1.2"] }, ["meta"]), + ).toEqual([ + { + model: "meta/muse-spark-typo", + reason: "model", + suggestions: ["muse-spark-1.2"], + }, + ]); +}); + test("buildOpencodeConfig registers the no-tools stack verifier (empty tool list)", () => { const opencode = buildOpencodeConfig(configWith({})) as { agent: Record }>; diff --git a/src/__tests__/research.test.ts b/src/__tests__/research.test.ts index 911e37c..4c219cc 100644 --- a/src/__tests__/research.test.ts +++ b/src/__tests__/research.test.ts @@ -52,6 +52,7 @@ test("the wrapper hands the MCP server a constructed environment, not an inherit PATH: process.env.PATH ?? "", HOME: "/root", ANTHROPIC_API_KEY: "must-not-reach-the-server", + META_API_KEY: "must-not-reach-the-server", CLAUDE_CODE_OAUTH_TOKEN: "must-not-reach-the-server", AWS_SECRET_ACCESS_KEY: "must-not-reach-the-server", GITHUB_TOKEN: "must-not-reach-the-server", @@ -93,6 +94,7 @@ test("research child environment forwards only locale, proxy variables, and the no_proxy: "localhost", BRAVE_SEARCH_API_KEY: "search-only", SECRET_TOKEN: "must-not-be-forwarded", + META_API_KEY: "must-not-be-forwarded", PATH: "/private/bin", }), ).toMatchObject({ @@ -103,6 +105,7 @@ test("research child environment forwards only locale, proxy variables, and the BRAVE_SEARCH_API_KEY: "search-only", }); expect(researchChildEnvironment({ SECRET_TOKEN: "nope" })).not.toHaveProperty("SECRET_TOKEN"); + expect(researchChildEnvironment({ META_API_KEY: "nope" })).not.toHaveProperty("META_API_KEY"); expect(researchChildEnvironment({ PATH: "/private/bin" })).not.toHaveProperty("PATH"); }); diff --git a/src/__tests__/setup-auth.test.ts b/src/__tests__/setup-auth.test.ts index a8ace7a..f25ebdc 100644 --- a/src/__tests__/setup-auth.test.ts +++ b/src/__tests__/setup-auth.test.ts @@ -14,6 +14,14 @@ test("plan: the mixed setup needs one ChatGPT login and one manual key", () => { expect(plan.unsupported).toEqual([]); }); +test("plan: Meta Model API is a manual API key setup", () => { + const plan = planFromAuth([{ provider: "meta", mode: "api-key", tokenEnv: "META_API_KEY" }]); + expect(plan.manualKeys).toEqual([ + { provider: "meta", tokenEnv: "META_API_KEY", upstream: undefined }, + ]); + expect(plan.unsupported).toEqual([]); +}); + test("plan: an anthropic entry (any mode) → claudeLogin; api-key without tokenEnv needs nothing", () => { const plan = planFromAuth([ // Mode is irrelevant for anthropic — it is always served by the Claude Code CLI. diff --git a/src/commands/setup-auth.ts b/src/commands/setup-auth.ts index c6d8462..94371c5 100644 --- a/src/commands/setup-auth.ts +++ b/src/commands/setup-auth.ts @@ -25,7 +25,8 @@ getting each credential: opens your browser) and prints the \`export =…\` line for CI/headless runs. • an API key (api-key entries): prints where to create the key, the exact - permissions it needs, and the export line to fill in. + permissions it needs, and the export line to fill in. Meta Model API + (\`meta/muse-spark-…\`) uses META_API_KEY from the Meta AI developer portal. Without a repo config, it offers the recommended ChatGPT/Codex subscription flow with the default env name. @@ -300,6 +301,10 @@ export async function setupAuthCommand(argv: string[] = []): Promise { } else if (upstream === "anthropic") { err(" https://console.anthropic.com/settings/keys — a workspace-scoped key"); err(" with a spend limit is all the reviewer needs."); + } else if (upstream === "meta") { + // @ref LLP 0007#doctor-and-setup-auth [implements] — Meta uses a manual API key + err(" https://developer.meta.com/ai/ — create a Model API key with access to"); + err(" Muse Spark, then store it as a dedicated review secret."); } else { err(` mint a key for the "${upstream}" provider.`); } diff --git a/src/config/load.ts b/src/config/load.ts index f53b8b3..2d378bd 100644 --- a/src/config/load.ts +++ b/src/config/load.ts @@ -177,11 +177,12 @@ async function loadConfigDir( for (const file of agentFiles) { const md = parseFrontmatter(await readFile(path.join(agentsDir, file), "utf8")); const id = file.replace(/\.md$/, ""); + const model = resolveModel(md.data.model); agents.push({ id, description: md.data.description ?? "", alwaysRun: /^(true|yes|1)$/i.test(md.data.alwaysRun ?? ""), - model: resolveModel(md.data.model), + model, temperature: resolveTemp(md.data.temperature, 0.1), tools: DEFAULT_AGENT_TOOLS, promptText: md.body, diff --git a/src/core/auth.ts b/src/core/auth.ts index f74b673..b067fc9 100644 --- a/src/core/auth.ts +++ b/src/core/auth.ts @@ -15,6 +15,7 @@ const PROVIDER_KEY_ENV: Record = { openai: "OPENAI_API_KEY", google: "GOOGLE_GENERATIVE_AI_API_KEY", openrouter: "OPENROUTER_API_KEY", + meta: "META_API_KEY", }; /** diff --git a/src/core/opencode.ts b/src/core/opencode.ts index a516f63..046f73d 100644 --- a/src/core/opencode.ts +++ b/src/core/opencode.ts @@ -19,6 +19,44 @@ import type { ResearchMcpRuntime } from "./research.js"; /** Discriminant for the Claude Code CLI engine (see core/claude-code.ts). */ export const CLAUDE_CODE_ENGINE = "claude-code" as const; +/** Fixed so repo config cannot redirect the Meta credential. */ +// @ref LLP 0003#muse-spark-via-meta-model-api [implements] — fixed endpoint and model allowlist +export const META_MODEL_API_BASE_URL = "https://api.meta.ai/v1"; + +/** Supported public Muse models. Unknown ids remain visible to preflight. */ +export const META_MUSE_MODELS: Record> = { + "muse-spark-1.2": { + name: "Muse Spark 1.2", + reasoning: true, + limit: { context: 1_048_576, output: 131_072 }, + modalities: { + input: ["text", "image", "pdf", "video"], + output: ["text"], + }, + options: { + store: false, + reasoningEffort: "high", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }, + }, + "muse-spark-1.2-contributor": { + name: "Muse Spark 1.2 Contributor", + reasoning: true, + limit: { context: 1_048_576, output: 131_072 }, + modalities: { + input: ["text", "image", "pdf", "video"], + output: ["text"], + }, + options: { + store: false, + reasoningEffort: "high", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }, + }, +}; + /** * Resolve which engine an agent's pass dispatches to, and (when claude) which * claude handle to run it against. The per-agent router (engineOf) wins; absent it @@ -221,6 +259,23 @@ export function buildOpencodeConfig( config.coordinator.model, ]; for (const entry of config.auth) { + if (entry.provider === "meta" && entry.mode === "api-key" && entry.tokenEnv) { + // Muse needs the Responses adapter; openai-compatible uses Chat Completions. + provider.meta = { + npm: "@ai-sdk/openai", + name: "Meta Model API", + options: { + baseURL: META_MODEL_API_BASE_URL, + apiKey: `{env:${entry.tokenEnv}}`, + }, + models: Object.fromEntries( + Object.entries(META_MUSE_MODELS).filter(([model]) => + referencedModels.includes(`meta/${model}`), + ), + ), + }; + continue; + } if (!entry.upstream || !entry.tokenEnv) { continue; } diff --git a/src/research-mcp/wrapper.ts b/src/research-mcp/wrapper.ts index 5c6e33a..f707316 100644 --- a/src/research-mcp/wrapper.ts +++ b/src/research-mcp/wrapper.ts @@ -21,7 +21,7 @@ */ import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; -import { constants } from "node:os"; +import { constants, tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import { researchWrapperEnvironment } from "./child-env.js"; @@ -36,6 +36,8 @@ const child = spawn( [existsSync(builtEntry) ? builtEntry : sourceEntry, ...process.argv.slice(2)], { env: researchWrapperEnvironment(process.env), + // Prevent runtimes such as Bun from reloading the reviewed repo's .env. + cwd: tmpdir(), // The child owns the engine's stdio directly, so the wrapper never sits in // the MCP byte stream and cannot truncate, buffer, or reorder a message. stdio: "inherit", diff --git a/templates/atlantis.yml b/templates/atlantis.yml index 74cfd1d..377a5e3 100644 --- a/templates/atlantis.yml +++ b/templates/atlantis.yml @@ -110,6 +110,8 @@ jobs: # Anthropic review credential — the env var named by auth.tokenEnv in # config.jsonc (see workflow.yml for the accepted token shapes). CLAUDE_CODE_REVIEW_SHARED_API_TOKEN: ${{ secrets.CLAUDE_CODE_REVIEW_SHARED_API_TOKEN }} + # Muse alternative (also set ECR_EXPECTED_TOKEN_ENV=META_API_KEY): + # META_API_KEY: ${{ secrets.META_API_KEY }} # Optional search-only credential for trusted platform documentation research. BRAVE_SEARCH_API_KEY: ${{ secrets.BRAVE_SEARCH_API_KEY }} # Optional: override the model for every agent. diff --git a/templates/command.yml b/templates/command.yml index 2867910..b5020f2 100644 --- a/templates/command.yml +++ b/templates/command.yml @@ -148,6 +148,8 @@ jobs: # by `claude setup-token`, or an `sk-ant-api…` Console key (the Claude # Code CLI reads either). CLAUDE_CODE_REVIEW_SHARED_API_TOKEN: ${{ secrets.CLAUDE_CODE_REVIEW_SHARED_API_TOKEN }} + # Muse alternative (also set ECR_EXPECTED_TOKEN_ENV=META_API_KEY): + # META_API_KEY: ${{ secrets.META_API_KEY }} # Optional search-only credential for trusted platform documentation research. BRAVE_SEARCH_API_KEY: ${{ secrets.BRAVE_SEARCH_API_KEY }} # Optional: override the model for every agent. diff --git a/templates/config.jsonc b/templates/config.jsonc index f5cc272..4f0943e 100644 --- a/templates/config.jsonc +++ b/templates/config.jsonc @@ -86,6 +86,13 @@ // store the key as a repo secret and pass it under that env var. // "auth": { "mode": "api-key", "provider": "openai", "tokenEnv": "OPENAI_API_KEY" } // + // Muse Spark via the public Meta Model API Responses endpoint: + // set models to `meta/muse-spark-1.2` and configure: + // "auth": { "providers": { + // "meta": { "mode": "api-key", "tokenEnv": "META_API_KEY" } + // } } + // Map META_API_KEY in the workflows and set ECR_EXPECTED_TOKEN_ENV to match. + // // ChatGPT/Codex subscription ("oauth"): tokenEnv holds the ACCESS token from // an `opencode auth login` ChatGPT sign-in — `ecr setup-auth` extracts it. // NEVER share the refresh token: it is single-use and dies on first rotation. diff --git a/templates/workflow.yml b/templates/workflow.yml index de0cbb9..783dae5 100644 --- a/templates/workflow.yml +++ b/templates/workflow.yml @@ -114,6 +114,8 @@ jobs: # by `claude setup-token`, or an `sk-ant-api…` Console key (the Claude # Code CLI reads either). CLAUDE_CODE_REVIEW_SHARED_API_TOKEN: ${{ secrets.CLAUDE_CODE_REVIEW_SHARED_API_TOKEN }} + # Muse alternative (also set ECR_EXPECTED_TOKEN_ENV=META_API_KEY): + # META_API_KEY: ${{ secrets.META_API_KEY }} # Optional search-only credential for trusted platform documentation research. BRAVE_SEARCH_API_KEY: ${{ secrets.BRAVE_SEARCH_API_KEY }} # Optional: override the model for every agent. From 776b49795c281f648bbda2b92a5e6333aeefa5e6 Mon Sep 17 00:00:00 2001 From: Peter Abbondanzo Date: Tue, 11 Aug 2026 18:19:43 -0400 Subject: [PATCH 2/3] Guard Meta provider aliases --- ...odel-runtimes-and-credentials.explainer.md | 12 +++++---- src/__tests__/opencode.test.ts | 27 +++++++++++++++++++ src/core/opencode.ts | 7 ++++- 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/llp/0003-model-runtimes-and-credentials.explainer.md b/llp/0003-model-runtimes-and-credentials.explainer.md index 6415e65..2082c3e 100644 --- a/llp/0003-model-runtimes-and-credentials.explainer.md +++ b/llp/0003-model-runtimes-and-credentials.explainer.md @@ -100,11 +100,13 @@ oversight, and must not be silently reintroduced as a "fix" ([observed] Muse Spark uses the existing OpenCode engine, but OpenCode `1.18.4` does not ship a resolvable `meta` provider. `buildOpencodeConfig` therefore synthesizes one only for an -in-use `meta` API-key entry. Its destination is fixed to `https://api.meta.ai/v1`, its -credential is an environment interpolation (never the key value), and its adapter is -`@ai-sdk/openai` so requests use the Responses API and preserve encrypted reasoning -across tool turns. The generic `@ai-sdk/openai-compatible` adapter is deliberately not -used because its Chat Completions path loses that continuity [observed: +in-use `meta` API-key entry without an `upstream`; an entry with `upstream` remains an +alias and never redirects that upstream's credential to Meta. The native provider's +destination is fixed to `https://api.meta.ai/v1`, its credential is an environment +interpolation (never the key value), and its adapter is `@ai-sdk/openai` so requests use +the Responses API and preserve encrypted reasoning across tool turns. The generic +`@ai-sdk/openai-compatible` adapter is deliberately not used because its Chat +Completions path loses that continuity [observed: `opencode.ts` `META_MODEL_API_BASE_URL` / `META_MUSE_MODELS` / `buildOpencodeConfig`]. The declared model catalog is an allowlist (`muse-spark-1.2` and diff --git a/src/__tests__/opencode.test.ts b/src/__tests__/opencode.test.ts index 88d1a89..114c622 100644 --- a/src/__tests__/opencode.test.ts +++ b/src/__tests__/opencode.test.ts @@ -373,6 +373,33 @@ test("Meta auth synthesizes the public Muse Responses provider without embedding }); }); +test("a Meta-named upstream alias does not redirect the upstream credential to Meta", () => { + const config = configWith({ + agents: [{ id: "correctness", model: "meta/muse-spark-1.2" }], + coordinatorModel: "meta/muse-spark-1.2", + auth: [ + { + mode: "api-key", + provider: "meta", + tokenEnv: "CLAUDE_CODE_REVIEW_SHARED_API_TOKEN", + upstream: "anthropic", + }, + ], + }); + const opencode = buildOpencodeConfig(config) as { + provider?: Record< + string, + { npm: string; options: { apiKey: string; baseURL?: string }; models: object } + >; + }; + const alias = opencode.provider?.meta; + expect(alias).toBeDefined(); + expect(alias!.npm).toBe("@ai-sdk/anthropic"); + expect(alias!.options).toEqual({ apiKey: "{env:CLAUDE_CODE_REVIEW_SHARED_API_TOKEN}" }); + expect(alias!.options.baseURL).toBeUndefined(); + expect(Object.keys(alias!.models)).toEqual(["muse-spark-1.2"]); +}); + test("Meta provider does not register an unknown Muse id, so preflight can reject it", () => { const config = configWith({ agents: [{ id: "correctness", model: "meta/muse-spark-typo" }], diff --git a/src/core/opencode.ts b/src/core/opencode.ts index 046f73d..089cc70 100644 --- a/src/core/opencode.ts +++ b/src/core/opencode.ts @@ -259,7 +259,12 @@ export function buildOpencodeConfig( config.coordinator.model, ]; for (const entry of config.auth) { - if (entry.provider === "meta" && entry.mode === "api-key" && entry.tokenEnv) { + if ( + entry.provider === "meta" && + entry.mode === "api-key" && + entry.tokenEnv && + !entry.upstream + ) { // Muse needs the Responses adapter; openai-compatible uses Chat Completions. provider.meta = { npm: "@ai-sdk/openai", From 699435e9a0dc38beeb14a7d88f0b28b0f39aac69 Mon Sep 17 00:00:00 2001 From: Peter Abbondanzo Date: Tue, 11 Aug 2026 19:02:06 -0400 Subject: [PATCH 3/3] Remove unrelated Muse refactors --- src/config/load.ts | 3 +-- src/research-mcp/wrapper.ts | 4 +--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/config/load.ts b/src/config/load.ts index 2d378bd..f53b8b3 100644 --- a/src/config/load.ts +++ b/src/config/load.ts @@ -177,12 +177,11 @@ async function loadConfigDir( for (const file of agentFiles) { const md = parseFrontmatter(await readFile(path.join(agentsDir, file), "utf8")); const id = file.replace(/\.md$/, ""); - const model = resolveModel(md.data.model); agents.push({ id, description: md.data.description ?? "", alwaysRun: /^(true|yes|1)$/i.test(md.data.alwaysRun ?? ""), - model, + model: resolveModel(md.data.model), temperature: resolveTemp(md.data.temperature, 0.1), tools: DEFAULT_AGENT_TOOLS, promptText: md.body, diff --git a/src/research-mcp/wrapper.ts b/src/research-mcp/wrapper.ts index f707316..5c6e33a 100644 --- a/src/research-mcp/wrapper.ts +++ b/src/research-mcp/wrapper.ts @@ -21,7 +21,7 @@ */ import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; -import { constants, tmpdir } from "node:os"; +import { constants } from "node:os"; import { fileURLToPath } from "node:url"; import { researchWrapperEnvironment } from "./child-env.js"; @@ -36,8 +36,6 @@ const child = spawn( [existsSync(builtEntry) ? builtEntry : sourceEntry, ...process.argv.slice(2)], { env: researchWrapperEnvironment(process.env), - // Prevent runtimes such as Bun from reloading the reviewed repo's .env. - cwd: tmpdir(), // The child owns the engine's stdio directly, so the wrapper never sits in // the MCP byte stream and cannot truncate, buffer, or reorder a message. stdio: "inherit",