@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.
- package/dist/agent/permissions.d.ts +15 -0
- package/dist/agent/permissions.js +54 -1
- package/dist/stats/cost-log.d.ts +6 -0
- package/dist/stats/cost-log.js +63 -2
- package/dist/ui/app.js +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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');
|
package/dist/stats/cost-log.d.ts
CHANGED
|
@@ -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). */
|
package/dist/stats/cost-log.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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