2026-02-18 15:29:24 -06:00
|
|
|
import fs from "node:fs/promises";
|
|
|
|
|
import os from "node:os";
|
|
|
|
|
import path from "node:path";
|
|
|
|
|
import { fileURLToPath } from "node:url";
|
2026-03-03 08:45:26 -06:00
|
|
|
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
|
|
|
|
import type { RunProcessResult } from "@paperclipai/adapter-utils/server-utils";
|
2026-02-18 13:53:03 -06:00
|
|
|
import {
|
|
|
|
|
asString,
|
|
|
|
|
asNumber,
|
|
|
|
|
asBoolean,
|
|
|
|
|
asStringArray,
|
|
|
|
|
parseObject,
|
|
|
|
|
parseJson,
|
|
|
|
|
buildPaperclipEnv,
|
2026-03-15 07:05:01 -05:00
|
|
|
readPaperclipRuntimeSkillEntries,
|
2026-03-13 08:49:11 -05:00
|
|
|
joinPromptSections,
|
2026-03-28 15:42:14 -05:00
|
|
|
buildInvocationEnvForLogs,
|
2026-02-18 13:53:03 -06:00
|
|
|
ensureAbsoluteDirectory,
|
|
|
|
|
ensureCommandResolvable,
|
|
|
|
|
ensurePathInEnv,
|
2026-03-28 15:42:14 -05:00
|
|
|
resolveCommandForLogs,
|
2026-02-18 13:53:03 -06:00
|
|
|
renderTemplate,
|
2026-03-28 09:55:41 -05:00
|
|
|
renderPaperclipWakePrompt,
|
|
|
|
|
stringifyPaperclipWakePayload,
|
2026-02-18 13:53:03 -06:00
|
|
|
runChildProcess,
|
2026-03-03 08:45:26 -06:00
|
|
|
} from "@paperclipai/adapter-utils/server-utils";
|
2026-02-23 14:40:44 -06:00
|
|
|
import {
|
|
|
|
|
parseClaudeStreamJson,
|
|
|
|
|
describeClaudeFailure,
|
|
|
|
|
detectClaudeLoginRequired,
|
2026-02-26 16:33:10 -06:00
|
|
|
isClaudeMaxTurnsResult,
|
2026-02-23 14:40:44 -06:00
|
|
|
isClaudeUnknownSessionError,
|
|
|
|
|
} from "./parse.js";
|
2026-03-13 22:49:42 -05:00
|
|
|
import { resolveClaudeDesiredSkillNames } from "./skills.js";
|
2026-04-07 23:05:49 +09:00
|
|
|
import { isBedrockModelId } from "./models.js";
|
2026-02-18 13:53:03 -06:00
|
|
|
|
2026-03-03 16:06:12 -06:00
|
|
|
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
2026-02-18 15:29:24 -06:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create a tmpdir with `.claude/skills/` containing symlinks to skills from
|
|
|
|
|
* the repo's `skills/` directory, so `--add-dir` makes Claude Code discover
|
|
|
|
|
* them as proper registered skills.
|
|
|
|
|
*/
|
2026-03-13 22:49:42 -05:00
|
|
|
async function buildSkillsDir(config: Record<string, unknown>): Promise<string> {
|
2026-02-18 15:29:24 -06:00
|
|
|
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skills-"));
|
|
|
|
|
const target = path.join(tmp, ".claude", "skills");
|
|
|
|
|
await fs.mkdir(target, { recursive: true });
|
2026-03-15 07:05:01 -05:00
|
|
|
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
2026-03-13 22:49:42 -05:00
|
|
|
const desiredNames = new Set(
|
|
|
|
|
resolveClaudeDesiredSkillNames(
|
|
|
|
|
config,
|
2026-03-15 07:05:01 -05:00
|
|
|
availableEntries,
|
2026-03-13 22:49:42 -05:00
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
for (const entry of availableEntries) {
|
2026-03-16 18:27:20 -05:00
|
|
|
if (!desiredNames.has(entry.key)) continue;
|
2026-03-13 22:49:42 -05:00
|
|
|
await fs.symlink(
|
|
|
|
|
entry.source,
|
2026-03-16 18:27:20 -05:00
|
|
|
path.join(target, entry.runtimeName),
|
2026-03-13 22:49:42 -05:00
|
|
|
);
|
2026-02-18 15:29:24 -06:00
|
|
|
}
|
|
|
|
|
return tmp;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 14:40:44 -06:00
|
|
|
interface ClaudeExecutionInput {
|
|
|
|
|
runId: string;
|
|
|
|
|
agent: AdapterExecutionContext["agent"];
|
|
|
|
|
config: Record<string, unknown>;
|
|
|
|
|
context: Record<string, unknown>;
|
|
|
|
|
authToken?: string;
|
|
|
|
|
}
|
2026-02-18 13:53:03 -06:00
|
|
|
|
2026-02-23 14:40:44 -06:00
|
|
|
interface ClaudeRuntimeConfig {
|
|
|
|
|
command: string;
|
2026-03-28 15:42:14 -05:00
|
|
|
resolvedCommand: string;
|
2026-02-23 14:40:44 -06:00
|
|
|
cwd: string;
|
2026-02-25 08:38:58 -06:00
|
|
|
workspaceId: string | null;
|
|
|
|
|
workspaceRepoUrl: string | null;
|
|
|
|
|
workspaceRepoRef: string | null;
|
2026-02-23 14:40:44 -06:00
|
|
|
env: Record<string, string>;
|
2026-03-28 15:42:14 -05:00
|
|
|
loggedEnv: Record<string, string>;
|
2026-02-23 14:40:44 -06:00
|
|
|
timeoutSec: number;
|
|
|
|
|
graceSec: number;
|
|
|
|
|
extraArgs: string[];
|
|
|
|
|
}
|
2026-02-18 13:53:03 -06:00
|
|
|
|
2026-02-23 14:40:44 -06:00
|
|
|
function buildLoginResult(input: {
|
|
|
|
|
proc: RunProcessResult;
|
|
|
|
|
loginUrl: string | null;
|
|
|
|
|
}) {
|
|
|
|
|
return {
|
|
|
|
|
exitCode: input.proc.exitCode,
|
|
|
|
|
signal: input.proc.signal,
|
|
|
|
|
timedOut: input.proc.timedOut,
|
|
|
|
|
stdout: input.proc.stdout,
|
|
|
|
|
stderr: input.proc.stderr,
|
|
|
|
|
loginUrl: input.loginUrl,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-25 21:35:44 -06:00
|
|
|
function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
|
|
|
|
|
const raw = env[key];
|
|
|
|
|
return typeof raw === "string" && raw.trim().length > 0;
|
|
|
|
|
}
|
|
|
|
|
|
feat: add AWS Bedrock auth support on "claude-local" (#2793)
Closes #2412
Related: #2681, #498, #128
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies
> - The Claude Code adapter spawns the `claude` CLI to run agent tasks
> - The adapter detects auth mode by checking for `ANTHROPIC_API_KEY` —
recognizing only "api" and "subscription" modes
> - But users running Claude Code via **AWS Bedrock**
(`CLAUDE_CODE_USE_BEDROCK=1`) fall through to the "subscription" path
> - This causes a misleading "ANTHROPIC_API_KEY is not set;
subscription-based auth can be used" message in the environment check
> - Additionally, the hello probe passes `--model claude-opus-4-6` which
is **not a valid Bedrock model identifier**, causing `400 The provided
model identifier is invalid` and a probe failure
> - This pull request adds Bedrock auth detection, skips the
Anthropic-style `--model` flag for Bedrock, and returns the correct
billing type
> - The benefit is that Bedrock users get a working environment check
and correct cost tracking out of the box
---
## Pain Point
Many enterprise teams use **Claude Code through AWS Bedrock** rather
than Anthropic's direct API — for compliance, billing consolidation, or
VPC requirements. Currently, these users hit a **hard wall during
onboarding**:
| Problem | Impact |
|---|---|
| :x: Adapter environment check **always fails** | Users cannot create
their first agent — blocked at step 1 |
| :x: `--model claude-opus-4-6` is **invalid on Bedrock** (requires
`us.anthropic.*` format) | Hello probe exits with code 1: `400 The
provided model identifier is invalid` |
| :x: Auth shown as _"subscription-based"_ | Misleading — Bedrock is
neither subscription nor API-key auth |
| :x: Quota polling hits Anthropic OAuth endpoint | Fails silently for
Bedrock users who have no Anthropic subscription |
> **Bottom line**: Paperclip is completely unusable for Bedrock users
out of the box.
## Why Bedrock Matters
AWS Bedrock is a major deployment path for Claude in enterprise
environments:
- **Enterprise compliance** — data stays within the customer's AWS
account and VPC
- **Unified billing** — Claude usage appears on the existing AWS
invoice, no separate Anthropic billing
- **IAM integration** — access controlled through AWS IAM roles and
policies
- **Regional deployment** — models run in the customer's preferred AWS
region
Supporting Bedrock unlocks Paperclip for organizations that **cannot**
use Anthropic's direct API due to procurement, security, or regulatory
constraints.
---
## What Changed
- **`execute.ts`**: Added `isBedrockAuth()` helper that checks
`CLAUDE_CODE_USE_BEDROCK` and `ANTHROPIC_BEDROCK_BASE_URL` env vars.
`resolveClaudeBillingType()` now returns `"metered_api"` for Bedrock.
Biller set to `"aws_bedrock"`. Skips `--model` flag when Bedrock is
active (Anthropic-style model IDs are invalid on Bedrock; the CLI uses
its own configured model).
- **`test.ts`**: Environment check now detects Bedrock env vars (from
adapter config or server env) and shows `"AWS Bedrock auth detected.
Claude will use Bedrock for inference."` instead of the misleading
subscription message. Also skips `--model` in the hello probe for
Bedrock.
- **`quota.ts`**: Early return with `{ ok: true, windows: [] }` when
Bedrock is active — Bedrock usage is billed through AWS, not Anthropic's
subscription quota system.
- **`ui/src/lib/utils.ts`**: Added `"aws_bedrock"` → `"AWS Bedrock"` to
`providerDisplayName()` and `quotaSourceDisplayName()`.
## Verification
1. `pnpm -r typecheck` — all packages pass
2. Unit tests added and passing (6/6)
3. Environment check with Bedrock env vars:
| | Before | After |
|---|---|---|
| **Status** | :red_circle: Failed | :white_check_mark: Passed |
| **Auth message** | `ANTHROPIC_API_KEY is not set; subscription-based
auth can be used if Claude is logged in.` | `AWS Bedrock auth detected.
Claude will use Bedrock for inference.` |
| **Hello probe** | `ERROR · Claude hello probe failed.` (exit code 1 —
`--model claude-opus-4-6` is invalid on Bedrock) | `INFO · Claude hello
probe succeeded.` |
| **Screenshot** | <img height="500" alt="Screenshot 2026-04-05 at 8 25
27 AM"
src="https://github.com/user-attachments/assets/476431f6-6139-425a-8abc-97875d653657"
/> | <img height="500" alt="Screenshot 2026-04-05 at 8 31 58 AM"
src="https://github.com/user-attachments/assets/d388ce87-c5e6-4574-b8d2-fd8b86135299"
/> |
4. Existing API key / subscription paths are completely untouched unless
Bedrock env vars are present
## Risks
- **Low risk.** All changes are additive — existing "api" and
"subscription" code paths are only entered when Bedrock env vars are
absent.
- When Bedrock is active, the `--model` flag is skipped, so the
Paperclip model dropdown selection is ignored in favor of the Claude
CLI's own model config. This is intentional since Bedrock requires
different model identifiers.
## Model Used
- Claude Opus 4.6 (`claude-opus-4-6`, 1M context window) via Claude Code
CLI
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-07 05:15:18 +09:00
|
|
|
function isBedrockAuth(env: Record<string, string>): boolean {
|
|
|
|
|
return (
|
|
|
|
|
env.CLAUDE_CODE_USE_BEDROCK === "1" ||
|
|
|
|
|
env.CLAUDE_CODE_USE_BEDROCK === "true" ||
|
|
|
|
|
hasNonEmptyEnvValue(env, "ANTHROPIC_BEDROCK_BASE_URL")
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resolveClaudeBillingType(env: Record<string, string>): "api" | "subscription" | "metered_api" {
|
|
|
|
|
if (isBedrockAuth(env)) return "metered_api";
|
2026-02-25 21:35:44 -06:00
|
|
|
return hasNonEmptyEnvValue(env, "ANTHROPIC_API_KEY") ? "api" : "subscription";
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 14:40:44 -06:00
|
|
|
async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<ClaudeRuntimeConfig> {
|
|
|
|
|
const { runId, agent, config, context, authToken } = input;
|
|
|
|
|
|
|
|
|
|
const command = asString(config.command, "claude");
|
2026-02-25 08:38:58 -06:00
|
|
|
const workspaceContext = parseObject(context.paperclipWorkspace);
|
|
|
|
|
const workspaceCwd = asString(workspaceContext.cwd, "");
|
|
|
|
|
const workspaceSource = asString(workspaceContext.source, "");
|
2026-03-10 10:58:38 -05:00
|
|
|
const workspaceStrategy = asString(workspaceContext.strategy, "");
|
2026-02-25 08:38:58 -06:00
|
|
|
const workspaceId = asString(workspaceContext.workspaceId, "") || null;
|
|
|
|
|
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "") || null;
|
|
|
|
|
const workspaceRepoRef = asString(workspaceContext.repoRef, "") || null;
|
2026-03-10 10:58:38 -05:00
|
|
|
const workspaceBranch = asString(workspaceContext.branchName, "") || null;
|
|
|
|
|
const workspaceWorktreePath = asString(workspaceContext.worktreePath, "") || null;
|
2026-03-14 00:36:53 -07:00
|
|
|
const agentHome = asString(workspaceContext.agentHome, "") || null;
|
2026-02-25 21:35:44 -06:00
|
|
|
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
|
|
|
|
|
? context.paperclipWorkspaces.filter(
|
|
|
|
|
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
|
|
|
|
)
|
|
|
|
|
: [];
|
2026-03-10 10:58:38 -05:00
|
|
|
const runtimeServiceIntents = Array.isArray(context.paperclipRuntimeServiceIntents)
|
|
|
|
|
? context.paperclipRuntimeServiceIntents.filter(
|
|
|
|
|
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
|
|
|
|
)
|
|
|
|
|
: [];
|
|
|
|
|
const runtimeServices = Array.isArray(context.paperclipRuntimeServices)
|
|
|
|
|
? context.paperclipRuntimeServices.filter(
|
|
|
|
|
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
|
|
|
|
)
|
|
|
|
|
: [];
|
|
|
|
|
const runtimePrimaryUrl = asString(context.paperclipRuntimePrimaryUrl, "");
|
2026-02-25 21:35:44 -06:00
|
|
|
const configuredCwd = asString(config.cwd, "");
|
|
|
|
|
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
|
|
|
|
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
|
|
|
|
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
|
2026-03-03 12:29:32 -06:00
|
|
|
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
2026-02-23 14:40:44 -06:00
|
|
|
|
2026-02-18 13:53:03 -06:00
|
|
|
const envConfig = parseObject(config.env);
|
2026-02-18 16:46:45 -06:00
|
|
|
const hasExplicitApiKey =
|
|
|
|
|
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
|
2026-02-18 13:53:03 -06:00
|
|
|
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
|
2026-02-19 09:09:50 -06:00
|
|
|
env.PAPERCLIP_RUN_ID = runId;
|
2026-02-23 14:40:44 -06:00
|
|
|
|
2026-02-19 09:09:50 -06:00
|
|
|
const wakeTaskId =
|
|
|
|
|
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
|
|
|
|
|
(typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) ||
|
|
|
|
|
null;
|
|
|
|
|
const wakeReason =
|
|
|
|
|
typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0
|
|
|
|
|
? context.wakeReason.trim()
|
|
|
|
|
: null;
|
2026-02-20 10:32:07 -06:00
|
|
|
const wakeCommentId =
|
|
|
|
|
(typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) ||
|
|
|
|
|
(typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) ||
|
|
|
|
|
null;
|
2026-02-19 13:02:53 -06:00
|
|
|
const approvalId =
|
|
|
|
|
typeof context.approvalId === "string" && context.approvalId.trim().length > 0
|
|
|
|
|
? context.approvalId.trim()
|
|
|
|
|
: null;
|
|
|
|
|
const approvalStatus =
|
|
|
|
|
typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0
|
|
|
|
|
? context.approvalStatus.trim()
|
|
|
|
|
: null;
|
|
|
|
|
const linkedIssueIds = Array.isArray(context.issueIds)
|
|
|
|
|
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
|
|
|
|
: [];
|
2026-03-28 09:55:41 -05:00
|
|
|
const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake);
|
2026-02-23 14:40:44 -06:00
|
|
|
|
2026-02-19 09:09:50 -06:00
|
|
|
if (wakeTaskId) {
|
|
|
|
|
env.PAPERCLIP_TASK_ID = wakeTaskId;
|
|
|
|
|
}
|
|
|
|
|
if (wakeReason) {
|
|
|
|
|
env.PAPERCLIP_WAKE_REASON = wakeReason;
|
|
|
|
|
}
|
2026-02-20 10:32:07 -06:00
|
|
|
if (wakeCommentId) {
|
|
|
|
|
env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
|
|
|
|
|
}
|
2026-02-19 13:02:53 -06:00
|
|
|
if (approvalId) {
|
|
|
|
|
env.PAPERCLIP_APPROVAL_ID = approvalId;
|
|
|
|
|
}
|
|
|
|
|
if (approvalStatus) {
|
|
|
|
|
env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
|
|
|
|
|
}
|
|
|
|
|
if (linkedIssueIds.length > 0) {
|
|
|
|
|
env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
|
|
|
|
}
|
2026-03-28 09:55:41 -05:00
|
|
|
if (wakePayloadJson) {
|
|
|
|
|
env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
|
|
|
|
|
}
|
2026-02-25 21:35:44 -06:00
|
|
|
if (effectiveWorkspaceCwd) {
|
|
|
|
|
env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd;
|
2026-02-25 08:38:58 -06:00
|
|
|
}
|
|
|
|
|
if (workspaceSource) {
|
|
|
|
|
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
|
|
|
|
}
|
2026-03-10 10:58:38 -05:00
|
|
|
if (workspaceStrategy) {
|
|
|
|
|
env.PAPERCLIP_WORKSPACE_STRATEGY = workspaceStrategy;
|
|
|
|
|
}
|
2026-02-25 08:38:58 -06:00
|
|
|
if (workspaceId) {
|
|
|
|
|
env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
|
|
|
|
}
|
|
|
|
|
if (workspaceRepoUrl) {
|
|
|
|
|
env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl;
|
|
|
|
|
}
|
|
|
|
|
if (workspaceRepoRef) {
|
|
|
|
|
env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
|
|
|
|
}
|
2026-03-10 10:58:38 -05:00
|
|
|
if (workspaceBranch) {
|
|
|
|
|
env.PAPERCLIP_WORKSPACE_BRANCH = workspaceBranch;
|
|
|
|
|
}
|
|
|
|
|
if (workspaceWorktreePath) {
|
|
|
|
|
env.PAPERCLIP_WORKSPACE_WORKTREE_PATH = workspaceWorktreePath;
|
|
|
|
|
}
|
2026-03-14 00:36:53 -07:00
|
|
|
if (agentHome) {
|
|
|
|
|
env.AGENT_HOME = agentHome;
|
|
|
|
|
}
|
2026-02-25 21:35:44 -06:00
|
|
|
if (workspaceHints.length > 0) {
|
|
|
|
|
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
|
|
|
|
}
|
2026-03-10 10:58:38 -05:00
|
|
|
if (runtimeServiceIntents.length > 0) {
|
|
|
|
|
env.PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents);
|
|
|
|
|
}
|
|
|
|
|
if (runtimeServices.length > 0) {
|
|
|
|
|
env.PAPERCLIP_RUNTIME_SERVICES_JSON = JSON.stringify(runtimeServices);
|
|
|
|
|
}
|
|
|
|
|
if (runtimePrimaryUrl) {
|
|
|
|
|
env.PAPERCLIP_RUNTIME_PRIMARY_URL = runtimePrimaryUrl;
|
|
|
|
|
}
|
2026-02-23 14:40:44 -06:00
|
|
|
|
|
|
|
|
for (const [key, value] of Object.entries(envConfig)) {
|
|
|
|
|
if (typeof value === "string") env[key] = value;
|
2026-02-18 13:53:03 -06:00
|
|
|
}
|
2026-02-23 14:40:44 -06:00
|
|
|
|
2026-02-18 16:46:45 -06:00
|
|
|
if (!hasExplicitApiKey && authToken) {
|
|
|
|
|
env.PAPERCLIP_API_KEY = authToken;
|
|
|
|
|
}
|
2026-02-23 14:40:44 -06:00
|
|
|
|
2026-02-18 13:53:03 -06:00
|
|
|
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
|
|
|
|
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
2026-03-28 15:42:14 -05:00
|
|
|
const resolvedCommand = await resolveCommandForLogs(command, cwd, runtimeEnv);
|
|
|
|
|
const loggedEnv = buildInvocationEnvForLogs(env, {
|
|
|
|
|
runtimeEnv,
|
|
|
|
|
includeRuntimeKeys: ["HOME", "CLAUDE_CONFIG_DIR"],
|
|
|
|
|
resolvedCommand,
|
|
|
|
|
});
|
2026-02-18 13:53:03 -06:00
|
|
|
|
2026-02-20 10:32:07 -06:00
|
|
|
const timeoutSec = asNumber(config.timeoutSec, 0);
|
2026-02-18 13:53:03 -06:00
|
|
|
const graceSec = asNumber(config.graceSec, 20);
|
|
|
|
|
const extraArgs = (() => {
|
|
|
|
|
const fromExtraArgs = asStringArray(config.extraArgs);
|
|
|
|
|
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
|
|
|
|
return asStringArray(config.args);
|
|
|
|
|
})();
|
2026-02-23 14:40:44 -06:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
command,
|
2026-03-28 15:42:14 -05:00
|
|
|
resolvedCommand,
|
2026-02-23 14:40:44 -06:00
|
|
|
cwd,
|
2026-02-25 08:38:58 -06:00
|
|
|
workspaceId,
|
|
|
|
|
workspaceRepoUrl,
|
|
|
|
|
workspaceRepoRef,
|
2026-02-23 14:40:44 -06:00
|
|
|
env,
|
2026-03-28 15:42:14 -05:00
|
|
|
loggedEnv,
|
2026-02-23 14:40:44 -06:00
|
|
|
timeoutSec,
|
|
|
|
|
graceSec,
|
|
|
|
|
extraArgs,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function runClaudeLogin(input: {
|
|
|
|
|
runId: string;
|
|
|
|
|
agent: AdapterExecutionContext["agent"];
|
|
|
|
|
config: Record<string, unknown>;
|
|
|
|
|
context?: Record<string, unknown>;
|
|
|
|
|
authToken?: string;
|
|
|
|
|
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
|
|
|
|
}) {
|
|
|
|
|
const onLog = input.onLog ?? (async () => {});
|
|
|
|
|
const runtime = await buildClaudeRuntimeConfig({
|
|
|
|
|
runId: input.runId,
|
|
|
|
|
agent: input.agent,
|
|
|
|
|
config: input.config,
|
|
|
|
|
context: input.context ?? {},
|
|
|
|
|
authToken: input.authToken,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const proc = await runChildProcess(input.runId, runtime.command, ["login"], {
|
|
|
|
|
cwd: runtime.cwd,
|
|
|
|
|
env: runtime.env,
|
|
|
|
|
timeoutSec: runtime.timeoutSec,
|
|
|
|
|
graceSec: runtime.graceSec,
|
|
|
|
|
onLog,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const loginMeta = detectClaudeLoginRequired({
|
|
|
|
|
parsed: null,
|
|
|
|
|
stdout: proc.stdout,
|
|
|
|
|
stderr: proc.stderr,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return buildLoginResult({
|
|
|
|
|
proc,
|
|
|
|
|
loginUrl: loginMeta.loginUrl,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
2026-03-19 11:20:36 -05:00
|
|
|
const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx;
|
2026-02-23 14:40:44 -06:00
|
|
|
|
|
|
|
|
const promptTemplate = asString(
|
|
|
|
|
config.promptTemplate,
|
|
|
|
|
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
|
|
|
|
|
);
|
|
|
|
|
const model = asString(config.model, "");
|
|
|
|
|
const effort = asString(config.effort, "");
|
2026-02-26 16:33:10 -06:00
|
|
|
const chrome = asBoolean(config.chrome, false);
|
2026-02-23 14:40:44 -06:00
|
|
|
const maxTurns = asNumber(config.maxTurnsPerRun, 0);
|
2026-03-31 20:21:13 +01:00
|
|
|
const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, true);
|
2026-02-26 16:34:15 -06:00
|
|
|
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
|
|
|
|
const instructionsFileDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
|
2026-02-23 14:40:44 -06:00
|
|
|
const runtimeConfig = await buildClaudeRuntimeConfig({
|
|
|
|
|
runId,
|
|
|
|
|
agent,
|
|
|
|
|
config,
|
|
|
|
|
context,
|
|
|
|
|
authToken,
|
|
|
|
|
});
|
2026-02-25 08:38:58 -06:00
|
|
|
const {
|
|
|
|
|
command,
|
2026-03-28 15:42:14 -05:00
|
|
|
resolvedCommand,
|
2026-02-25 08:38:58 -06:00
|
|
|
cwd,
|
|
|
|
|
workspaceId,
|
|
|
|
|
workspaceRepoUrl,
|
|
|
|
|
workspaceRepoRef,
|
|
|
|
|
env,
|
2026-03-28 15:42:14 -05:00
|
|
|
loggedEnv,
|
2026-02-25 08:38:58 -06:00
|
|
|
timeoutSec,
|
|
|
|
|
graceSec,
|
|
|
|
|
extraArgs,
|
|
|
|
|
} = runtimeConfig;
|
2026-03-14 22:00:12 -05:00
|
|
|
const effectiveEnv = Object.fromEntries(
|
|
|
|
|
Object.entries({ ...process.env, ...env }).filter(
|
|
|
|
|
(entry): entry is [string, string] => typeof entry[1] === "string",
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
const billingType = resolveClaudeBillingType(effectiveEnv);
|
2026-03-13 22:49:42 -05:00
|
|
|
const skillsDir = await buildSkillsDir(config);
|
2026-02-18 13:53:03 -06:00
|
|
|
|
2026-04-07 22:16:00 +01:00
|
|
|
const runtimeSessionParams = parseObject(runtime.sessionParams);
|
|
|
|
|
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
|
|
|
|
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
|
|
|
|
const canResumeSession =
|
|
|
|
|
runtimeSessionId.length > 0 &&
|
|
|
|
|
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
|
|
|
|
|
const sessionId = canResumeSession ? runtimeSessionId : null;
|
|
|
|
|
if (runtimeSessionId && !canResumeSession) {
|
|
|
|
|
await onLog(
|
|
|
|
|
"stdout",
|
|
|
|
|
`[paperclip] Claude session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-08 06:52:35 -05:00
|
|
|
let effectiveInstructionsFilePath: string | undefined;
|
|
|
|
|
let preparedInstructionsFile = false;
|
|
|
|
|
|
|
|
|
|
const ensureEffectiveInstructionsFilePath = async (resumeSessionId: string | null) => {
|
|
|
|
|
if (resumeSessionId || !instructionsFilePath) return undefined;
|
|
|
|
|
if (preparedInstructionsFile) return effectiveInstructionsFilePath;
|
|
|
|
|
|
|
|
|
|
preparedInstructionsFile = true;
|
2026-03-10 16:46:48 -07:00
|
|
|
try {
|
|
|
|
|
const instructionsContent = await fs.readFile(instructionsFilePath, "utf-8");
|
2026-04-10 14:22:48 +02:00
|
|
|
const pathDirective =
|
|
|
|
|
`\nThe above agent instructions were loaded from ${instructionsFilePath}. ` +
|
|
|
|
|
`Resolve any relative file references from ${instructionsFileDir}. ` +
|
|
|
|
|
`This base directory is authoritative for sibling instruction files such as ` +
|
|
|
|
|
`./HEARTBEAT.md, ./SOUL.md, and ./TOOLS.md; do not resolve those from the parent agent directory.`;
|
2026-03-10 16:46:48 -07:00
|
|
|
const combinedPath = path.join(skillsDir, "agent-instructions.md");
|
|
|
|
|
await fs.writeFile(combinedPath, instructionsContent + pathDirective, "utf-8");
|
|
|
|
|
effectiveInstructionsFilePath = combinedPath;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
const reason = err instanceof Error ? err.message : String(err);
|
|
|
|
|
await onLog(
|
|
|
|
|
"stderr",
|
|
|
|
|
`[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`,
|
|
|
|
|
);
|
|
|
|
|
effectiveInstructionsFilePath = undefined;
|
|
|
|
|
}
|
2026-03-02 16:08:55 -06:00
|
|
|
|
2026-04-08 06:52:35 -05:00
|
|
|
return effectiveInstructionsFilePath;
|
|
|
|
|
};
|
2026-03-13 08:49:11 -05:00
|
|
|
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
|
|
|
|
|
const templateData = {
|
2026-02-19 14:39:37 -06:00
|
|
|
agentId: agent.id,
|
|
|
|
|
companyId: agent.companyId,
|
|
|
|
|
runId,
|
2026-02-18 13:53:03 -06:00
|
|
|
company: { id: agent.companyId },
|
|
|
|
|
agent,
|
|
|
|
|
run: { id: runId, source: "on_demand" },
|
|
|
|
|
context,
|
2026-03-13 08:49:11 -05:00
|
|
|
};
|
|
|
|
|
const renderedBootstrapPrompt =
|
|
|
|
|
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
|
|
|
|
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
|
|
|
|
: "";
|
2026-03-28 10:33:40 -05:00
|
|
|
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) });
|
|
|
|
|
const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0;
|
|
|
|
|
const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData);
|
2026-03-13 08:49:11 -05:00
|
|
|
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
|
|
|
|
const prompt = joinPromptSections([
|
|
|
|
|
renderedBootstrapPrompt,
|
2026-03-28 09:55:41 -05:00
|
|
|
wakePrompt,
|
2026-03-13 08:49:11 -05:00
|
|
|
sessionHandoffNote,
|
|
|
|
|
renderedPrompt,
|
|
|
|
|
]);
|
|
|
|
|
const promptMetrics = {
|
|
|
|
|
promptChars: prompt.length,
|
|
|
|
|
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
2026-03-28 09:55:41 -05:00
|
|
|
wakePromptChars: wakePrompt.length,
|
2026-03-13 08:49:11 -05:00
|
|
|
sessionHandoffChars: sessionHandoffNote.length,
|
|
|
|
|
heartbeatPromptChars: renderedPrompt.length,
|
|
|
|
|
};
|
2026-02-18 13:53:03 -06:00
|
|
|
|
2026-04-08 06:52:35 -05:00
|
|
|
const buildClaudeArgs = (
|
|
|
|
|
resumeSessionId: string | null,
|
|
|
|
|
attemptInstructionsFilePath: string | undefined,
|
|
|
|
|
) => {
|
2026-02-18 15:29:24 -06:00
|
|
|
const args = ["--print", "-", "--output-format", "stream-json", "--verbose"];
|
2026-02-18 13:53:03 -06:00
|
|
|
if (resumeSessionId) args.push("--resume", resumeSessionId);
|
|
|
|
|
if (dangerouslySkipPermissions) args.push("--dangerously-skip-permissions");
|
2026-02-26 16:33:10 -06:00
|
|
|
if (chrome) args.push("--chrome");
|
2026-04-07 23:05:49 +09:00
|
|
|
// For Bedrock: only pass --model when the ID is a Bedrock-native identifier
|
|
|
|
|
// (e.g. "us.anthropic.*" or ARN). Anthropic-style IDs like "claude-opus-4-6" are invalid
|
|
|
|
|
// on Bedrock, so skip them and let the CLI use its own configured model.
|
|
|
|
|
if (model && (!isBedrockAuth(effectiveEnv) || isBedrockModelId(model))) {
|
|
|
|
|
args.push("--model", model);
|
|
|
|
|
}
|
2026-02-20 10:32:07 -06:00
|
|
|
if (effort) args.push("--effort", effort);
|
2026-02-18 13:53:03 -06:00
|
|
|
if (maxTurns > 0) args.push("--max-turns", String(maxTurns));
|
2026-04-06 16:11:39 +01:00
|
|
|
// On resumed sessions the instructions are already in the session cache;
|
|
|
|
|
// re-injecting them via --append-system-prompt-file wastes 5-10K tokens
|
|
|
|
|
// per heartbeat and the Claude CLI may reject the combination outright.
|
2026-04-08 06:52:35 -05:00
|
|
|
if (attemptInstructionsFilePath && !resumeSessionId) {
|
|
|
|
|
args.push("--append-system-prompt-file", attemptInstructionsFilePath);
|
2026-02-26 16:34:15 -06:00
|
|
|
}
|
2026-02-18 15:29:24 -06:00
|
|
|
args.push("--add-dir", skillsDir);
|
2026-02-18 13:53:03 -06:00
|
|
|
if (extraArgs.length > 0) args.push(...extraArgs);
|
|
|
|
|
return args;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const parseFallbackErrorMessage = (proc: RunProcessResult) => {
|
|
|
|
|
const stderrLine =
|
|
|
|
|
proc.stderr
|
|
|
|
|
.split(/\r?\n/)
|
|
|
|
|
.map((line) => line.trim())
|
|
|
|
|
.find(Boolean) ?? "";
|
|
|
|
|
|
|
|
|
|
if ((proc.exitCode ?? 0) === 0) {
|
|
|
|
|
return "Failed to parse claude JSON output";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return stderrLine
|
|
|
|
|
? `Claude exited with code ${proc.exitCode ?? -1}: ${stderrLine}`
|
|
|
|
|
: `Claude exited with code ${proc.exitCode ?? -1}`;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const runAttempt = async (resumeSessionId: string | null) => {
|
2026-04-08 06:52:35 -05:00
|
|
|
const attemptInstructionsFilePath = await ensureEffectiveInstructionsFilePath(resumeSessionId);
|
|
|
|
|
const args = buildClaudeArgs(resumeSessionId, attemptInstructionsFilePath);
|
|
|
|
|
const commandNotes =
|
|
|
|
|
attemptInstructionsFilePath && !resumeSessionId
|
|
|
|
|
? [
|
|
|
|
|
`Injected agent instructions via --append-system-prompt-file ${instructionsFilePath} (with path directive appended)`,
|
|
|
|
|
]
|
|
|
|
|
: [];
|
2026-02-18 13:53:03 -06:00
|
|
|
if (onMeta) {
|
|
|
|
|
await onMeta({
|
|
|
|
|
adapterType: "claude_local",
|
2026-03-28 15:42:14 -05:00
|
|
|
command: resolvedCommand,
|
2026-02-18 13:53:03 -06:00
|
|
|
cwd,
|
2026-02-18 15:29:24 -06:00
|
|
|
commandArgs: args,
|
2026-03-02 16:43:59 -06:00
|
|
|
commandNotes,
|
2026-03-28 15:42:14 -05:00
|
|
|
env: loggedEnv,
|
2026-02-18 13:53:03 -06:00
|
|
|
prompt,
|
2026-03-13 08:49:11 -05:00
|
|
|
promptMetrics,
|
2026-02-18 13:53:03 -06:00
|
|
|
context,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const proc = await runChildProcess(runId, command, args, {
|
|
|
|
|
cwd,
|
|
|
|
|
env,
|
2026-02-18 15:29:24 -06:00
|
|
|
stdin: prompt,
|
2026-02-18 13:53:03 -06:00
|
|
|
timeoutSec,
|
|
|
|
|
graceSec,
|
2026-03-19 11:20:36 -05:00
|
|
|
onSpawn,
|
2026-02-18 13:53:03 -06:00
|
|
|
onLog,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const parsedStream = parseClaudeStreamJson(proc.stdout);
|
|
|
|
|
const parsed = parsedStream.resultJson ?? parseJson(proc.stdout);
|
|
|
|
|
return { proc, parsedStream, parsed };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const toAdapterResult = (
|
|
|
|
|
attempt: {
|
|
|
|
|
proc: RunProcessResult;
|
|
|
|
|
parsedStream: ReturnType<typeof parseClaudeStreamJson>;
|
|
|
|
|
parsed: Record<string, unknown> | null;
|
|
|
|
|
},
|
|
|
|
|
opts: { fallbackSessionId: string | null; clearSessionOnMissingSession?: boolean },
|
|
|
|
|
): AdapterExecutionResult => {
|
|
|
|
|
const { proc, parsedStream, parsed } = attempt;
|
2026-02-23 14:40:44 -06:00
|
|
|
const loginMeta = detectClaudeLoginRequired({
|
|
|
|
|
parsed,
|
|
|
|
|
stdout: proc.stdout,
|
|
|
|
|
stderr: proc.stderr,
|
|
|
|
|
});
|
|
|
|
|
const errorMeta =
|
|
|
|
|
loginMeta.loginUrl != null
|
|
|
|
|
? {
|
|
|
|
|
loginUrl: loginMeta.loginUrl,
|
|
|
|
|
}
|
|
|
|
|
: undefined;
|
|
|
|
|
|
2026-02-18 13:53:03 -06:00
|
|
|
if (proc.timedOut) {
|
|
|
|
|
return {
|
|
|
|
|
exitCode: proc.exitCode,
|
|
|
|
|
signal: proc.signal,
|
|
|
|
|
timedOut: true,
|
|
|
|
|
errorMessage: `Timed out after ${timeoutSec}s`,
|
2026-02-23 14:40:44 -06:00
|
|
|
errorCode: "timeout",
|
|
|
|
|
errorMeta,
|
2026-02-18 13:53:03 -06:00
|
|
|
clearSession: Boolean(opts.clearSessionOnMissingSession),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!parsed) {
|
|
|
|
|
return {
|
|
|
|
|
exitCode: proc.exitCode,
|
|
|
|
|
signal: proc.signal,
|
|
|
|
|
timedOut: false,
|
|
|
|
|
errorMessage: parseFallbackErrorMessage(proc),
|
2026-02-23 14:40:44 -06:00
|
|
|
errorCode: loginMeta.requiresLogin ? "claude_auth_required" : null,
|
|
|
|
|
errorMeta,
|
2026-02-18 13:53:03 -06:00
|
|
|
resultJson: {
|
|
|
|
|
stdout: proc.stdout,
|
|
|
|
|
stderr: proc.stderr,
|
|
|
|
|
},
|
|
|
|
|
clearSession: Boolean(opts.clearSessionOnMissingSession),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const usage =
|
|
|
|
|
parsedStream.usage ??
|
|
|
|
|
(() => {
|
|
|
|
|
const usageObj = parseObject(parsed.usage);
|
|
|
|
|
return {
|
|
|
|
|
inputTokens: asNumber(usageObj.input_tokens, 0),
|
|
|
|
|
cachedInputTokens: asNumber(usageObj.cache_read_input_tokens, 0),
|
|
|
|
|
outputTokens: asNumber(usageObj.output_tokens, 0),
|
|
|
|
|
};
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
const resolvedSessionId =
|
|
|
|
|
parsedStream.sessionId ??
|
|
|
|
|
(asString(parsed.session_id, opts.fallbackSessionId ?? "") || opts.fallbackSessionId);
|
2026-02-19 14:01:58 -06:00
|
|
|
const resolvedSessionParams = resolvedSessionId
|
2026-02-25 08:38:58 -06:00
|
|
|
? ({
|
|
|
|
|
sessionId: resolvedSessionId,
|
|
|
|
|
cwd,
|
|
|
|
|
...(workspaceId ? { workspaceId } : {}),
|
|
|
|
|
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
|
|
|
|
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
|
|
|
|
} as Record<string, unknown>)
|
2026-02-19 14:01:58 -06:00
|
|
|
: null;
|
2026-02-26 16:33:10 -06:00
|
|
|
const clearSessionForMaxTurns = isClaudeMaxTurnsResult(parsed);
|
2026-02-18 13:53:03 -06:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
exitCode: proc.exitCode,
|
|
|
|
|
signal: proc.signal,
|
|
|
|
|
timedOut: false,
|
|
|
|
|
errorMessage:
|
|
|
|
|
(proc.exitCode ?? 0) === 0
|
|
|
|
|
? null
|
|
|
|
|
: describeClaudeFailure(parsed) ?? `Claude exited with code ${proc.exitCode ?? -1}`,
|
2026-02-23 14:40:44 -06:00
|
|
|
errorCode: loginMeta.requiresLogin ? "claude_auth_required" : null,
|
|
|
|
|
errorMeta,
|
2026-02-18 13:53:03 -06:00
|
|
|
usage,
|
|
|
|
|
sessionId: resolvedSessionId,
|
2026-02-19 14:01:58 -06:00
|
|
|
sessionParams: resolvedSessionParams,
|
|
|
|
|
sessionDisplayId: resolvedSessionId,
|
2026-02-18 13:53:03 -06:00
|
|
|
provider: "anthropic",
|
feat: add AWS Bedrock auth support on "claude-local" (#2793)
Closes #2412
Related: #2681, #498, #128
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies
> - The Claude Code adapter spawns the `claude` CLI to run agent tasks
> - The adapter detects auth mode by checking for `ANTHROPIC_API_KEY` —
recognizing only "api" and "subscription" modes
> - But users running Claude Code via **AWS Bedrock**
(`CLAUDE_CODE_USE_BEDROCK=1`) fall through to the "subscription" path
> - This causes a misleading "ANTHROPIC_API_KEY is not set;
subscription-based auth can be used" message in the environment check
> - Additionally, the hello probe passes `--model claude-opus-4-6` which
is **not a valid Bedrock model identifier**, causing `400 The provided
model identifier is invalid` and a probe failure
> - This pull request adds Bedrock auth detection, skips the
Anthropic-style `--model` flag for Bedrock, and returns the correct
billing type
> - The benefit is that Bedrock users get a working environment check
and correct cost tracking out of the box
---
## Pain Point
Many enterprise teams use **Claude Code through AWS Bedrock** rather
than Anthropic's direct API — for compliance, billing consolidation, or
VPC requirements. Currently, these users hit a **hard wall during
onboarding**:
| Problem | Impact |
|---|---|
| :x: Adapter environment check **always fails** | Users cannot create
their first agent — blocked at step 1 |
| :x: `--model claude-opus-4-6` is **invalid on Bedrock** (requires
`us.anthropic.*` format) | Hello probe exits with code 1: `400 The
provided model identifier is invalid` |
| :x: Auth shown as _"subscription-based"_ | Misleading — Bedrock is
neither subscription nor API-key auth |
| :x: Quota polling hits Anthropic OAuth endpoint | Fails silently for
Bedrock users who have no Anthropic subscription |
> **Bottom line**: Paperclip is completely unusable for Bedrock users
out of the box.
## Why Bedrock Matters
AWS Bedrock is a major deployment path for Claude in enterprise
environments:
- **Enterprise compliance** — data stays within the customer's AWS
account and VPC
- **Unified billing** — Claude usage appears on the existing AWS
invoice, no separate Anthropic billing
- **IAM integration** — access controlled through AWS IAM roles and
policies
- **Regional deployment** — models run in the customer's preferred AWS
region
Supporting Bedrock unlocks Paperclip for organizations that **cannot**
use Anthropic's direct API due to procurement, security, or regulatory
constraints.
---
## What Changed
- **`execute.ts`**: Added `isBedrockAuth()` helper that checks
`CLAUDE_CODE_USE_BEDROCK` and `ANTHROPIC_BEDROCK_BASE_URL` env vars.
`resolveClaudeBillingType()` now returns `"metered_api"` for Bedrock.
Biller set to `"aws_bedrock"`. Skips `--model` flag when Bedrock is
active (Anthropic-style model IDs are invalid on Bedrock; the CLI uses
its own configured model).
- **`test.ts`**: Environment check now detects Bedrock env vars (from
adapter config or server env) and shows `"AWS Bedrock auth detected.
Claude will use Bedrock for inference."` instead of the misleading
subscription message. Also skips `--model` in the hello probe for
Bedrock.
- **`quota.ts`**: Early return with `{ ok: true, windows: [] }` when
Bedrock is active — Bedrock usage is billed through AWS, not Anthropic's
subscription quota system.
- **`ui/src/lib/utils.ts`**: Added `"aws_bedrock"` → `"AWS Bedrock"` to
`providerDisplayName()` and `quotaSourceDisplayName()`.
## Verification
1. `pnpm -r typecheck` — all packages pass
2. Unit tests added and passing (6/6)
3. Environment check with Bedrock env vars:
| | Before | After |
|---|---|---|
| **Status** | :red_circle: Failed | :white_check_mark: Passed |
| **Auth message** | `ANTHROPIC_API_KEY is not set; subscription-based
auth can be used if Claude is logged in.` | `AWS Bedrock auth detected.
Claude will use Bedrock for inference.` |
| **Hello probe** | `ERROR · Claude hello probe failed.` (exit code 1 —
`--model claude-opus-4-6` is invalid on Bedrock) | `INFO · Claude hello
probe succeeded.` |
| **Screenshot** | <img height="500" alt="Screenshot 2026-04-05 at 8 25
27 AM"
src="https://github.com/user-attachments/assets/476431f6-6139-425a-8abc-97875d653657"
/> | <img height="500" alt="Screenshot 2026-04-05 at 8 31 58 AM"
src="https://github.com/user-attachments/assets/d388ce87-c5e6-4574-b8d2-fd8b86135299"
/> |
4. Existing API key / subscription paths are completely untouched unless
Bedrock env vars are present
## Risks
- **Low risk.** All changes are additive — existing "api" and
"subscription" code paths are only entered when Bedrock env vars are
absent.
- When Bedrock is active, the `--model` flag is skipped, so the
Paperclip model dropdown selection is ignored in favor of the Claude
CLI's own model config. This is intentional since Bedrock requires
different model identifiers.
## Model Used
- Claude Opus 4.6 (`claude-opus-4-6`, 1M context window) via Claude Code
CLI
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-07 05:15:18 +09:00
|
|
|
biller: isBedrockAuth(effectiveEnv) ? "aws_bedrock" : "anthropic",
|
2026-02-18 13:53:03 -06:00
|
|
|
model: parsedStream.model || asString(parsed.model, model),
|
2026-02-25 21:35:44 -06:00
|
|
|
billingType,
|
2026-02-18 13:53:03 -06:00
|
|
|
costUsd: parsedStream.costUsd ?? asNumber(parsed.total_cost_usd, 0),
|
|
|
|
|
resultJson: parsed,
|
|
|
|
|
summary: parsedStream.summary || asString(parsed.result, ""),
|
2026-02-26 16:33:10 -06:00
|
|
|
clearSession: clearSessionForMaxTurns || Boolean(opts.clearSessionOnMissingSession && !resolvedSessionId),
|
2026-02-18 13:53:03 -06:00
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-18 15:29:24 -06:00
|
|
|
try {
|
|
|
|
|
const initial = await runAttempt(sessionId ?? null);
|
|
|
|
|
if (
|
|
|
|
|
sessionId &&
|
|
|
|
|
!initial.proc.timedOut &&
|
|
|
|
|
(initial.proc.exitCode ?? 0) !== 0 &&
|
|
|
|
|
initial.parsed &&
|
|
|
|
|
isClaudeUnknownSessionError(initial.parsed)
|
|
|
|
|
) {
|
|
|
|
|
await onLog(
|
2026-03-18 21:16:37 -05:00
|
|
|
"stdout",
|
2026-02-18 15:29:24 -06:00
|
|
|
`[paperclip] Claude resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
|
|
|
|
);
|
|
|
|
|
const retry = await runAttempt(null);
|
|
|
|
|
return toAdapterResult(retry, { fallbackSessionId: null, clearSessionOnMissingSession: true });
|
|
|
|
|
}
|
2026-02-18 13:53:03 -06:00
|
|
|
|
2026-02-19 14:01:58 -06:00
|
|
|
return toAdapterResult(initial, { fallbackSessionId: runtimeSessionId || runtime.sessionId });
|
2026-02-18 15:29:24 -06:00
|
|
|
} finally {
|
|
|
|
|
fs.rm(skillsDir, { recursive: true, force: true }).catch(() => {});
|
|
|
|
|
}
|
2026-02-18 13:53:03 -06:00
|
|
|
}
|