diff --git a/LICENSE b/LICENSE index 6b540023..643dd5d0 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ MIT License Copyright (c) 2026 Lauren Tan +Copyright (c) 2026 Martin Patino (pstack-flex modifications) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/NOTICE.md b/NOTICE.md index 0ec8a23e..60e30343 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -2,6 +2,10 @@ This plugin is a port of upstream MIT-licensed work. All upstream copyright notices and license terms are preserved. The open-pstack history begins from `michael-denyer/pstack-claude` through proven import commit `053ed78732e3b71826933170eafe7f7782dda844`. +## pstack-flex provenance + +This repository, **pstack-flex** (Martin Patino), is a fork of [ericlitman/open-pstack](https://github.com/ericlitman/open-pstack) at v1.4.1 (`de67e6b40511814171e5e4c8ad7af3b79f07c9ee`), which ports [Lauren Tan's pstack](https://github.com/cursor/plugins/tree/main/pstack) (Cursor) to Claude Code and Codex. Provenance chain: pstack-flex <- ericlitman/open-pstack <- cursor/plugins/pstack. All licenses remain MIT; every upstream license and notice file is preserved. The flex gateway additions and their tests are (c) 2026 Martin Patino, MIT, and are inventoried in [UPSTREAM-FLEX.md](UPSTREAM-FLEX.md). + ## Upstream sources | Component | Upstream | Copyright | License | License file | diff --git a/README.md b/README.md index 64901115..d6ef60e8 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,12 @@ Lauren built pstack from the skills she uses to ship code at Cursor. In a [55-mi Open Pstack is an unofficial community project that makes pstack work in Claude Code and Codex. If Cursor is your main coding environment, use [Lauren's original pstack](https://github.com/cursor/plugins/tree/main/pstack). If Claude Code or Codex is your main coding environment, use this repository. +## This fork: pstack-flex + +**pstack-flex** is a fork of [ericlitman/open-pstack](https://github.com/ericlitman/open-pstack) at v1.4.1. This change adds `deepseek` and `minimax` providers to the external runner. Each provider runs the stock `claude` binary against its Anthropic-compatible endpoint with its own API key. The runner uses an isolated `CLAUDE_CONFIG_DIR`, rejects OAuth credentials found there, and removes inherited Anthropic headers and provider-selection flags before starting the child. Gateway receipts keep token usage but set `costUsd` to null because Claude Code's cost estimate uses Anthropic prices. + +The stock setup and model matrix remain in place. A follow-up change will add gateway routes to setup and document the lane configurations. The fork's provenance and sync process are recorded in [UPSTREAM-FLEX.md](UPSTREAM-FLEX.md). Anthropic does not support pointing Claude Code at non-Anthropic endpoints; use synthetic data for gateway testing and keep API keys in your local environment. + ## What pstack does pstack is a plugin for coding agents. It is not a new model or a hosted service. It gives your agent engineering rules, step-by-step workflows for different kinds of work, focused skills, and small local tools. diff --git a/UPSTREAM-FLEX.md b/UPSTREAM-FLEX.md new file mode 100644 index 00000000..52c8e9b6 --- /dev/null +++ b/UPSTREAM-FLEX.md @@ -0,0 +1,46 @@ +# Flex fork synchronization + +pstack-flex layers on top of open-pstack's own upstream tracking. Two sync relationships exist: + +1. `cursor/plugins/pstack` -> `ericlitman/open-pstack` — documented in [UPSTREAM.md](UPSTREAM.md), unchanged by this fork. +2. `ericlitman/open-pstack` -> `thisguymartin/pstack-flex` — this document. + +## Fork point + +| Source | Value | +| --- | --- | +| Repository | `https://github.com/ericlitman/open-pstack.git` | +| Tag | `v1.4.1` | +| Commit | `de67e6b40511814171e5e4c8ad7af3b79f07c9ee` | +| Tracks Cursor pstack | `0.15.1` (`f8abedd`) | + +The fork keeps full upstream history. The `upstream` remote points at ericlitman/open-pstack. + +## What the fork owns + +All flex changes are additive and live in port-owned files so upstream merges stay cheap: + +- `plugins/pstack/skills/poteto-mode/scripts/runner/flex-providers.ts` and `flex-providers.test.ts` (new) +- Gateway-provider hooks in `runner/{types,commands,run,parse-output,cli}.ts` and their tests +- This file, the README fork section, and the NOTICE/LICENSE additions + +The follow-up routing and documentation changes will add the flex model matrix, assignment-first setup, and `docs/LANES.md`. + +The stock model matrix, the first-run sheet, every upstream skill body, and the static quad invariants are byte-unchanged. + +## Merge procedure + +```shell +git fetch upstream +git switch -c merge-rehearsal +git merge --no-ff --no-commit upstream/main +# inspect, resolve, run the full local gate, then merge for real or abort +``` + +Expected conflict surface on future upstream releases: + +- `plugins/pstack/skills/setup-pstack/SKILL.md` — upstream issue #88 (1.5.0, syncing Cursor pstack 0.15.5) folds upstream PR #73, which moves setup to the same assignment-first, probe-only-assigned shape this fork already uses. Resolve toward upstream's wording wherever it covers the same rule; keep the flex families and the diversity rule. +- `plugins/pstack/skills/poteto-mode/scripts/runner/model-matrix.test.ts` — upstream 1.5.0 changes the stock panel to three lanes. Take upstream's stock assertions verbatim; the flex-matrix describe block is fork-owned and should survive as-is. +- `plugins/pstack/skills/poteto-mode/references/provider-dispatch.md` — stock matrix and default-panel prose are upstream's; the flex section is fork-owned. + +After every merge: run the full local gate (`bun install --frozen-lockfile`, `bun run test`, `bun run typecheck`, manifest JSON parse, `PSTACK_STATIC_ONLY=1 bash tests/skill-collision-repro.sh`), then record the installed version, action, and observed result for each affected harness in the pull request before tagging. diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/cli.test.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/cli.test.ts index 05f79b5a..e76b52dd 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/cli.test.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/cli.test.ts @@ -40,4 +40,28 @@ describe("runner CLI parsing", () => { "greater than zero" ); }); + + it("accepts gateway providers", () => { + const parsed = parseArgs([ + ...argv().map((value, index, all) => + all[index - 1] === "--provider" + ? "minimax" + : all[index - 1] === "--model" + ? "MiniMax-M3" + : value + ), + ]); + expect(parsed?.provider).toBe("minimax"); + expect(parsed?.model).toBe("MiniMax-M3"); + }); + + it("names the gateway providers in the provider rejection", () => { + expect(() => + parseArgs( + argv().map((value, index, all) => + all[index - 1] === "--provider" ? "gemini" : value + ) + ) + ).toThrow("deepseek, minimax"); + }); }); diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/cli.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/cli.ts index 4fcce242..eb326f85 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/cli.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/cli.ts @@ -13,7 +13,7 @@ import { UsageError, } from "./types.ts"; -const HELP = `Usage: pstack-runner --parent --provider \\ +const HELP = `Usage: pstack-runner --parent --provider <${PROVIDERS.join("|")}> \\ --model --effort --mode \\ --prompt --cwd --output --receipt [--timeout ] diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/commands.test.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/commands.test.ts index ea697ff2..827a1b66 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/commands.test.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/commands.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { invocationCommand } from "./commands.ts"; +import { invocationCommand, preflightCommand } from "./commands.ts"; import type { RunnerOptions } from "./types.ts"; function options(overrides: Partial = {}): RunnerOptions { @@ -146,6 +146,30 @@ describe("invocationCommand", () => { ); }); + it("runs gateway lanes with the exact claude argv for the lane's model", () => { + for (const [provider, model] of [ + ["deepseek", "deepseek-flash"], + ["minimax", "MiniMax-M3"], + ] as const) { + const gateway = invocationCommand(options({ provider, model })); + const claude = invocationCommand( + options({ provider: "claude", model }) + ); + expect(gateway.command).toBe("claude"); + expect(gateway.stdin).toBe("prompt"); + expect(gateway.args).toEqual(claude.args); + } + }); + + it("preflights gateway lanes with a version probe, not an auth check", () => { + for (const provider of ["deepseek", "minimax"] as const) { + const spec = preflightCommand(provider); + expect(spec.command).toBe("claude"); + expect(spec.args).toEqual(["--version"]); + expect(spec.stdin).toBe("none"); + } + }); + it("covers low, medium, and high for every external provider", () => { const cases = [ { diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/commands.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/commands.ts index 5f2b10c6..e61951d6 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/commands.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/commands.ts @@ -4,6 +4,7 @@ import type { Provider, RunnerOptions, } from "./types.ts"; +import { isGatewayProvider } from "./types.ts"; export interface CommandSpec { readonly command: string; @@ -12,6 +13,14 @@ export interface CommandSpec { } export function preflightCommand(provider: Provider): CommandSpec { + if (isGatewayProvider(provider)) { + // Gateway lanes run the claude binary with token auth against a + // third-party endpoint. `claude auth status` semantics under token + // auth are undocumented, so the preflight only proves the binary + // executes; credentials are checked in-process by the gateway guard + // and the one-shot invocation is the real auth test. + return { command: "claude", args: ["--version"], stdin: "none" }; + } switch (provider) { case "claude": return { @@ -63,33 +72,40 @@ function effortOverride(effort: Effort): string { return `model_reasoning_effort=${JSON.stringify(effort)}`; } +function claudeInvocation(options: RunnerOptions): CommandSpec { + return { + command: "claude", + args: [ + "-p", + "--model", + options.model, + "--effort", + options.effort, + "--permission-mode", + permissionMode(options.mode), + "--setting-sources", + "project", + "--strict-mcp-config", + "--tools", + claudeTools(options.mode), + "--no-session-persistence", + "--disable-slash-commands", + "--disallowed-tools", + claudeDeniedTools(options.mode), + "--output-format", + "json", + ], + stdin: "prompt", + }; +} + export function invocationCommand(options: RunnerOptions): CommandSpec { + // Gateway lanes use the same binary and argv as claude; the difference is + // injected environment (endpoint, token, isolated CLAUDE_CONFIG_DIR). + if (isGatewayProvider(options.provider)) return claudeInvocation(options); switch (options.provider) { case "claude": - return { - command: "claude", - args: [ - "-p", - "--model", - options.model, - "--effort", - options.effort, - "--permission-mode", - permissionMode(options.mode), - "--setting-sources", - "project", - "--strict-mcp-config", - "--tools", - claudeTools(options.mode), - "--no-session-persistence", - "--disable-slash-commands", - "--disallowed-tools", - claudeDeniedTools(options.mode), - "--output-format", - "json", - ], - stdin: "prompt", - }; + return claudeInvocation(options); case "codex": return { command: "codex", diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/flex-providers.test.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/flex-providers.test.ts new file mode 100644 index 00000000..755b70d3 --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/flex-providers.test.ts @@ -0,0 +1,183 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; +import { + GATEWAY_INHERITED_CONFLICTS, + GATEWAY_SPECS, + gatewayConfigDir, + gatewayEnvironment, + gatewayGuard, +} from "./flex-providers.ts"; +import { GATEWAY_PROVIDERS } from "./types.ts"; + +let scratch = ""; + +beforeEach(() => { + scratch = mkdtempSync(join(tmpdir(), "flex-providers-")); +}); + +afterEach(() => { + rmSync(scratch, { recursive: true, force: true }); +}); + +describe("GATEWAY_SPECS", () => { + it("covers every gateway provider with an https default endpoint", () => { + for (const provider of GATEWAY_PROVIDERS) { + const spec = GATEWAY_SPECS[provider]; + expect(spec.apiKeyVar.length).toBeGreaterThan(0); + expect(spec.baseUrlDefault.startsWith("https://")).toBe(true); + } + }); +}); + +describe("gatewayConfigDir", () => { + it("defaults under the home directory per provider", () => { + expect(gatewayConfigDir("deepseek", {})).toBe( + join(homedir(), ".pstack-flex", "deepseek") + ); + expect(gatewayConfigDir("minimax", {})).toBe( + join(homedir(), ".pstack-flex", "minimax") + ); + }); + + it("honors the override variable and ignores blank overrides", () => { + expect( + gatewayConfigDir("deepseek", { PSTACK_FLEX_DEEPSEEK_CONFIG_DIR: "/opt/lane" }) + ).toBe("/opt/lane"); + expect( + gatewayConfigDir("deepseek", { PSTACK_FLEX_DEEPSEEK_CONFIG_DIR: " " }) + ).toBe(join(homedir(), ".pstack-flex", "deepseek")); + }); +}); + +describe("gatewayEnvironment", () => { + it("injects the full endpoint, token, model, and isolation map", () => { + const source = { + DEEPSEEK_API_KEY: "sk-test", + PSTACK_FLEX_DEEPSEEK_CONFIG_DIR: scratch, + }; + expect(gatewayEnvironment("deepseek", "deepseek-flash", source)).toEqual({ + ANTHROPIC_BASE_URL: "https://api.deepseek.com/anthropic", + ANTHROPIC_AUTH_TOKEN: "sk-test", + ANTHROPIC_MODEL: "deepseek-flash", + ANTHROPIC_DEFAULT_OPUS_MODEL: "deepseek-flash", + ANTHROPIC_DEFAULT_SONNET_MODEL: "deepseek-flash", + ANTHROPIC_DEFAULT_HAIKU_MODEL: "deepseek-flash", + CLAUDE_CODE_SUBAGENT_MODEL: "deepseek-flash", + CLAUDE_CODE_ATTRIBUTION_HEADER: "0", + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", + CLAUDE_CODE_MAX_CONTEXT_TOKENS: "128000", + CLAUDE_CONFIG_DIR: scratch, + }); + }); + + it("omits the context cap when the provider has no default", () => { + const env = gatewayEnvironment("minimax", "MiniMax-M3", { + MINIMAX_API_KEY: "mm-test", + PSTACK_FLEX_MINIMAX_CONFIG_DIR: scratch, + }); + expect(env.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBeUndefined(); + expect(env.ANTHROPIC_BASE_URL).toBe("https://api.minimax.io/anthropic"); + expect(env.ANTHROPIC_MODEL).toBe("MiniMax-M3"); + }); + + it("honors base URL and context overrides", () => { + const env = gatewayEnvironment("deepseek", "deepseek-flash", { + DEEPSEEK_API_KEY: "sk-test", + DEEPSEEK_BASE_URL: "https://proxy.internal/anthropic", + DEEPSEEK_MAX_CONTEXT_TOKENS: "64000", + }); + expect(env.ANTHROPIC_BASE_URL).toBe("https://proxy.internal/anthropic"); + expect(env.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBe("64000"); + }); + + it("never leaks a value from a non-token source variable", () => { + const env = gatewayEnvironment("deepseek", "deepseek-flash", { + DEEPSEEK_API_KEY: "sk-secret", + UNRELATED_SECRET: "do-not-copy", + }); + const values = Object.entries(env) + .filter(([key]) => key !== "ANTHROPIC_AUTH_TOKEN") + .map(([, value]) => value); + expect(values).not.toContain("sk-secret"); + expect(values).not.toContain("do-not-copy"); + }); + + it("lists every alternative Claude provider selector as an inherited conflict", () => { + const conflicts = new Set(GATEWAY_INHERITED_CONFLICTS); + for (const key of [ + "CLAUDE_CODE_USE_ANTHROPIC_AWS", + "CLAUDE_CODE_USE_BEDROCK", + "CLAUDE_CODE_USE_FOUNDRY", + "CLAUDE_CODE_USE_MANTLE", + "CLAUDE_CODE_USE_VERTEX", + "CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST", + "CLAUDE_CONFIG_DIR", + ]) { + expect(conflicts.has(key)).toBe(true); + } + }); +}); + +describe("gatewayGuard", () => { + it("refuses when the API key variable is missing or blank", () => { + expect(gatewayGuard("deepseek", {})?.message).toBe("DEEPSEEK_API_KEY is not set"); + expect(gatewayGuard("minimax", { MINIMAX_API_KEY: " " })?.message).toBe( + "MINIMAX_API_KEY is not set" + ); + }); + + it("passes when the config dir does not exist yet", () => { + expect( + gatewayGuard("deepseek", { + DEEPSEEK_API_KEY: "sk-test", + PSTACK_FLEX_DEEPSEEK_CONFIG_DIR: join(scratch, "never-created"), + }) + ).toBeNull(); + }); + + it("refuses an OAuth credentials file and cites the path, not the contents", () => { + const dir = join(scratch, "oauth"); + mkdirSync(dir); + const credentials = join(dir, ".credentials.json"); + writeFileSync( + credentials, + JSON.stringify({ claudeAiOauth: { accessToken: "oauth-secret" } }), + { mode: 0o600 } + ); + const refusal = gatewayGuard("deepseek", { + DEEPSEEK_API_KEY: "sk-test", + PSTACK_FLEX_DEEPSEEK_CONFIG_DIR: dir, + }); + expect(refusal?.message).toContain("OAuth credentials found"); + expect(refusal?.evidence).toBe(credentials); + expect(refusal?.evidence).not.toContain("oauth-secret"); + expect(refusal?.message).not.toContain("oauth-secret"); + }); + + it("refuses an unparseable credentials file", () => { + const dir = join(scratch, "garbage"); + mkdirSync(dir); + writeFileSync(join(dir, ".credentials.json"), "not json", { mode: 0o600 }); + const refusal = gatewayGuard("deepseek", { + DEEPSEEK_API_KEY: "sk-test", + PSTACK_FLEX_DEEPSEEK_CONFIG_DIR: dir, + }); + expect(refusal?.message).toContain("unknown credential state"); + }); + + it("passes a credentials file that carries no OAuth markers", () => { + const dir = join(scratch, "clean"); + mkdirSync(dir); + writeFileSync(join(dir, ".credentials.json"), JSON.stringify({ note: "empty" }), { + mode: 0o600, + }); + expect( + gatewayGuard("deepseek", { + DEEPSEEK_API_KEY: "sk-test", + PSTACK_FLEX_DEEPSEEK_CONFIG_DIR: dir, + }) + ).toBeNull(); + }); +}); diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/flex-providers.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/flex-providers.ts new file mode 100644 index 00000000..1a247860 --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/flex-providers.ts @@ -0,0 +1,135 @@ +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { GatewayProvider } from "./types.ts"; + +// pstack-flex addition. Gateway providers run the stock `claude` binary +// against a third-party Anthropic-compatible endpoint. Everything a lane +// needs is injected as environment at spawn time; secrets come from the +// operator's environment and are never written to disk or receipts. + +export interface GatewaySpec { + readonly apiKeyVar: string; + readonly baseUrlDefault: string; + readonly baseUrlOverrideVar: string; + readonly configDirOverrideVar: string; + readonly maxContextTokensDefault: string | null; + readonly maxContextTokensOverrideVar: string; +} + +export const GATEWAY_SPECS: Record = { + deepseek: { + apiKeyVar: "DEEPSEEK_API_KEY", + baseUrlDefault: "https://api.deepseek.com/anthropic", + baseUrlOverrideVar: "DEEPSEEK_BASE_URL", + configDirOverrideVar: "PSTACK_FLEX_DEEPSEEK_CONFIG_DIR", + maxContextTokensDefault: "128000", + maxContextTokensOverrideVar: "DEEPSEEK_MAX_CONTEXT_TOKENS", + }, + minimax: { + apiKeyVar: "MINIMAX_API_KEY", + baseUrlDefault: "https://api.minimax.io/anthropic", + baseUrlOverrideVar: "MINIMAX_BASE_URL", + configDirOverrideVar: "PSTACK_FLEX_MINIMAX_CONFIG_DIR", + maxContextTokensDefault: null, + maxContextTokensOverrideVar: "MINIMAX_MAX_CONTEXT_TOKENS", + }, +}; + +// Provider selection and Claude configuration from the parent must not +// override the gateway's endpoint, token, or isolated config directory. +export const GATEWAY_INHERITED_CONFLICTS = [ + "CLAUDE_CODE_USE_ANTHROPIC_AWS", + "CLAUDE_CODE_USE_BEDROCK", + "CLAUDE_CODE_USE_FOUNDRY", + "CLAUDE_CODE_USE_MANTLE", + "CLAUDE_CODE_USE_VERTEX", + "CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST", + "CLAUDE_CODE_SUBAGENT_MODEL", + "CLAUDE_CODE_MAX_CONTEXT_TOKENS", + "CLAUDE_CONFIG_DIR", +] as const; + +function overridden(source: NodeJS.ProcessEnv, name: string): string | null { + const value = source[name]; + return value !== undefined && value.trim().length > 0 ? value : null; +} + +export function gatewayConfigDir( + provider: GatewayProvider, + source: NodeJS.ProcessEnv = process.env +): string { + return ( + overridden(source, GATEWAY_SPECS[provider].configDirOverrideVar) ?? + join(homedir(), ".pstack-flex", provider) + ); +} + +export function gatewayEnvironment( + provider: GatewayProvider, + model: string, + source: NodeJS.ProcessEnv = process.env +): NodeJS.ProcessEnv { + const spec = GATEWAY_SPECS[provider]; + const injected: NodeJS.ProcessEnv = { + ANTHROPIC_BASE_URL: overridden(source, spec.baseUrlOverrideVar) ?? spec.baseUrlDefault, + ANTHROPIC_MODEL: model, + ANTHROPIC_DEFAULT_OPUS_MODEL: model, + ANTHROPIC_DEFAULT_SONNET_MODEL: model, + ANTHROPIC_DEFAULT_HAIKU_MODEL: model, + CLAUDE_CODE_SUBAGENT_MODEL: model, + CLAUDE_CODE_ATTRIBUTION_HEADER: "0", + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", + CLAUDE_CONFIG_DIR: gatewayConfigDir(provider, source), + }; + const token = overridden(source, spec.apiKeyVar); + if (token !== null) injected.ANTHROPIC_AUTH_TOKEN = token; + const maxContext = + overridden(source, spec.maxContextTokensOverrideVar) ?? spec.maxContextTokensDefault; + if (maxContext !== null) injected.CLAUDE_CODE_MAX_CONTEXT_TOKENS = maxContext; + return injected; +} + +export interface GatewayRefusal { + readonly message: string; + readonly evidence: string; +} + +// Runs in-process before any subprocess is spawned, so no request can leave +// the machine first. Refusals surface as `unauthenticated` receipts. +export function gatewayGuard( + provider: GatewayProvider, + source: NodeJS.ProcessEnv = process.env +): GatewayRefusal | null { + const spec = GATEWAY_SPECS[provider]; + if (overridden(source, spec.apiKeyVar) === null) { + return { + message: `${spec.apiKeyVar} is not set`, + evidence: `gateway lane ${provider} requires ${spec.apiKeyVar} in the environment`, + }; + } + const credentialsPath = join(gatewayConfigDir(provider, source), ".credentials.json"); + if (!existsSync(credentialsPath)) return null; + let raw: unknown; + try { + raw = JSON.parse(readFileSync(credentialsPath, "utf8")); + } catch { + return { + message: + "unreadable credentials file in gateway config dir; refusing to run with unknown credential state", + evidence: credentialsPath, + }; + } + const record = + raw !== null && typeof raw === "object" && !Array.isArray(raw) + ? (raw as Record) + : null; + if (record === null || "claudeAiOauth" in record || "accessToken" in record) { + return { + message: + "OAuth credentials found in gateway config dir; refusing to point a claude.ai login at a third-party endpoint", + evidence: credentialsPath, + }; + } + return null; +} diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/parse-output.test.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/parse-output.test.ts index b4ebc041..270a5863 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/parse-output.test.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/parse-output.test.ts @@ -110,6 +110,38 @@ describe("parseProviderOutput", () => { expect(parsed.reportedModel).toBe("claude-fable-9-9"); }); + it("parses gateway output as claude-shaped JSON with cost forced null", () => { + const parsed = parseProviderOutput( + "minimax", + JSON.stringify({ + result: "GATEWAY_OK", + session_id: "mm-session", + usage: { input_tokens: 12, output_tokens: 5 }, + total_cost_usd: 0.42, + modelUsage: { "minimax-m3": {} }, + }), + "", + "MiniMax-M3" + ); + expect(parsed).toMatchObject({ + text: "GATEWAY_OK", + reportedModel: "minimax-m3", + sessionId: "mm-session", + usage: { inputTokens: 12, outputTokens: 5 }, + costUsd: null, + }); + }); + + it("matches gateway model slugs case-insensitively", () => { + expect(reportedModelMatches("minimax", "MiniMax-M3", "minimax-m3")).toBe(true); + expect(reportedModelMatches("deepseek", "deepseek-flash", "DeepSeek-Flash")).toBe(true); + expect( + reportedModelMatches("deepseek", "deepseek-flash", "deepseek-flash-0731") + ).toBe(true); + expect(reportedModelMatches("minimax", "MiniMax-M3", "some-other-model")).toBe(false); + expect(reportedModelMatches("claude", "MiniMax-M3", "minimax-m3")).toBe(false); + }); + it("matches only concrete Claude revisions from the requested rolling family", () => { expect(reportedModelMatches("claude", "fable", "claude-fable-9-9")).toBe(true); expect(reportedModelMatches("claude", "opus", "claude-opus-9")).toBe(true); diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/parse-output.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/parse-output.ts index 81ed53d4..64d51b75 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/parse-output.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/parse-output.ts @@ -3,6 +3,7 @@ import type { ParsedOutput, Provider, } from "./types.ts"; +import { isGatewayProvider } from "./types.ts"; import { concreteModelMatchesRollingAlias, isRollingClaudeAlias, @@ -61,7 +62,11 @@ function modelFromUsage( ?? null; } -function parseClaude(stdout: string, requestedModel: string): ParsedOutput { +function parseClaude( + stdout: string, + requestedModel: string, + provider: Provider = "claude" +): ParsedOutput { let raw: unknown; try { raw = JSON.parse(stdout); @@ -77,7 +82,7 @@ function parseClaude(stdout: string, requestedModel: string): ParsedOutput { return { text, - reportedModel: modelFromUsage(value.modelUsage, "claude", requestedModel), + reportedModel: modelFromUsage(value.modelUsage, provider, requestedModel), sessionId: nullableString(value.session_id ?? value.sessionId), usage: normalizedUsage(value.usage), costUsd: finiteNumber(value.total_cost_usd) ?? null, @@ -163,6 +168,13 @@ export function parseProviderOutput( stderr: string, requestedModel: string ): ParsedOutput { + if (isGatewayProvider(provider)) { + // Gateway lanes emit claude-shaped JSON, but the CLI's + // total_cost_usd is computed at Anthropic rates and would be + // fiction for third-party traffic. Token usage stays; cost is null. + const parsed = parseClaude(stdout, requestedModel, provider); + return { ...parsed, costUsd: null }; + } switch (provider) { case "claude": return parseClaude(stdout, requestedModel); @@ -182,6 +194,13 @@ export function reportedModelMatches( if (provider === "claude" && isRollingClaudeAlias(requested)) { return concreteModelMatchesRollingAlias(requested, reported); } + if (isGatewayProvider(provider)) { + // Third-party endpoints are inconsistent about slug casing + // (e.g. MiniMax-M3 vs minimax-m3); compare case-insensitively. + const wanted = requested.toLowerCase(); + const got = reported.toLowerCase(); + return got === wanted || got.startsWith(`${wanted}-`); + } if (reported === requested || reported.startsWith(`${requested}-`)) { return true; } diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/run.test.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/run.test.ts index 20743b52..41e9a5f3 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/run.test.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/run.test.ts @@ -25,7 +25,7 @@ import { appendFileSync, existsSync, unlinkSync, writeFileSync } from "node:fs"; const args = process.argv.slice(2); const name = process.argv[1].split("/").at(-1); const isPreflight = - (name === "claude" && args[0] === "auth") || + (name === "claude" && (args[0] === "auth" || args[0] === "--version")) || (name === "codex" && args[0] === "login") || (name === "grok" && args[0] === "models"); const stage = isPreflight ? "preflight" : "model"; @@ -61,6 +61,10 @@ if (name === "claude" && args[0] === "auth") { console.log(JSON.stringify({loggedIn:true})); process.exit(0); } +if (name === "claude" && args[0] === "--version") { + console.log("9.9.9 (fake)"); + process.exit(0); +} if (name === "codex" && args[0] === "login") { console.log("Logged in using ChatGPT"); process.exit(0); @@ -89,11 +93,18 @@ if (name === "grok" && args[0] === "models") { } const modelIndex = args.findIndex((value) => value === "--model"); const model = modelIndex >= 0 ? args[modelIndex + 1] : "unknown"; -const reportedModel = model === "fable" +const reportedModel = process.env.FAKE_REPORT_MODEL ?? (model === "fable" ? "claude-fable-9-9" : model === "opus" ? "claude-opus-9" - : model; + : model); +if (stage === "model" && process.env.FAKE_DUMP_ENV_PATH) { + writeFileSync(process.env.FAKE_DUMP_ENV_PATH, JSON.stringify(process.env)); +} +if (stage === "model" && process.env.FAKE_AUTH_ERROR === "1") { + console.error("API error: authentication_error - invalid api key"); + process.exit(1); +} if (process.env.FAKE_INVALID_MODEL === "1") { console.error("The requested model is not supported with this account."); process.exit(1); @@ -115,7 +126,7 @@ if (stage === "model" && process.env.FAKE_SELF_SIGNAL) { await Bun.sleep(5_000); } if (name === "claude") { - console.log(JSON.stringify({result:"CLAUDE_OK",session_id:"c1",usage:{input_tokens:10,output_tokens:2},total_cost_usd:0.01,modelUsage:{[reportedModel]:{}}})); + console.log(JSON.stringify({result:"CLAUDE_OK",session_id:"c1",usage:{input_tokens:10,output_tokens:2},total_cost_usd:0.01,...(process.env.FAKE_OMIT_MODEL_USAGE === "1" ? {} : {modelUsage:{[reportedModel]:{}}})})); } else if (name === "codex") { console.log(JSON.stringify({type:"thread.started",thread_id:"o1"})); console.log(JSON.stringify({type:"item.completed",item:{type:"agent_message",text:"CODEX_OK"}})); @@ -906,7 +917,197 @@ describe("runLane", () => { }); }); +describe("gateway lanes", () => { + const GATEWAY_TEST_KEYS = [ + "DEEPSEEK_API_KEY", + "MINIMAX_API_KEY", + "PSTACK_FLEX_DEEPSEEK_CONFIG_DIR", + "PSTACK_FLEX_MINIMAX_CONFIG_DIR", + "ANTHROPIC_API_KEY", + "ANTHROPIC_CUSTOM_HEADERS", + "CLAUDE_CODE_USE_BEDROCK", + "FAKE_DUMP_ENV_PATH", + "FAKE_AUTH_ERROR", + "FAKE_REPORT_MODEL", + "FAKE_OMIT_MODEL_USAGE", + ] as const; + + function gatewayOptions( + provider: "deepseek" | "minimax", + suffix: string + ): RunnerOptions { + return { + ...options(provider === "deepseek" ? "claude" : "codex", suffix), + provider, + parent: "claude", + model: provider === "deepseek" ? "deepseek-flash" : "MiniMax-M3", + effort: "high", + }; + } + + beforeEach(() => { + for (const key of GATEWAY_TEST_KEYS) delete process.env[key]; + process.env.DEEPSEEK_API_KEY = "sk-deepseek-test"; + process.env.MINIMAX_API_KEY = "sk-minimax-test"; + process.env.PSTACK_FLEX_DEEPSEEK_CONFIG_DIR = join(scratch, "flex-deepseek"); + process.env.PSTACK_FLEX_MINIMAX_CONFIG_DIR = join(scratch, "flex-minimax"); + }); + + afterEach(() => { + for (const key of GATEWAY_TEST_KEYS) delete process.env[key]; + }); + + it("refuses without spawning anything when the API key is missing", async () => { + delete process.env.DEEPSEEK_API_KEY; + process.env.FAKE_PREFLIGHT_STARTED_PATH = join(scratch, "preflight-started"); + process.env.FAKE_MODEL_STARTED_PATH = join(scratch, "model-started"); + const input = gatewayOptions("deepseek", "missing-key"); + const result = await runLane(input); + expect(result.exitCode).toBe(77); + const written = receipt(input.receiptPath); + expect(written.status).toBe("unauthenticated"); + expect(written.preflight.status).toBe("not-run"); + expect(written.error?.message).toBe("DEEPSEEK_API_KEY is not set"); + expect(existsSync(join(scratch, "preflight-started"))).toBe(false); + expect(existsSync(join(scratch, "model-started"))).toBe(false); + expect(existsSync(input.outputPath)).toBe(false); + }); + + it("refuses to run over an OAuth login without leaking its contents", async () => { + const dir = join(scratch, "flex-deepseek"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, ".credentials.json"), + JSON.stringify({ claudeAiOauth: { accessToken: "oauth-secret" } }), + { mode: 0o600 } + ); + const input = gatewayOptions("deepseek", "oauth-refused"); + const result = await runLane(input); + expect(result.exitCode).toBe(77); + const written = receipt(input.receiptPath); + expect(written.status).toBe("unauthenticated"); + expect(written.error?.message).toContain("OAuth credentials found"); + expect(written.error?.evidence).toBe(join(dir, ".credentials.json")); + expect(JSON.stringify(written)).not.toContain("oauth-secret"); + }); + + it("injects the gateway environment and never the parent's Anthropic identity", async () => { + process.env.ANTHROPIC_API_KEY = "parent-anthropic-secret"; + process.env.ANTHROPIC_CUSTOM_HEADERS = "Authorization: Bearer parent-header-secret"; + process.env.CLAUDE_CODE_USE_BEDROCK = "1"; + const dumpPath = join(scratch, "env-dump.json"); + process.env.FAKE_DUMP_ENV_PATH = dumpPath; + const input = gatewayOptions("deepseek", "env-dump"); + const result = await runLane(input); + expect(result.exitCode).toBe(0); + const child = JSON.parse(readFileSync(dumpPath, "utf8")) as Record; + expect(child.ANTHROPIC_BASE_URL).toBe("https://api.deepseek.com/anthropic"); + expect(child.ANTHROPIC_AUTH_TOKEN).toBe("sk-deepseek-test"); + expect(child.ANTHROPIC_MODEL).toBe("deepseek-flash"); + expect(child.CLAUDE_CODE_SUBAGENT_MODEL).toBe("deepseek-flash"); + expect(child.CLAUDE_CODE_ATTRIBUTION_HEADER).toBe("0"); + expect(child.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC).toBe("1"); + expect(child.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBe("128000"); + expect(child.CLAUDE_CONFIG_DIR).toBe(join(scratch, "flex-deepseek")); + expect(child.ANTHROPIC_API_KEY).toBeUndefined(); + expect(child.ANTHROPIC_CUSTOM_HEADERS).toBeUndefined(); + expect(child.CLAUDE_CODE_USE_BEDROCK).toBeUndefined(); + expect(child.CLAUDECODE).toBeUndefined(); + }); + + it("completes with cost null and a verified provider report on the happy path", async () => { + const input = gatewayOptions("deepseek", "happy"); + const result = await runLane(input); + expect(result.exitCode).toBe(0); + const written = receipt(input.receiptPath); + expect(written.status).toBe("complete"); + expect(written.costUsd).toBeNull(); + expect(written.usage).toMatchObject({ inputTokens: 10, outputTokens: 2 }); + expect(written.modelVerified).toBe(true); + expect(written.modelEvidence).toBe("provider-report"); + expect(written.preflight.status).toBe("passed"); + expect(written.preflight.evidence).toBe( + "claude binary responded; gateway credentials verified in-process" + ); + expect(readFileSync(input.outputPath, "utf8")).toBe("CLAUDE_OK"); + }); + + it("verifies a case-shifted served model for MiniMax", async () => { + process.env.FAKE_REPORT_MODEL = "minimax-m3"; + const input = gatewayOptions("minimax", "case-shift"); + const result = await runLane(input); + expect(result.exitCode).toBe(0); + const written = receipt(input.receiptPath); + expect(written.modelVerified).toBe(true); + expect(written.modelEvidence).toBe("provider-report"); + expect(written.reportedModel).toBe("minimax-m3"); + }); + + it("fails when the endpoint reports a different model", async () => { + process.env.FAKE_REPORT_MODEL = "unrelated-model"; + const input = gatewayOptions("minimax", "pinned"); + const result = await runLane(input); + expect(result.exitCode).toBe(65); + const written = receipt(input.receiptPath); + expect(written.status).toBe("malformed-output"); + expect(written.modelVerified).toBe(false); + expect(written.modelEvidence).toBeNull(); + expect(written.error?.message).toContain("requested model MiniMax-M3 was not reported"); + expect(existsSync(input.outputPath)).toBe(false); + }); + + it("uses the pinned argv only when the endpoint reports no model", async () => { + process.env.FAKE_OMIT_MODEL_USAGE = "1"; + const input = gatewayOptions("minimax", "unreported-model"); + const result = await runLane(input); + expect(result.exitCode).toBe(0); + const written = receipt(input.receiptPath); + expect(written.status).toBe("complete"); + expect(written.reportedModel).toBeNull(); + expect(written.modelVerified).toBe(false); + expect(written.modelEvidence).toBe("pinned-argv"); + }); + + it("classifies an endpoint authentication error as unauthenticated", async () => { + process.env.FAKE_AUTH_ERROR = "1"; + const input = gatewayOptions("deepseek", "endpoint-401"); + const result = await runLane(input); + expect(result.exitCode).toBe(77); + expect(receipt(input.receiptPath).status).toBe("unauthenticated"); + }); +}); + describe("childEnvironment", () => { + it("strips identity and Anthropic inheritance before gateway injection", () => { + const source = { + PATH: "/bin", + CLAUDECODE: "1", + CODEX_CI: "1", + ANTHROPIC_API_KEY: "parent-secret", + ANTHROPIC_BASE_URL: "https://api.anthropic.com", + ANTHROPIC_CUSTOM_HEADERS: "Authorization: Bearer parent-secret", + ANTHROPIC_BEDROCK_BASE_URL: "https://parent-bedrock.example", + CLAUDE_CODE_USE_BEDROCK: "1", + CLAUDE_CODE_USE_VERTEX: "1", + DEEPSEEK_API_KEY: "sk-test", + PSTACK_FLEX_DEEPSEEK_CONFIG_DIR: "/tmp/flex-deepseek", + KEEP_ME: "yes", + }; + const env = childEnvironment("deepseek", source, "deepseek-flash"); + expect(env.CLAUDECODE).toBeUndefined(); + expect(env.CODEX_CI).toBeUndefined(); + expect(env.ANTHROPIC_API_KEY).toBeUndefined(); + expect(env.ANTHROPIC_CUSTOM_HEADERS).toBeUndefined(); + expect(env.ANTHROPIC_BEDROCK_BASE_URL).toBeUndefined(); + expect(env.CLAUDE_CODE_USE_BEDROCK).toBeUndefined(); + expect(env.CLAUDE_CODE_USE_VERTEX).toBeUndefined(); + expect(env.ANTHROPIC_BASE_URL).toBe("https://api.deepseek.com/anthropic"); + expect(env.ANTHROPIC_AUTH_TOKEN).toBe("sk-test"); + expect(env.CLAUDE_CONFIG_DIR).toBe("/tmp/flex-deepseek"); + expect(env.KEEP_ME).toBe("yes"); + expect(env.PATH).toBe("/bin"); + }); + it("removes only inherited runtime identity needed to avoid nested detection", () => { const source = { PATH: "/bin", diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/run.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/run.ts index 054564a4..b3e2ef03 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/run.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/run.ts @@ -10,6 +10,11 @@ import { } from "node:fs"; import { dirname, resolve } from "node:path"; import { invocationCommand, preflightCommand, type CommandSpec } from "./commands.ts"; +import { + GATEWAY_INHERITED_CONFLICTS, + gatewayEnvironment, + gatewayGuard, +} from "./flex-providers.ts"; import { versionedClaudeAlias } from "./model-aliases.ts"; import { parseProviderOutput, reportedModelMatches } from "./parse-output.ts"; import type { @@ -18,7 +23,7 @@ import type { RunnerOptions, RunnerReceipt, } from "./types.ts"; -import { UsageError } from "./types.ts"; +import { isGatewayProvider, UsageError } from "./types.ts"; const ERROR_EVIDENCE_LIMIT = 4_000; const GROK_PREFLIGHT_RETRY_DELAY_MS = 5_000; @@ -131,7 +136,8 @@ const CLAUDE_IDENTITY = [ export function childEnvironment( provider: Provider, - source: NodeJS.ProcessEnv = process.env + source: NodeJS.ProcessEnv = process.env, + model: string = "" ): NodeJS.ProcessEnv { const result = { ...source }; const remove = provider === "claude" @@ -140,6 +146,13 @@ export function childEnvironment( ? CLAUDE_IDENTITY : [...CODEX_IDENTITY, ...CLAUDE_IDENTITY]; for (const key of remove) delete result[key]; + if (isGatewayProvider(provider)) { + for (const key of Object.keys(result)) { + if (key.startsWith("ANTHROPIC_")) delete result[key]; + } + for (const key of GATEWAY_INHERITED_CONFLICTS) delete result[key]; + Object.assign(result, gatewayEnvironment(provider, model, source)); + } return result; } @@ -352,6 +365,9 @@ async function waitForGrokPreflightRetry( function preflightPassed(provider: Provider, model: string, result: ProcessResult): boolean { if (result.exitCode !== 0 || result.timedOut) return false; + // `claude --version` succeeded; gateway credentials were already verified + // in-process by the gateway guard before any subprocess ran. + if (isGatewayProvider(provider)) return true; const combined = `${result.stdout}\n${result.stderr}`; switch (provider) { case "claude": { @@ -374,6 +390,9 @@ function preflightPassed(provider: Provider, model: string, result: ProcessResul } function successfulPreflightEvidence(provider: Provider, model: string): string { + if (isGatewayProvider(provider)) { + return "claude binary responded; gateway credentials verified in-process"; + } return provider === "grok" ? `authenticated; model ${model} available` : "authenticated"; @@ -396,6 +415,11 @@ function preflightFailureStatus( ): ReceiptStatus { const status = unavailableStatus(value); if (status !== "child-failed") return status; + if (isGatewayProvider(provider)) { + // The gateway preflight is a version probe, not an auth check; a + // failure here means the binary misbehaved, not that auth failed. + return "child-failed"; + } return provider === "grok" && !value.includes(model) ? "unavailable-model" : "unauthenticated"; @@ -457,6 +481,16 @@ function modelProof( modelEvidence: "pinned-argv", }; } + if (isGatewayProvider(provider) && reported === null) { + // Third-party Anthropic-compatible endpoints do not reliably echo the + // requested model slug. A reported mismatch is a failure, since some + // gateways silently substitute a default model for unknown slugs. + return { + reportedModel: null, + modelVerified: false, + modelEvidence: "pinned-argv", + }; + } return { reportedModel: reported, modelVerified: false, @@ -534,7 +568,7 @@ async function executeLane( ): Promise { const startedAt = new Date(started).toISOString(); const prompt = readFileSync(options.promptPath, "utf8"); - const env = childEnvironment(options.provider); + const env = childEnvironment(options.provider, process.env, options.model); const executable = Bun.which(invocation.command, { PATH: env.PATH, cwd: options.cwd, @@ -589,6 +623,37 @@ async function executeLane( return finishWithoutChild("timed-out", "before authentication preflight"); } + if (isGatewayProvider(options.provider)) { + const refusal = gatewayGuard(options.provider); + if (refusal !== null) { + const completed = Date.now(); + receipt = completeReceipt(options, { + status: "unauthenticated", + startedAt, + completedAt: new Date(completed).toISOString(), + elapsedMs: completed - started, + executable, + preflight: preflightState, + argv: [executable ?? invocation.command, ...invocation.args], + exitCode: null, + signal: null, + reportedModel: null, + modelVerified: false, + modelEvidence: null, + sessionId: null, + usage: null, + costUsd: null, + error: { + message: refusal.message, + evidence: refusal.evidence, + }, + }); + removeIfExists(options.outputPath); + writeReceipt(options.receiptPath, receipt); + return { exitCode: statusExitCode("unauthenticated"), receipt }; + } + } + if (executable === null) { const completed = Date.now(); receipt = completeReceipt(options, { diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/types.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/types.ts index 11c6dfb9..418b2bb6 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/types.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/types.ts @@ -1,10 +1,19 @@ export const PARENTS = ["claude", "codex"] as const; -export const PROVIDERS = ["claude", "codex", "grok"] as const; +// pstack-flex: gateway providers run the stock `claude` binary against a +// third-party Anthropic-compatible endpoint with injected environment. Adding +// one here requires a matching row in flex-providers.ts GATEWAY_SPECS. +export const GATEWAY_PROVIDERS = ["deepseek", "minimax"] as const; +export const PROVIDERS = ["claude", "codex", "grok", ...GATEWAY_PROVIDERS] as const; export const EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const; export const ACCESS_MODES = ["read-only", "isolated-write"] as const; export type Parent = (typeof PARENTS)[number]; export type Provider = (typeof PROVIDERS)[number]; +export type GatewayProvider = (typeof GATEWAY_PROVIDERS)[number]; + +export function isGatewayProvider(provider: Provider): provider is GatewayProvider { + return (GATEWAY_PROVIDERS as readonly string[]).includes(provider); +} export type Effort = (typeof EFFORTS)[number]; export type AccessMode = (typeof ACCESS_MODES)[number];