@blockrun/franklin 3.15.100 → 3.15.102

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.
@@ -30,6 +30,21 @@ export declare class PermissionManager {
30
30
  * Returns true if allowed, false if denied.
31
31
  */
32
32
  promptUser(toolName: string, input: Record<string, unknown>, pendingCount?: number): Promise<boolean>;
33
+ /**
34
+ * Persist a tool name to the user's allow rules so future sessions
35
+ * skip the prompt. Idempotent: appends to the existing
36
+ * `allow: []` array only if not already present.
37
+ *
38
+ * Why this exists: pre-2026-05-12, "always" in the UI prompt was a
39
+ * misnomer — it only added the tool to the in-memory `sessionAllowed`
40
+ * Set, which evaporated on every `franklin start`. Users reported
41
+ * being prompted repeatedly across sessions despite hitting [a] each
42
+ * time. Persistence here makes "always" actually mean always.
43
+ *
44
+ * Best-effort writes (try/catch around fs) — a logging failure should
45
+ * never block the paid call that just got approved.
46
+ */
47
+ private persistAllowRule;
33
48
  private loadRules;
34
49
  private matchesRule;
35
50
  private getPrimaryInputValue;
@@ -122,7 +122,15 @@ export class PermissionManager {
122
122
  if (this.promptFn) {
123
123
  const result = await this.promptFn(toolName, hint);
124
124
  if (result === 'always') {
125
+ // "Always" must mean ALWAYS — including after the user restarts
126
+ // franklin. Pre-fix it was only in-memory: every `franklin start`
127
+ // re-asked the same prompts even after the user had pressed [a]
128
+ // explicitly. Verified 2026-05-12: ~/.blockrun/franklin-permissions.json
129
+ // was non-existent despite the user reporting repeated prompts.
130
+ // Persist to disk and update in-memory rules so subsequent
131
+ // checks short-circuit at the allow-rule stage (line 124).
125
132
  this.sessionAllowed.add(toolName);
133
+ this.persistAllowRule(toolName);
126
134
  return true;
127
135
  }
128
136
  return result === 'yes';
@@ -140,7 +148,8 @@ export class PermissionManager {
140
148
  const normalized = answer.trim().toLowerCase();
141
149
  if (normalized === 'a' || normalized === 'always') {
142
150
  this.sessionAllowed.add(toolName);
143
- console.error(chalk.green(` ✓ ${toolName} allowed for this session`));
151
+ this.persistAllowRule(toolName);
152
+ console.error(chalk.green(` ✓ ${toolName} allowed (saved to ~/.blockrun/franklin-permissions.json)`));
144
153
  return true;
145
154
  }
146
155
  if (normalized === 'y' || normalized === 'yes' || normalized === '') {
@@ -150,6 +159,50 @@ export class PermissionManager {
150
159
  return false;
151
160
  }
152
161
  // ─── Internal ──────────────────────────────────────────────────────────
162
+ /**
163
+ * Persist a tool name to the user's allow rules so future sessions
164
+ * skip the prompt. Idempotent: appends to the existing
165
+ * `allow: []` array only if not already present.
166
+ *
167
+ * Why this exists: pre-2026-05-12, "always" in the UI prompt was a
168
+ * misnomer — it only added the tool to the in-memory `sessionAllowed`
169
+ * Set, which evaporated on every `franklin start`. Users reported
170
+ * being prompted repeatedly across sessions despite hitting [a] each
171
+ * time. Persistence here makes "always" actually mean always.
172
+ *
173
+ * Best-effort writes (try/catch around fs) — a logging failure should
174
+ * never block the paid call that just got approved.
175
+ */
176
+ persistAllowRule(toolName) {
177
+ const configPath = path.join(BLOCKRUN_DIR, 'franklin-permissions.json');
178
+ try {
179
+ // Read current state (may not exist). Treat missing/malformed as
180
+ // empty rules — never throw on the user's tool execution path.
181
+ let current = { allow: [], deny: [], ask: [] };
182
+ if (fs.existsSync(configPath)) {
183
+ try {
184
+ const raw = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
185
+ current = {
186
+ allow: Array.isArray(raw.allow) ? raw.allow : [],
187
+ deny: Array.isArray(raw.deny) ? raw.deny : [],
188
+ ask: Array.isArray(raw.ask) ? raw.ask : [],
189
+ };
190
+ }
191
+ catch { /* malformed — reset */ }
192
+ }
193
+ if (current.allow.includes(toolName))
194
+ return; // already saved
195
+ current.allow.push(toolName);
196
+ // Update in-memory rules too so subsequent checks short-circuit
197
+ // at line 124 (matchesRule against allow) without re-prompting.
198
+ if (!this.rules.allow.includes(toolName)) {
199
+ this.rules.allow.push(toolName);
200
+ }
201
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
202
+ fs.writeFileSync(configPath, JSON.stringify(current, null, 2));
203
+ }
204
+ catch { /* best-effort */ }
205
+ }
153
206
  loadRules() {
154
207
  const configPath = path.join(BLOCKRUN_DIR, 'franklin-permissions.json');
155
208
  const legacyPath = path.join(BLOCKRUN_DIR, 'runcode-permissions.json');
@@ -29,6 +29,12 @@ export interface SettlementRow {
29
29
  costUsd: number;
30
30
  /** Unix milliseconds (normalized — SDK writes seconds). */
31
31
  ts: number;
32
+ /** Wallet that signed (lowercased). Used for test-wallet filtering. */
33
+ wallet?: string;
34
+ /** Model that was charged (e.g. `openai/gpt-5.5`). */
35
+ model?: string;
36
+ /** Which client wrote the row (LLMClient / AgentClient / ProxyClient / AsyncLLMClient). */
37
+ clientKind?: string;
32
38
  }
33
39
  export interface SettlementSummary {
34
40
  /** Path to cost_log.jsonl (or the fallback location). */
@@ -25,6 +25,27 @@
25
25
  import fs from 'node:fs';
26
26
  import path from 'node:path';
27
27
  import { BLOCKRUN_DIR } from '../config.js';
28
+ /**
29
+ * Anvil/Hardhat deterministic test accounts. The first one
30
+ * (0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266) leaked into a real
31
+ * cost_log on 2026-05-13 — some SDK path signed with a hardcoded test
32
+ * key in production. These addresses are public knowledge (the private
33
+ * keys are in the Anvil source), so a settlement signed by them is
34
+ * definitionally not a real user spend. Filter them out at read time
35
+ * so dashboards / stats don't surface phantom rows.
36
+ */
37
+ const KNOWN_TEST_WALLETS = new Set([
38
+ '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266', // Anvil #0
39
+ '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', // Anvil #1
40
+ '0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc', // Anvil #2
41
+ '0x90f79bf6eb2c4f870365e785982e1f101e93b906', // Anvil #3
42
+ '0x15d34aaf54267db7d7c367839aaf71a00a2c6a65', // Anvil #4
43
+ '0x9965507d1a55bcc2695c58ba16fb37d819b0a4dc', // Anvil #5
44
+ '0x976ea74026e726554db657fa54763abd0c3a0aa9', // Anvil #6
45
+ '0x14dc79964da2c08b23698b3d3cc7ca32193d9955', // Anvil #7
46
+ '0x23618e81e3f5cdf7f54c3d65f7fbc0abf5b21e8f', // Anvil #8
47
+ '0xa0ee7a142d267c1f36714e4a8f75612f20a79720', // Anvil #9
48
+ ]);
28
49
  function getCostLogPath() {
29
50
  return path.join(BLOCKRUN_DIR, 'cost_log.jsonl');
30
51
  }
@@ -77,9 +98,49 @@ export function loadSdkSettlements(opts) {
77
98
  const ts = tsRaw < 1e12 ? Math.round(tsRaw * 1000) : Math.round(tsRaw);
78
99
  if (ts < sinceMs || ts > untilMs)
79
100
  continue;
80
- rows.push({ endpoint, costUsd, ts });
101
+ // Filter out known test-wallet leaks. Verified 2026-05-13: a real
102
+ // cost_log had a $1 entry written under Anvil account #0
103
+ // (0xf39Fd6...) — public test key. Any settlement under those
104
+ // addresses is by definition not real user spend; drop.
105
+ const walletRaw = typeof obj.wallet === 'string' ? obj.wallet : undefined;
106
+ const wallet = walletRaw?.toLowerCase();
107
+ if (wallet && KNOWN_TEST_WALLETS.has(wallet))
108
+ continue;
109
+ const model = typeof obj.model === 'string' ? obj.model : undefined;
110
+ const clientKindRaw = obj.client_kind ?? obj.clientKind;
111
+ const clientKind = typeof clientKindRaw === 'string' ? clientKindRaw : undefined;
112
+ rows.push({ endpoint, costUsd, ts, wallet, model, clientKind });
113
+ }
114
+ return dedupeRows(rows);
115
+ }
116
+ /**
117
+ * Collapse SDK double-writes. Verified 2026-05-13: a single
118
+ * `gpt-5.5 / /v1/chat/completions / $1.00` call generated THREE
119
+ * cost_log rows in the same physical second (two `LLMClient`, one
120
+ * `AsyncLLMClient`) because the SDK wraps the same fetch through two
121
+ * client classes, both of which call `appendCostLog`. Bucket by
122
+ * `(second, endpoint, model, cost-in-micro-USDC)` and keep the first;
123
+ * the others were always duplicates.
124
+ *
125
+ * Edge case: two legitimate same-second / same-model / same-price
126
+ * calls would also dedupe to one. Accepting that trade-off — the SDK
127
+ * bug currently inflates by 200-300%; a worst-case 1-row undercount
128
+ * on rapid-fire identical calls is a much smaller error and the user's
129
+ * dashboards round to cents anyway.
130
+ */
131
+ function dedupeRows(rows) {
132
+ const seen = new Map();
133
+ for (const r of rows) {
134
+ const bucket = Math.round(r.ts / 1000);
135
+ const microUsd = Math.round(r.costUsd * 1e6);
136
+ const key = `${bucket}|${r.endpoint}|${r.model ?? ''}|${microUsd}`;
137
+ // Keep the FIRST row in each bucket (chronologically earliest by ts).
138
+ // If the existing row in the map already has earlier ts, leave it.
139
+ const existing = seen.get(key);
140
+ if (!existing || r.ts < existing.ts)
141
+ seen.set(key, r);
81
142
  }
82
- return rows;
143
+ return [...seen.values()].sort((a, b) => a.ts - b.ts);
83
144
  }
84
145
  /**
85
146
  * Append one settlement row to ~/.blockrun/cost_log.jsonl in the same
package/dist/ui/app.js CHANGED
@@ -1060,7 +1060,7 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
1060
1060
  : `${formatTokens(r.tokens.input)} in / ${formatTokens(r.tokens.output)} out`, r.cost > 0 ? ` · $${r.cost.toFixed(4)}` : '', r.savings !== undefined && r.savings > 0 ? _jsxs(Text, { color: "green", children: [" saved ", Math.round(r.savings * 100), "%"] }) : '', r.ctxPct !== undefined && r.ctxPct >= 5
1061
1061
  ? _jsxs(Text, { color: r.ctxPct >= 80 ? 'red' : r.ctxPct >= 50 ? 'yellow' : undefined, dimColor: r.ctxPct < 50, children: [" \u00B7 ctx ", r.ctxPct, "%"] })
1062
1062
  : ''] }) }))] }, r.key));
1063
- } }), permissionRequest && (_jsxs(Box, { flexDirection: "column", marginTop: 1, marginLeft: 2, children: [_jsx(Text, { color: "red", bold: true, children: "\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501 \u26A0 ACTION REQUIRED \u26A0 \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501" }), _jsx(Text, { color: "yellow", children: "\u256D\u2500 Permission required \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }), _jsxs(Text, { color: "yellow", children: ["\u2502 ", _jsx(Text, { bold: true, children: permissionRequest.toolName })] }), permissionRequest.description.split('\n').map((line, i) => (_jsxs(Text, { dimColor: true, children: ["\u2502 ", line] }, i))), _jsx(Text, { color: "yellow", children: "\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }), _jsx(Box, { marginLeft: 2, children: _jsxs(Text, { children: [_jsx(Text, { bold: true, color: "green", children: "[y]" }), _jsx(Text, { dimColor: true, children: " yes " }), _jsx(Text, { bold: true, color: "cyan", children: "[a]" }), _jsx(Text, { dimColor: true, children: " always " }), _jsx(Text, { bold: true, color: "red", children: "[n]" }), _jsx(Text, { dimColor: true, children: " no" })] }) })] })), askUserRequest && (_jsxs(Box, { flexDirection: "column", marginTop: 1, marginLeft: 2, children: [_jsx(Text, { color: "magenta", bold: true, children: "\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501 \u26A0 ANSWER REQUIRED \u26A0 \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501" }), _jsx(Text, { color: "cyan", children: "\u256D\u2500 Question \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }), _jsxs(Text, { color: "cyan", children: ["\u2502 ", _jsx(Text, { bold: true, children: askUserRequest.question })] }), askUserRequest.options && askUserRequest.options.length > 0 && (askUserRequest.options.map((opt, i) => (_jsxs(Text, { dimColor: true, children: ["\u2502 ", i + 1, ". ", opt] }, i)))), _jsx(Text, { color: "cyan", children: "\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }), _jsxs(Box, { marginLeft: 2, children: [_jsx(Text, { bold: true, children: "answer> " }), _jsx(TextInput, { value: askUserInput, onChange: setAskUserInput, onSubmit: (val) => {
1063
+ } }), permissionRequest && (_jsxs(Box, { flexDirection: "column", marginTop: 1, marginLeft: 2, children: [_jsx(Text, { color: "red", bold: true, children: "\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501 \u26A0 ACTION REQUIRED \u26A0 \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501" }), _jsx(Text, { color: "yellow", children: "\u256D\u2500 Permission required \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }), _jsxs(Text, { color: "yellow", children: ["\u2502 ", _jsx(Text, { bold: true, children: permissionRequest.toolName })] }), permissionRequest.description.split('\n').map((line, i) => (_jsxs(Text, { dimColor: true, children: ["\u2502 ", line] }, i))), _jsx(Text, { color: "yellow", children: "\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }), _jsx(Box, { marginLeft: 2, children: _jsxs(Text, { children: [_jsx(Text, { bold: true, color: "green", children: "[y]" }), _jsx(Text, { dimColor: true, children: " yes " }), _jsx(Text, { bold: true, color: "cyan", children: "[a]" }), _jsx(Text, { dimColor: true, children: " always " }), _jsx(Text, { dimColor: true, italic: true, children: "(saved across sessions)" }), _jsx(Text, { dimColor: true, children: " " }), _jsx(Text, { bold: true, color: "red", children: "[n]" }), _jsx(Text, { dimColor: true, children: " no" })] }) })] })), askUserRequest && (_jsxs(Box, { flexDirection: "column", marginTop: 1, marginLeft: 2, children: [_jsx(Text, { color: "magenta", bold: true, children: "\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501 \u26A0 ANSWER REQUIRED \u26A0 \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501" }), _jsx(Text, { color: "cyan", children: "\u256D\u2500 Question \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }), _jsxs(Text, { color: "cyan", children: ["\u2502 ", _jsx(Text, { bold: true, children: askUserRequest.question })] }), askUserRequest.options && askUserRequest.options.length > 0 && (askUserRequest.options.map((opt, i) => (_jsxs(Text, { dimColor: true, children: ["\u2502 ", i + 1, ". ", opt] }, i)))), _jsx(Text, { color: "cyan", children: "\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }), _jsxs(Box, { marginLeft: 2, children: [_jsx(Text, { bold: true, children: "answer> " }), _jsx(TextInput, { value: askUserInput, onChange: setAskUserInput, onSubmit: (val) => {
1064
1064
  // resolveAskUserAnswer translates "1" / "2" / ... into the
1065
1065
  // matching label string when the dialog showed a numbered
1066
1066
  // option list. Without it, every onAskUser caller's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.15.100",
3
+ "version": "3.15.102",
4
4
  "description": "Franklin Agent — The AI agent with a wallet. Spends USDC autonomously to get real work done. Pay per action, no subscriptions.",
5
5
  "type": "module",
6
6
  "exports": {