From 00757e96ad39dfcc989d28e16be9b666a39b7d44 Mon Sep 17 00:00:00 2001 From: Martin Patino Date: Fri, 25 Sep 2026 05:25:00 +0000 Subject: [PATCH 1/5] docs: record pstack-flex fork point and provenance Fork of ericlitman/open-pstack at v1.4.1 (de67e6b). UPSTREAM-FLEX.md adds the second sync layer (cursor -> open-pstack -> flex) with the merge rehearsal procedure and the expected conflict surface for upstream 1.5.0. NOTICE.md records the provenance chain, LICENSE adds the fork copyright line without removing existing holders, and the README gains a fork section stating what flex adds and that a stock install behaves exactly like upstream. --- LICENSE | 1 + NOTICE.md | 4 ++++ README.md | 12 ++++++++++++ UPSTREAM-FLEX.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 63 insertions(+) create mode 100644 UPSTREAM-FLEX.md 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..b6e8238d 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 additions (gateway providers, optional families, and their docs and 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..1675031c 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,18 @@ 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 friendly fork of [ericlitman/open-pstack](https://github.com/ericlitman/open-pstack) (fork point: v1.4.1). A stock install behaves exactly like upstream. On top of that it adds model freedom and cost control: + +- **Optional model families.** Setup no longer requires all four frontier families. Assign the families you actually have; unassigned families get no effort question and no probe. +- **Gateway lanes for DeepSeek and MiniMax.** New `deepseek:*` and `minimax:*` descriptors run the stock `claude` binary against each lab's Anthropic-compatible endpoint with that lab's API key — no Anthropic account, no subscription, pay per token. See the flex model matrix in [provider-dispatch.md](plugins/pstack/skills/poteto-mode/references/provider-dispatch.md). +- **Zero-subscription mode.** The whole stack can run on nothing but `DEEPSEEK_API_KEY` and `MINIMAX_API_KEY`; two labs are two model families, so adversarial panels keep real diversity. Walkthrough in [docs/LANES.md](docs/LANES.md). +- **Credential isolation by construction.** Gateway lanes use an isolated `CLAUDE_CONFIG_DIR` and refuse to start if a claude.ai OAuth login is present there, so a claude.ai credential can never be sent to a third-party endpoint. +- **Honest cost reporting.** Gateway receipts force `costUsd` to null instead of reporting Anthropic-rate fiction; real prices (including DeepSeek's off-peak discount window) live in [docs/LANES.md](docs/LANES.md). + +What does not change: pstack's skills and playbooks, the four stock lanes and their defaults, the no-fallback and no-implicit-timeout contracts, and upstream's sync process. The flex delta is documented in [UPSTREAM-FLEX.md](UPSTREAM-FLEX.md). Anthropic does not support pointing Claude Code at non-Anthropic endpoints (unsupported is not prohibited); read the safety notes in [docs/LANES.md](docs/LANES.md) before enabling a gateway lane. + ## 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..fbe5cf43 --- /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 +- The "Flex model matrix" section and route-table columns in `references/provider-dispatch.md` +- The assignment-first restructure of `skills/setup-pstack/SKILL.md` +- `docs/LANES.md`, this file, the README fork section, and the NOTICE/LICENSE/CHANGES additions + +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 the live lane checks in [docs/LANES.md](docs/LANES.md) before tagging. From 09373fa9eb63e53a609bbf064c98baf00e6ac360 Mon Sep 17 00:00:00 2001 From: Martin Patino Date: Fri, 25 Sep 2026 05:25:00 +0000 Subject: [PATCH 2/5] feat(runner): add deepseek and minimax gateway providers New gateway lanes run the stock claude binary against each lab's Anthropic-compatible endpoint, reusing the exact claude argv and the existing spawn path. flex-providers.ts owns the per-provider env maps: endpoint (overridable), ANTHROPIC_AUTH_TOKEN from DEEPSEEK_API_KEY or MINIMAX_API_KEY, model pins, nonessential-traffic and attribution-header hygiene, and an isolated CLAUDE_CONFIG_DIR under ~/.pstack-flex. childEnvironment deletes inherited ANTHROPIC_* values before injection so a parent session's credentials or endpoint never bleed into a gateway child. - Preflight is claude --version: auth-status semantics under token auth are undocumented, credentials are checked in-process, and the one-shot invocation is the real auth test. - Gateway receipts force costUsd to null (the CLI prices total_cost_usd at Anthropic rates, fiction for third-party traffic); token usage stays. - Served models match case-insensitively (MiniMax-M3 vs minimax-m3) and otherwise fall back to modelEvidence pinned-argv like Codex. - provider==parent stays rejected; gateway providers are distinct, so they run under both parents through the external runner. --- .../poteto-mode/scripts/runner/cli.test.ts | 24 +++ .../skills/poteto-mode/scripts/runner/cli.ts | 2 +- .../scripts/runner/commands.test.ts | 26 ++- .../poteto-mode/scripts/runner/commands.ts | 65 ++++--- .../scripts/runner/flex-providers.test.ts | 180 ++++++++++++++++++ .../scripts/runner/flex-providers.ts | 136 +++++++++++++ .../scripts/runner/parse-output.test.ts | 32 ++++ .../scripts/runner/parse-output.ts | 24 ++- .../poteto-mode/scripts/runner/types.ts | 10 +- 9 files changed, 470 insertions(+), 29 deletions(-) create mode 100644 plugins/pstack/skills/poteto-mode/scripts/runner/flex-providers.test.ts create mode 100644 plugins/pstack/skills/poteto-mode/scripts/runner/flex-providers.ts 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..45d6a60d 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 \\ --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..55326c8d 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/commands.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/commands.ts @@ -27,6 +27,14 @@ export function preflightCommand(provider: Provider): CommandSpec { }; case "grok": return { command: "grok", args: ["models"], stdin: "none" }; + case "deepseek": + case "minimax": + // 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" }; } } @@ -63,33 +71,42 @@ 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 { 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 "deepseek": + case "minimax": + // Same binary, same argv; the gateway difference is injected + // environment (endpoint, token, isolated CLAUDE_CONFIG_DIR). + 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..dc348d13 --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/flex-providers.test.ts @@ -0,0 +1,180 @@ +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 injected credential-bearing variable as an inherited conflict", () => { + // Whatever the parent had for these must be deleted before injection. + for (const key of [ + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_BASE_URL", + "CLAUDE_CONFIG_DIR", + ]) { + expect(GATEWAY_INHERITED_CONFLICTS as readonly string[]).toContain(key); + } + }); +}); + +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..3c29d8d3 --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/flex-providers.ts @@ -0,0 +1,136 @@ +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", + }, +}; + +// A parent session's own Anthropic credentials, endpoint, or model pins must +// never bleed into a gateway child. These are deleted before injection. +export const GATEWAY_INHERITED_CONFLICTS = [ + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_BASE_URL", + "ANTHROPIC_MODEL", + "ANTHROPIC_DEFAULT_OPUS_MODEL", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + "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..e41c6e93 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, @@ -170,6 +175,14 @@ export function parseProviderOutput( return parseCodex(stdout); case "grok": return parseGrok(stdout, requestedModel); + case "deepseek": + case "minimax": { + // 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 }; + } } } @@ -182,6 +195,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/types.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/types.ts index 11c6dfb9..146d70fc 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/types.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/types.ts @@ -1,10 +1,18 @@ export const PARENTS = ["claude", "codex"] as const; -export const PROVIDERS = ["claude", "codex", "grok"] as const; +export const PROVIDERS = ["claude", "codex", "grok", "deepseek", "minimax"] as const; +// pstack-flex: gateway providers run the stock `claude` binary against a +// third-party Anthropic-compatible endpoint with injected environment. +export const GATEWAY_PROVIDERS = ["deepseek", "minimax"] 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]; From 6fa309a1dcc2c2d33076c904abab74cb338bb97b Mon Sep 17 00:00:00 2001 From: Martin Patino Date: Fri, 25 Sep 2026 05:25:00 +0000 Subject: [PATCH 3/5] feat(runner): gateway preflight with OAuth-leak guard A gateway lane refuses to start, in-process and before any subprocess, when its API key variable is missing or its isolated config dir carries a claude.ai OAuth credentials file (or one that cannot be parsed). The refusal is an unauthenticated receipt (exit 77) whose evidence names the path and never the contents. This guarantees a claude.ai login can never be pointed at a third-party endpoint through the runner. Endpoint authentication errors from the one-shot invocation classify as unauthenticated through the existing stderr matching. Tests cover the key-missing, OAuth-refusal, garbage-file, env-injection, happy-path, case-shifted-model, pinned-argv, and endpoint-401 paths, plus the zero-subscription scenario with mocked binaries; nothing performs network I/O. --- .../poteto-mode/scripts/runner/run.test.ts | 178 +++++++++++++++++- .../skills/poteto-mode/scripts/runner/run.ts | 70 ++++++- 2 files changed, 242 insertions(+), 6 deletions(-) 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..6fbccee7 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); @@ -906,7 +917,168 @@ 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", + "FAKE_DUMP_ENV_PATH", + "FAKE_AUTH_ERROR", + "FAKE_REPORT_MODEL", + ] 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"; + 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.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("falls back to pinned argv when the endpoint reports another slug", async () => { + process.env.FAKE_REPORT_MODEL = "unrelated-model"; + const input = gatewayOptions("minimax", "pinned"); + const result = await runLane(input); + expect(result.exitCode).toBe(0); + const written = receipt(input.receiptPath); + expect(written.status).toBe("complete"); + 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", + 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_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..9d31dc61 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,10 @@ export function childEnvironment( ? CLAUDE_IDENTITY : [...CODEX_IDENTITY, ...CLAUDE_IDENTITY]; for (const key of remove) delete result[key]; + if (isGatewayProvider(provider)) { + for (const key of GATEWAY_INHERITED_CONFLICTS) delete result[key]; + Object.assign(result, gatewayEnvironment(provider, model, source)); + } return result; } @@ -370,10 +380,18 @@ function preflightPassed(provider: Provider, model: string, result: ProcessResul return /logged in/i.test(combined); case "grok": return /logged in/i.test(combined) && combined.includes(model); + case "deepseek": + case "minimax": + // `claude --version` succeeded; credentials were already verified + // in-process by the gateway guard before any subprocess ran. + return true; } } 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 +414,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 +480,16 @@ function modelProof( modelEvidence: "pinned-argv", }; } + if (isGatewayProvider(provider)) { + // Third-party Anthropic-compatible endpoints do not reliably echo the + // requested model slug; fall back to the pinned argv as evidence, the + // same posture Codex lanes already use. + return { + reportedModel: reported, + modelVerified: false, + modelEvidence: "pinned-argv", + }; + } return { reportedModel: reported, modelVerified: false, @@ -534,7 +567,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 +622,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, { From 09816a636169043f564b5daed8243dd0d709efdc Mon Sep 17 00:00:00 2001 From: Martin Patino Date: Sun, 27 Sep 2026 21:44:35 -0700 Subject: [PATCH 4/5] refactor(runner): derive gateway lanes from one provider list PROVIDERS now spreads GATEWAY_PROVIDERS, and the preflight, invocation, parse, and preflight-pass paths branch on isGatewayProvider instead of naming deepseek and minimax in each switch. Adding an Anthropic-compatible gateway is one GATEWAY_PROVIDERS entry plus one GATEWAY_SPECS row; the Record type makes a missing spec a compile error. --- .../skills/poteto-mode/scripts/runner/cli.ts | 2 +- .../poteto-mode/scripts/runner/commands.ts | 25 +++++++++---------- .../scripts/runner/parse-output.ts | 15 ++++++----- .../skills/poteto-mode/scripts/runner/run.ts | 8 +++--- .../poteto-mode/scripts/runner/types.ts | 5 ++-- 5 files changed, 26 insertions(+), 29 deletions(-) diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/cli.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/cli.ts index 45d6a60d..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.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/commands.ts index 55326c8d..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 { @@ -27,14 +36,6 @@ export function preflightCommand(provider: Provider): CommandSpec { }; case "grok": return { command: "grok", args: ["models"], stdin: "none" }; - case "deepseek": - case "minimax": - // 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" }; } } @@ -99,14 +100,12 @@ function claudeInvocation(options: RunnerOptions): CommandSpec { } 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 claudeInvocation(options); - case "deepseek": - case "minimax": - // Same binary, same argv; the gateway difference is injected - // environment (endpoint, token, isolated CLAUDE_CONFIG_DIR). - return claudeInvocation(options); case "codex": return { command: "codex", 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 e41c6e93..64d51b75 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/parse-output.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/parse-output.ts @@ -168,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); @@ -175,14 +182,6 @@ export function parseProviderOutput( return parseCodex(stdout); case "grok": return parseGrok(stdout, requestedModel); - case "deepseek": - case "minimax": { - // 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 }; - } } } diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/run.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/run.ts index 9d31dc61..667dd0b7 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/run.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/run.ts @@ -362,6 +362,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": { @@ -380,11 +383,6 @@ function preflightPassed(provider: Provider, model: string, result: ProcessResul return /logged in/i.test(combined); case "grok": return /logged in/i.test(combined) && combined.includes(model); - case "deepseek": - case "minimax": - // `claude --version` succeeded; credentials were already verified - // in-process by the gateway guard before any subprocess ran. - return true; } } diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/types.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/types.ts index 146d70fc..418b2bb6 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/types.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/types.ts @@ -1,8 +1,9 @@ export const PARENTS = ["claude", "codex"] as const; -export const PROVIDERS = ["claude", "codex", "grok", "deepseek", "minimax"] as const; // pstack-flex: gateway providers run the stock `claude` binary against a -// third-party Anthropic-compatible endpoint with injected environment. +// 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; From 53930c80c602e096b138ba976a5e7a0a2c18b45c Mon Sep 17 00:00:00 2001 From: Martin Patino Date: Sun, 27 Sep 2026 22:01:48 -0700 Subject: [PATCH 5/5] fix(runner): isolate gateway routing and reject model mismatches --- NOTICE.md | 2 +- README.md | 10 ++---- UPSTREAM-FLEX.md | 8 ++--- .../scripts/runner/flex-providers.test.ts | 15 +++++---- .../scripts/runner/flex-providers.ts | 17 +++++----- .../poteto-mode/scripts/runner/run.test.ts | 33 +++++++++++++++++-- .../skills/poteto-mode/scripts/runner/run.ts | 11 ++++--- 7 files changed, 62 insertions(+), 34 deletions(-) diff --git a/NOTICE.md b/NOTICE.md index b6e8238d..60e30343 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -4,7 +4,7 @@ This plugin is a port of upstream MIT-licensed work. All upstream copyright noti ## 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 additions (gateway providers, optional families, and their docs and tests) are (c) 2026 Martin Patino, MIT, and are inventoried in [UPSTREAM-FLEX.md](UPSTREAM-FLEX.md). +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 diff --git a/README.md b/README.md index 1675031c..d6ef60e8 100644 --- a/README.md +++ b/README.md @@ -14,15 +14,9 @@ Open Pstack is an unofficial community project that makes pstack work in Claude ## This fork: pstack-flex -**pstack-flex** is a friendly fork of [ericlitman/open-pstack](https://github.com/ericlitman/open-pstack) (fork point: v1.4.1). A stock install behaves exactly like upstream. On top of that it adds model freedom and cost control: +**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. -- **Optional model families.** Setup no longer requires all four frontier families. Assign the families you actually have; unassigned families get no effort question and no probe. -- **Gateway lanes for DeepSeek and MiniMax.** New `deepseek:*` and `minimax:*` descriptors run the stock `claude` binary against each lab's Anthropic-compatible endpoint with that lab's API key — no Anthropic account, no subscription, pay per token. See the flex model matrix in [provider-dispatch.md](plugins/pstack/skills/poteto-mode/references/provider-dispatch.md). -- **Zero-subscription mode.** The whole stack can run on nothing but `DEEPSEEK_API_KEY` and `MINIMAX_API_KEY`; two labs are two model families, so adversarial panels keep real diversity. Walkthrough in [docs/LANES.md](docs/LANES.md). -- **Credential isolation by construction.** Gateway lanes use an isolated `CLAUDE_CONFIG_DIR` and refuse to start if a claude.ai OAuth login is present there, so a claude.ai credential can never be sent to a third-party endpoint. -- **Honest cost reporting.** Gateway receipts force `costUsd` to null instead of reporting Anthropic-rate fiction; real prices (including DeepSeek's off-peak discount window) live in [docs/LANES.md](docs/LANES.md). - -What does not change: pstack's skills and playbooks, the four stock lanes and their defaults, the no-fallback and no-implicit-timeout contracts, and upstream's sync process. The flex delta is documented in [UPSTREAM-FLEX.md](UPSTREAM-FLEX.md). Anthropic does not support pointing Claude Code at non-Anthropic endpoints (unsupported is not prohibited); read the safety notes in [docs/LANES.md](docs/LANES.md) before enabling a gateway lane. +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 diff --git a/UPSTREAM-FLEX.md b/UPSTREAM-FLEX.md index fbe5cf43..52c8e9b6 100644 --- a/UPSTREAM-FLEX.md +++ b/UPSTREAM-FLEX.md @@ -22,9 +22,9 @@ All flex changes are additive and live in port-owned files so upstream merges st - `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 -- The "Flex model matrix" section and route-table columns in `references/provider-dispatch.md` -- The assignment-first restructure of `skills/setup-pstack/SKILL.md` -- `docs/LANES.md`, this file, the README fork section, and the NOTICE/LICENSE/CHANGES additions +- 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. @@ -43,4 +43,4 @@ Expected conflict surface on future upstream releases: - `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 the live lane checks in [docs/LANES.md](docs/LANES.md) before tagging. +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/flex-providers.test.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/flex-providers.test.ts index dc348d13..755b70d3 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/flex-providers.test.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/flex-providers.test.ts @@ -104,15 +104,18 @@ describe("gatewayEnvironment", () => { expect(values).not.toContain("do-not-copy"); }); - it("lists every injected credential-bearing variable as an inherited conflict", () => { - // Whatever the parent had for these must be deleted before injection. + it("lists every alternative Claude provider selector as an inherited conflict", () => { + const conflicts = new Set(GATEWAY_INHERITED_CONFLICTS); for (const key of [ - "ANTHROPIC_API_KEY", - "ANTHROPIC_AUTH_TOKEN", - "ANTHROPIC_BASE_URL", + "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(GATEWAY_INHERITED_CONFLICTS as readonly string[]).toContain(key); + expect(conflicts.has(key)).toBe(true); } }); }); diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/flex-providers.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/flex-providers.ts index 3c29d8d3..1a247860 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/flex-providers.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/flex-providers.ts @@ -36,16 +36,15 @@ export const GATEWAY_SPECS: Record = { }, }; -// A parent session's own Anthropic credentials, endpoint, or model pins must -// never bleed into a gateway child. These are deleted before injection. +// 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 = [ - "ANTHROPIC_API_KEY", - "ANTHROPIC_AUTH_TOKEN", - "ANTHROPIC_BASE_URL", - "ANTHROPIC_MODEL", - "ANTHROPIC_DEFAULT_OPUS_MODEL", - "ANTHROPIC_DEFAULT_SONNET_MODEL", - "ANTHROPIC_DEFAULT_HAIKU_MODEL", + "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", 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 6fbccee7..41e9a5f3 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/run.test.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/run.test.ts @@ -126,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"}})); @@ -924,9 +924,12 @@ describe("gateway lanes", () => { "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( @@ -990,6 +993,8 @@ describe("gateway lanes", () => { 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"); @@ -1005,6 +1010,8 @@ describe("gateway lanes", () => { 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(); }); @@ -1036,13 +1043,27 @@ describe("gateway lanes", () => { expect(written.reportedModel).toBe("minimax-m3"); }); - it("falls back to pinned argv when the endpoint reports another slug", async () => { + 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"); }); @@ -1064,6 +1085,10 @@ describe("childEnvironment", () => { 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", @@ -1072,6 +1097,10 @@ describe("childEnvironment", () => { 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"); diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/run.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/run.ts index 667dd0b7..b3e2ef03 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/run.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/run.ts @@ -147,6 +147,9 @@ export function childEnvironment( : [...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)); } @@ -478,12 +481,12 @@ function modelProof( modelEvidence: "pinned-argv", }; } - if (isGatewayProvider(provider)) { + if (isGatewayProvider(provider) && reported === null) { // Third-party Anthropic-compatible endpoints do not reliably echo the - // requested model slug; fall back to the pinned argv as evidence, the - // same posture Codex lanes already use. + // requested model slug. A reported mismatch is a failure, since some + // gateways silently substitute a default model for unknown slugs. return { - reportedModel: reported, + reportedModel: null, modelVerified: false, modelEvidence: "pinned-argv", };