Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/cli/src/commands/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ export async function start(args: StartArgs): Promise<void> {
console.error(`ERROR: ${creds.error}`);
process.exit(1);
}
if (creds.warning) {
console.warn(`WARNING: ${creds.warning}`);
}

// 3. Resolve paths
const repo = resolveRepo(args.repo);
Expand Down
42 changes: 41 additions & 1 deletion apps/cli/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export function buildEnvFlags(): string[] {
interface CredentialValidation {
valid: boolean;
error?: string;
warning?: string;
}

/**
Expand Down Expand Up @@ -118,5 +119,44 @@ export function validateCredentials(): CredentialValidation {
return { valid: false, error: 'Credentials for more than one provider are set.' };
}

return { valid: true };
return { valid: true, ...anthropicCredentialWarning(spec.providerId) };
}

/**
* De-silence the two ways an Anthropic credential surprises the user. Both are
* warnings, not errors: the run still starts, but the operator is told what will
* actually happen on the wire.
*
* - Both ANTHROPIC_API_KEY and CLAUDE_CODE_OAUTH_TOKEN set: they authenticate
* differently (x-api-key vs OAuth bearer) yet share the `anthropic` provider, so
* the "exactly one provider" gate never fires and the API key silently wins — a
* run the user believes is on their subscription quietly bills API credits.
* - A CLAUDE_CODE_OAUTH_TOKEN without the `sk-ant-oat` marker: pi recognises OAuth
* only by that marker, so anything else is sent as an x-api-key that
* api.anthropic.com rejects.
*/
function anthropicCredentialWarning(providerId: ProviderId): { warning?: string } {
if (providerId !== 'anthropic') return {};

const apiKey = process.env.ANTHROPIC_API_KEY;
const oauthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN;

if (apiKey && oauthToken) {
return {
warning:
'Both ANTHROPIC_API_KEY and CLAUDE_CODE_OAUTH_TOKEN are set. ANTHROPIC_API_KEY takes precedence, ' +
'so this scan bills API credits and the OAuth token is ignored. Unset ANTHROPIC_API_KEY to run on a ' +
'Claude Code subscription.',
};
}

if (oauthToken && !oauthToken.includes('sk-ant-oat')) {
return {
warning:
'CLAUDE_CODE_OAUTH_TOKEN does not contain the "sk-ant-oat" marker, so it is sent as an API key rather ' +
'than an OAuth token. Generate a Claude Code token with `claude setup-token`.',
};
}

return {};
}
51 changes: 51 additions & 0 deletions apps/worker/src/ai/extensions/oauth-prompt-shape/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* pi extension: keep Claude Code OAuth requests on included subscription billing.
*
* Anthropic's OAuth billing classifier inspects the request payload and decides
* from its shape which billing lane applies. pi prepends the Claude Code identity
* block correctly, but its own harness system prompt names the product "pi", and
* that name routes the request into the metered "extra usage" pool instead of the
* subscription's included quota. Once that pool is empty — most subscribers never
* fund it — every request fails with HTTP 429 "monthly spend limit".
*
* Isolated empirically against api.anthropic.com with a single credential,
* alternating variants in one run: replacing only `pi` flips every request from
* HTTP 429 to HTTP 200 and moves the response's anthropic-ratelimit-unified-reset
* to the subscription window. Replacing only "harness", or only the opening
* sentence, does not, and trimming the prompt does not either — the product name
* is the fingerprint and it has to go everywhere.
*
* This handler rewrites the harness system prompt for OAuth runs only. Shannon's
* own agent prompts are untouched: they travel as user messages, which the
* classifier does not appear to inspect.
*
* Reference: NousResearch/hermes-agent#72171 (root cause + bisection of the same
* class of bug in another harness).
*/

import type { BeforeAgentStartEvent, BeforeAgentStartEventResult, ExtensionAPI } from '@earendil-works/pi-coding-agent';

/** Product name the classifier fingerprints, and the identity pi already claims for OAuth. */
const HARNESS_NAME = /\bpi\b/gi;
const CLAUDE_CODE_NAME = 'Claude Code';

/**
* Whether this run authenticates with a Claude Code OAuth token. pi decides the
* OAuth wire path the same way — by the `sk-ant-oat` marker in the token — so this
* gate matches exactly the requests that carry the Claude Code identity headers.
*/
function isOAuthRun(): boolean {
const token = process.env.CLAUDE_CODE_OAUTH_TOKEN;
return typeof token === 'string' && token.includes('sk-ant-oat');
}

export default function oauthPromptShapeExtension(pi: ExtensionAPI): void {
pi.on('before_agent_start', (event: BeforeAgentStartEvent): BeforeAgentStartEventResult | undefined => {
if (!isOAuthRun()) return undefined;

const rewritten = event.systemPrompt.replace(HARNESS_NAME, CLAUDE_CODE_NAME);
if (rewritten === event.systemPrompt) return undefined;

return { systemPrompt: rewritten };
});
}
6 changes: 4 additions & 2 deletions apps/worker/src/ai/pi/pi-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
} from '@earendil-works/pi-coding-agent';
import { fs, path } from 'zx';
import type { AuditSession } from '../../audit/index.js';
import { BASH_TIMEOUT_EXTENSION_DIR, deliverablesDir } from '../../paths.js';
import { BASH_TIMEOUT_EXTENSION_DIR, deliverablesDir, OAUTH_PROMPT_SHAPE_EXTENSION_DIR } from '../../paths.js';
import { isRetryableFailure, PentestError } from '../../services/error-handling.js';
import { AGENT_VALIDATORS } from '../../session-manager.js';
import type { ActivityLogger } from '../../types/activity-logger.js';
Expand Down Expand Up @@ -76,7 +76,9 @@ async function buildResourceLoader(
agentName: string | null,
): Promise<ResourceLoader> {
// Always enforce bounded bash timeouts so an unbounded command cannot hang the agent.
const additionalExtensionPaths: string[] = [BASH_TIMEOUT_EXTENSION_DIR];
// The OAuth-prompt-shape extension self-gates on the token, so loading it always is a
// no-op for non-OAuth runs.
const additionalExtensionPaths: string[] = [BASH_TIMEOUT_EXTENSION_DIR, OAUTH_PROMPT_SHAPE_EXTENSION_DIR];
if (permissionSystemConfigExists(getAgentDir())) {
try {
additionalExtensionPaths.push(permissionSystemPackageDir());
Expand Down
8 changes: 8 additions & 0 deletions apps/worker/src/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ export const CONFIGS_DIR = path.join(WORKER_ROOT, 'configs');
/** Compiled pi extension dir that enforces bounded `bash` timeouts (resolved from dist/) */
export const BASH_TIMEOUT_EXTENSION_DIR = path.join(import.meta.dirname, 'ai', 'extensions', 'bash-timeout');

/** Compiled pi extension dir that keeps OAuth requests on subscription billing (resolved from dist/) */
export const OAUTH_PROMPT_SHAPE_EXTENSION_DIR = path.join(
import.meta.dirname,
'ai',
'extensions',
'oauth-prompt-shape',
);

/** Default deliverables subdirectory relative to repoPath */
export const DEFAULT_DELIVERABLES_SUBDIR = '.shannon/deliverables';

Expand Down