clawmoney 0.10.20 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/setup.js +49 -10
- package/dist/relay/executor.d.ts +3 -1
- package/dist/relay/executor.js +48 -11
- package/dist/relay/provider.js +44 -7
- package/dist/relay/types.d.ts +10 -0
- package/dist/relay/upstream/claude-api.d.ts +31 -0
- package/dist/relay/upstream/claude-api.js +466 -0
- package/dist/relay/upstream/rate-guard.d.ts +66 -0
- package/dist/relay/upstream/rate-guard.js +124 -0
- package/package.json +1 -1
- package/scripts/capture-claude-request.mjs +224 -0
- package/scripts/deep-test-claude-api.mjs +86 -0
- package/scripts/probe-claude-api.mjs +394 -0
- package/scripts/verify-on-mba.sh +96 -0
package/dist/commands/setup.js
CHANGED
|
@@ -42,19 +42,37 @@ export async function setupCommand() {
|
|
|
42
42
|
return;
|
|
43
43
|
}
|
|
44
44
|
// Step 2: Check wallet status
|
|
45
|
+
//
|
|
46
|
+
// We try to get the wallet address directly — if awal returns one, the
|
|
47
|
+
// wallet is signed in, end of story. This avoids the awal `status` command
|
|
48
|
+
// returning an unrecognized shape (observed field-name drift: some versions
|
|
49
|
+
// return `authenticated`, others `signedIn`/`account`/nested objects)
|
|
50
|
+
// which would otherwise force us into the login flow even when the user
|
|
51
|
+
// is already signed in, and awal would then refuse with "already signed in".
|
|
45
52
|
const walletSpinner = ora('Checking wallet status...').start();
|
|
46
53
|
let walletAddress = '';
|
|
47
54
|
let needsLogin = false;
|
|
48
55
|
try {
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
56
|
+
const addrResult = await awalExec(['address']);
|
|
57
|
+
const addrData = addrResult.data;
|
|
58
|
+
const addr = addrData.address || '';
|
|
59
|
+
if (addr) {
|
|
60
|
+
walletAddress = addr;
|
|
61
|
+
walletSpinner.succeed(`Wallet connected: ${walletAddress}`);
|
|
54
62
|
}
|
|
55
63
|
else {
|
|
56
|
-
|
|
57
|
-
|
|
64
|
+
// Fall back to legacy `status` shape in case some awal version only
|
|
65
|
+
// exposes authentication through that command.
|
|
66
|
+
const status = await awalExec(['status']);
|
|
67
|
+
const statusData = status.data;
|
|
68
|
+
if (statusData.authenticated || statusData.loggedIn || statusData.address) {
|
|
69
|
+
walletAddress = statusData.address || '';
|
|
70
|
+
walletSpinner.succeed(`Wallet connected${walletAddress ? `: ${walletAddress}` : ''}`);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
needsLogin = true;
|
|
74
|
+
walletSpinner.info('Wallet not authenticated');
|
|
75
|
+
}
|
|
58
76
|
}
|
|
59
77
|
}
|
|
60
78
|
catch {
|
|
@@ -104,9 +122,30 @@ export async function setupCommand() {
|
|
|
104
122
|
}
|
|
105
123
|
}
|
|
106
124
|
catch (err) {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
125
|
+
// If awal reports "already signed in" we're actually in the happy path
|
|
126
|
+
// — the wallet is authenticated, the address check at the top just
|
|
127
|
+
// failed to detect it. Try once more to fetch the address directly.
|
|
128
|
+
const msg = err.message || '';
|
|
129
|
+
if (/already\s*signed\s*in/i.test(msg)) {
|
|
130
|
+
loginSpinner.info('Wallet already signed in');
|
|
131
|
+
try {
|
|
132
|
+
const addrResult = await awalExec(['address']);
|
|
133
|
+
const addrData = addrResult.data;
|
|
134
|
+
walletAddress = addrData.address || '';
|
|
135
|
+
if (walletAddress) {
|
|
136
|
+
console.log(chalk.dim(` Wallet: ${walletAddress}`));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// Address fetch still failed — continue anyway; the agent
|
|
141
|
+
// register/login flow below doesn't strictly require a wallet.
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
loginSpinner.fail('Wallet login failed');
|
|
146
|
+
console.error(chalk.red(msg));
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
110
149
|
}
|
|
111
150
|
}
|
|
112
151
|
// If we still don't have address, try fetching it
|
package/dist/relay/executor.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { ParsedOutput } from "./types.js";
|
|
2
|
-
export declare function
|
|
2
|
+
export declare function ensureEmptyMcpConfig(): string;
|
|
3
|
+
export declare function ensureSandboxDir(): string;
|
|
4
|
+
export declare function spawnCli(cliType: string, args: string[], timeoutMs?: number, cwd?: string): Promise<string>;
|
|
3
5
|
export declare function buildCliArgs(cliType: string, prompt: string, sessionId?: string, maxBudgetUsd?: number, model?: string): string[];
|
|
4
6
|
export declare function parseClaudeOutput(raw: string): ParsedOutput;
|
|
5
7
|
export declare function parseCodexOutput(raw: string): ParsedOutput;
|
package/dist/relay/executor.js
CHANGED
|
@@ -1,22 +1,51 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { writeFileSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
2
5
|
import { relayLogger as logger } from "./logger.js";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
// Pure-LLM system prompt. Tools are physically disabled via --tools "" and
|
|
7
|
+
// an empty MCP config, so this prompt only needs to set the assistant role.
|
|
8
|
+
const RELAY_SYSTEM_PROMPT = "You are a helpful AI assistant. Respond with text only.";
|
|
9
|
+
// Empty MCP config — written once at startup and passed via --mcp-config.
|
|
10
|
+
// Combined with --strict-mcp-config this disables all MCP tools without
|
|
11
|
+
// depending on the user's global/project MCP configuration.
|
|
12
|
+
const CLAWMONEY_DIR = join(homedir(), ".clawmoney");
|
|
13
|
+
const EMPTY_MCP_CONFIG_PATH = join(CLAWMONEY_DIR, "empty-mcp.json");
|
|
14
|
+
const SANDBOX_DIR = join(CLAWMONEY_DIR, "sandbox");
|
|
15
|
+
export function ensureEmptyMcpConfig() {
|
|
16
|
+
try {
|
|
17
|
+
mkdirSync(CLAWMONEY_DIR, { recursive: true });
|
|
18
|
+
writeFileSync(EMPTY_MCP_CONFIG_PATH, '{"mcpServers":{}}', "utf-8");
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
logger.warn(`Failed to write empty MCP config at ${EMPTY_MCP_CONFIG_PATH}:`, err);
|
|
22
|
+
}
|
|
23
|
+
return EMPTY_MCP_CONFIG_PATH;
|
|
24
|
+
}
|
|
25
|
+
// Ensures an empty sandbox directory exists and is used as the spawn cwd.
|
|
26
|
+
// Claude Code auto-injects cwd path, CLAUDE.md, and git status into the
|
|
27
|
+
// system prompt based on the spawn directory — running from an empty
|
|
28
|
+
// sandbox prevents any of the provider's real project data from leaking
|
|
29
|
+
// to the consumer side.
|
|
30
|
+
export function ensureSandboxDir() {
|
|
31
|
+
try {
|
|
32
|
+
mkdirSync(SANDBOX_DIR, { recursive: true });
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
logger.warn(`Failed to create sandbox dir at ${SANDBOX_DIR}:`, err);
|
|
36
|
+
}
|
|
37
|
+
return SANDBOX_DIR;
|
|
38
|
+
}
|
|
11
39
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
12
40
|
// ── Spawn CLI process ──
|
|
13
|
-
export function spawnCli(cliType, args, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
41
|
+
export function spawnCli(cliType, args, timeoutMs = DEFAULT_TIMEOUT_MS, cwd) {
|
|
14
42
|
return new Promise((resolve, reject) => {
|
|
15
43
|
logger.info(` │ Exec: ${cliType} ${args.slice(0, 3).join(" ")}...`);
|
|
16
44
|
const child = spawn(cliType, args, {
|
|
17
45
|
stdio: ["ignore", "pipe", "pipe"],
|
|
18
46
|
timeout: timeoutMs,
|
|
19
47
|
env: { ...process.env },
|
|
48
|
+
cwd,
|
|
20
49
|
});
|
|
21
50
|
let stdout = "";
|
|
22
51
|
let stderr = "";
|
|
@@ -45,10 +74,19 @@ export function spawnCli(cliType, args, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
|
45
74
|
export function buildCliArgs(cliType, prompt, sessionId, maxBudgetUsd, model) {
|
|
46
75
|
let args;
|
|
47
76
|
if (cliType === "claude") {
|
|
77
|
+
// Pure-LLM relay mode: strip all tool access, MCP servers, user/project
|
|
78
|
+
// settings, CLAUDE.md auto-discovery, and the default system prompt.
|
|
79
|
+
// Combined with spawning the process in an empty sandbox cwd, this
|
|
80
|
+
// ensures the consumer never sees the provider's filesystem, project
|
|
81
|
+
// context, or CLAUDE.md contents.
|
|
48
82
|
args = [
|
|
49
83
|
"-p", prompt,
|
|
50
84
|
"--output-format", "json",
|
|
51
|
-
"--
|
|
85
|
+
"--tools", "",
|
|
86
|
+
"--strict-mcp-config",
|
|
87
|
+
"--mcp-config", EMPTY_MCP_CONFIG_PATH,
|
|
88
|
+
"--setting-sources", "",
|
|
89
|
+
"--system-prompt", RELAY_SYSTEM_PROMPT,
|
|
52
90
|
];
|
|
53
91
|
if (model) {
|
|
54
92
|
args.push("--model", model);
|
|
@@ -59,7 +97,6 @@ export function buildCliArgs(cliType, prompt, sessionId, maxBudgetUsd, model) {
|
|
|
59
97
|
if (sessionId) {
|
|
60
98
|
args.push("--resume", sessionId);
|
|
61
99
|
}
|
|
62
|
-
args.push("--append-system-prompt", SAFETY_PROMPT);
|
|
63
100
|
}
|
|
64
101
|
else if (cliType === "codex") {
|
|
65
102
|
if (sessionId) {
|
package/dist/relay/provider.js
CHANGED
|
@@ -3,7 +3,8 @@ import { join } from "node:path";
|
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import YAML from "yaml";
|
|
5
5
|
import { RelayWsClient } from "./ws-client.js";
|
|
6
|
-
import { spawnCli, buildCliArgs, parseCliOutput } from "./executor.js";
|
|
6
|
+
import { spawnCli, buildCliArgs, parseCliOutput, ensureEmptyMcpConfig, ensureSandboxDir, } from "./executor.js";
|
|
7
|
+
import { callClaudeApi, preflightClaudeApi } from "./upstream/claude-api.js";
|
|
7
8
|
import { calculateCost } from "./pricing.js";
|
|
8
9
|
import { relayLogger as logger } from "./logger.js";
|
|
9
10
|
const CONFIG_DIR = join(homedir(), ".clawmoney");
|
|
@@ -11,6 +12,7 @@ const CONFIG_FILE = join(CONFIG_DIR, "config.yaml");
|
|
|
11
12
|
const PID_FILE = join(CONFIG_DIR, "relay.pid");
|
|
12
13
|
const DEFAULT_RELAY = {
|
|
13
14
|
cli_type: "claude",
|
|
15
|
+
execution_mode: "cli",
|
|
14
16
|
model: "claude-opus-4-6",
|
|
15
17
|
mode: "chat",
|
|
16
18
|
concurrency: 5,
|
|
@@ -69,8 +71,13 @@ function loadRelayConfig(cliOverride) {
|
|
|
69
71
|
process.exit(1);
|
|
70
72
|
}
|
|
71
73
|
const userRelay = (raw.relay ?? {});
|
|
74
|
+
// Env override lets `CLAWMONEY_RELAY_EXECUTION_MODE=api` flip the mode
|
|
75
|
+
// without editing config.yaml — useful for quick A/B and testing.
|
|
76
|
+
const envMode = process.env.CLAWMONEY_RELAY_EXECUTION_MODE;
|
|
77
|
+
const executionMode = envMode ?? userRelay.execution_mode ?? DEFAULT_RELAY.execution_mode;
|
|
72
78
|
const relay = {
|
|
73
79
|
cli_type: cliOverride ?? userRelay.cli_type ?? DEFAULT_RELAY.cli_type,
|
|
80
|
+
execution_mode: executionMode,
|
|
74
81
|
model: userRelay.model ?? DEFAULT_RELAY.model,
|
|
75
82
|
mode: userRelay.mode ?? DEFAULT_RELAY.mode,
|
|
76
83
|
concurrency: userRelay.concurrency ?? DEFAULT_RELAY.concurrency,
|
|
@@ -99,6 +106,8 @@ async function executeRelayRequest(request, config) {
|
|
|
99
106
|
const model = request.model ?? config.relay.model;
|
|
100
107
|
const stateful = request.stateful ?? false;
|
|
101
108
|
const cliSessionId = request.cli_session_id ?? undefined;
|
|
109
|
+
// api mode is currently claude-only; everything else falls back to spawn CLI.
|
|
110
|
+
const useApiMode = config.relay.execution_mode === "api" && cliType === "claude";
|
|
102
111
|
// Build prompt from messages
|
|
103
112
|
const prompt = request.messages
|
|
104
113
|
? messagesToPrompt(request.messages)
|
|
@@ -112,17 +121,34 @@ async function executeRelayRequest(request, config) {
|
|
|
112
121
|
const modeLabel = stateful
|
|
113
122
|
? (cliSessionId ? `stateful[resume ${cliSessionId.slice(0, 8)}]` : "stateful[new]")
|
|
114
123
|
: "stateless";
|
|
124
|
+
const execLabel = useApiMode ? "api" : "cli";
|
|
115
125
|
logger.info(` ┌─ Request ${request_id.slice(0, 8)}`);
|
|
116
|
-
logger.info(` │ CLI: ${cliType} / ${model} (${modeLabel})`);
|
|
126
|
+
logger.info(` │ CLI: ${cliType} / ${model} (${modeLabel}, exec=${execLabel})`);
|
|
117
127
|
logger.info(` │ Turns: ${turns}`);
|
|
118
128
|
logger.info(` │ Prompt: ${String(lastUserMsg).slice(0, 80)}`);
|
|
119
129
|
try {
|
|
120
130
|
const startMs = Date.now();
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
131
|
+
let parsed;
|
|
132
|
+
if (useApiMode) {
|
|
133
|
+
// Direct /v1/messages call — no subprocess, no sandbox needed because
|
|
134
|
+
// the only thing the upstream sees is the prompt text we pass in.
|
|
135
|
+
parsed = await callClaudeApi({
|
|
136
|
+
prompt,
|
|
137
|
+
model,
|
|
138
|
+
maxTokens: max_budget_usd ? undefined : 4096,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
// In stateful mode, pass cli_session_id so buildCliArgs adds --resume
|
|
143
|
+
const args = buildCliArgs(cliType, prompt, cliSessionId, max_budget_usd, model);
|
|
144
|
+
// Spawn from an empty sandbox directory so Claude Code's auto-injected
|
|
145
|
+
// cwd / CLAUDE.md / git-status context can't leak the provider's real
|
|
146
|
+
// project data to the consumer.
|
|
147
|
+
const sandbox = ensureSandboxDir();
|
|
148
|
+
const raw = await spawnCli(cliType, args, undefined, sandbox);
|
|
149
|
+
parsed = parseCliOutput(cliType, raw);
|
|
150
|
+
}
|
|
124
151
|
const elapsedMs = Date.now() - startMs;
|
|
125
|
-
const parsed = parseCliOutput(cliType, raw);
|
|
126
152
|
const answer = parsed.text.replace(/\n/g, " ").slice(0, 80);
|
|
127
153
|
const { input_tokens: inT, output_tokens: outT, cache_creation_tokens: cacheWriteT, cache_read_tokens: cacheReadT } = parsed.usage;
|
|
128
154
|
const cost = calculateCost(model, inT, outT, cacheWriteT, cacheReadT);
|
|
@@ -162,6 +188,17 @@ export function runRelayProvider(cliOverride) {
|
|
|
162
188
|
process.exit(1);
|
|
163
189
|
}
|
|
164
190
|
const config = loadRelayConfig(cliOverride);
|
|
191
|
+
// Prepare relay sandbox assets once at startup.
|
|
192
|
+
ensureEmptyMcpConfig();
|
|
193
|
+
ensureSandboxDir();
|
|
194
|
+
// If the operator picked api mode, validate the OAuth token + fingerprint
|
|
195
|
+
// up-front so we fail fast instead of on the first inbound request.
|
|
196
|
+
if (config.relay.execution_mode === "api" && config.relay.cli_type === "claude") {
|
|
197
|
+
preflightClaudeApi(config.relay.rate_guard).catch((err) => {
|
|
198
|
+
logger.error(`Claude API preflight failed — falling back to CLI mode: ${err.message}`);
|
|
199
|
+
config.relay.execution_mode = "cli";
|
|
200
|
+
});
|
|
201
|
+
}
|
|
165
202
|
const activeTasks = new Set();
|
|
166
203
|
// Create WS client
|
|
167
204
|
const wsClient = new RelayWsClient(config, (event) => {
|
|
@@ -231,5 +268,5 @@ export function runRelayProvider(cliOverride) {
|
|
|
231
268
|
writeRelayPid();
|
|
232
269
|
wsClient.start();
|
|
233
270
|
logger.info("Relay Provider running. Listening for relay requests...");
|
|
234
|
-
logger.info(`Config: cli=${config.relay.cli_type}, model=${config.relay.model}, mode=${config.relay.mode}, concurrency=${config.relay.concurrency}`);
|
|
271
|
+
logger.info(`Config: cli=${config.relay.cli_type}, exec=${config.relay.execution_mode ?? "cli"}, model=${config.relay.model}, mode=${config.relay.mode}, concurrency=${config.relay.concurrency}`);
|
|
235
272
|
}
|
package/dist/relay/types.d.ts
CHANGED
|
@@ -53,8 +53,18 @@ export interface ParsedOutput {
|
|
|
53
53
|
model: string;
|
|
54
54
|
costUsd: number;
|
|
55
55
|
}
|
|
56
|
+
export interface RelayRateGuardConfig {
|
|
57
|
+
max_concurrency?: number;
|
|
58
|
+
quiet_hours_max_concurrency?: number;
|
|
59
|
+
quiet_hours?: number[];
|
|
60
|
+
min_request_gap_ms?: number;
|
|
61
|
+
jitter_ms?: number;
|
|
62
|
+
daily_budget_usd?: number;
|
|
63
|
+
}
|
|
56
64
|
export interface RelayProviderSettings {
|
|
57
65
|
cli_type: string;
|
|
66
|
+
execution_mode?: "cli" | "api";
|
|
67
|
+
rate_guard?: RelayRateGuardConfig;
|
|
58
68
|
model: string;
|
|
59
69
|
mode: string;
|
|
60
70
|
concurrency: number;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Direct Anthropic API upstream for Claude Code OAuth subscriptions.
|
|
3
|
+
*
|
|
4
|
+
* Instead of spawning the `claude` CLI for every relay request, this module
|
|
5
|
+
* reuses the OAuth token that the locally-logged-in Claude Code has already
|
|
6
|
+
* obtained, and sends /v1/messages requests directly to api.anthropic.com
|
|
7
|
+
* with the exact Claude Code request shape (captured from claude-cli/2.1.100).
|
|
8
|
+
*
|
|
9
|
+
* Why this exists:
|
|
10
|
+
* - spawn CLI latency is 1-3s per request; direct HTTP is ~300ms
|
|
11
|
+
* - CLI mode can't stream; HTTP mode is real SSE
|
|
12
|
+
* - CLI mode can't saturate concurrency; HTTP mode scales trivially
|
|
13
|
+
*
|
|
14
|
+
* Token is loaded once at startup (from macOS Keychain or ~/.claude) and
|
|
15
|
+
* refreshed in-process when within 3 min of expiry. Refreshed tokens are
|
|
16
|
+
* persisted back to the Keychain so the Provider's real Claude Code stays
|
|
17
|
+
* in sync — otherwise Claude Code would find its refresh_token revoked on
|
|
18
|
+
* next use.
|
|
19
|
+
*/
|
|
20
|
+
import type { ParsedOutput, RelayRateGuardConfig } from "../types.js";
|
|
21
|
+
import { RateGuard, RateGuardBudgetExceededError } from "./rate-guard.js";
|
|
22
|
+
export { RateGuardBudgetExceededError };
|
|
23
|
+
export declare function configureRateGuard(config?: RelayRateGuardConfig): void;
|
|
24
|
+
export declare function getRateGuardSnapshot(): ReturnType<RateGuard["currentLoad"]> | null;
|
|
25
|
+
export declare function preflightClaudeApi(config?: RelayRateGuardConfig): Promise<void>;
|
|
26
|
+
export interface CallClaudeApiOptions {
|
|
27
|
+
prompt: string;
|
|
28
|
+
model: string;
|
|
29
|
+
maxTokens?: number;
|
|
30
|
+
}
|
|
31
|
+
export declare function callClaudeApi(opts: CallClaudeApiOptions): Promise<ParsedOutput>;
|