bloby-bot 0.29.0 → 0.30.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bloby-bot",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "releaseNotes": [
5
5
  "1. # voice note (PTT bubble)",
6
6
  "2. # audio file + caption",
@@ -1,23 +1,23 @@
1
1
  // Read on-chain USDC balances for one or more Tempo addresses.
2
2
  //
3
- // Fill in the CONFIG block below, then run from repo root:
4
- // cp scripts/tempo-balance.ts /tmp/tempo-transfer/ && \
5
- // (cd /tmp/tempo-transfer && ./node_modules/.bin/tsx tempo-balance.ts)
6
- //
7
- // (The script must be executed from inside the isolated /tmp/tempo-transfer/
8
- // install — the repo's own node_modules has a @noble/hashes version conflict
9
- // that breaks viem's ESM import resolution. To rebuild the isolated dir if
10
- // missing: mkdir -p /tmp/tempo-transfer && cd /tmp/tempo-transfer \
11
- // && npm init -y && npm install viem@2.47.6 tsx)
3
+ // RUN (one-liner, from repo root): cp scripts/tempo-balance.ts /tmp/tempo-transfer/ && (cd /tmp/tempo-transfer && ./node_modules/.bin/tsx tempo-balance.ts)
12
4
  //
5
+ // Edit the ACCOUNTS list in the CONFIG block below to add/remove wallets.
13
6
  // Each entry prints: USDC balance, nonce (0 = wallet has never sent a tx),
14
7
  // and whether the address is an EOA or a contract.
8
+ //
9
+ // Why the copy-into-/tmp dance: the repo's own node_modules has a
10
+ // @noble/hashes version conflict that breaks viem's ESM import resolution,
11
+ // so the script must run from the isolated /tmp/tempo-transfer/ install.
12
+ // To rebuild that dir if it's missing:
13
+ // mkdir -p /tmp/tempo-transfer && cd /tmp/tempo-transfer && npm init -y && npm install viem@2.47.6 tsx
15
14
 
16
15
  // ─── CONFIG ──────────────────────────────────────────────────────────────────
17
16
 
18
17
  const ACCOUNTS = [
19
- { label: 'Bot wallet', wallet: '0xc65d8a0dB80f637337B87e42b8BEb5D86D46f942' },
18
+ { label: 'Bloby', wallet: '0xc65d8a0dB80f637337B87e42b8BEb5D86D46f942' },
20
19
  { label: 'Treasury', wallet: '0x084A80e395FaE8Aaf58F591f47F63DA1EeF06bbC' },
20
+ { label: 'Serge', wallet: '0x21AC79F74da90c9F441699Ee6215dA32c0CE9F5a' },
21
21
  ];
22
22
 
23
23
  // ─────────────────────────────────────────────────────────────────────────────
@@ -1,9 +1,22 @@
1
1
  /**
2
2
  * Codex OAuth PKCE flow for ChatGPT Plus/Pro subscription authentication.
3
- * Adapted from CodeDeck's CodexOAuthService for server-side Node.js.
4
3
  *
5
4
  * Spins up a temporary HTTP server on port 1455 to capture the OAuth callback.
6
- * Credentials are stored in ~/.codex/codedeck-auth.json.
5
+ * Credentials are stored in ~/.codex/auth.json in the same shape Codex CLI
6
+ * itself writes, so a spawned `codex app-server` process can use them directly.
7
+ *
8
+ * Shape on disk (chatgpt mode):
9
+ * {
10
+ * "OPENAI_API_KEY": null,
11
+ * "auth_mode": "chatgpt",
12
+ * "tokens": {
13
+ * "id_token": "<raw jwt>",
14
+ * "access_token": "<jwt>",
15
+ * "refresh_token": "...",
16
+ * "account_id": "<chatgpt_account_id from id_token claims>"
17
+ * },
18
+ * "last_refresh": "2026-05-03T12:34:56.789Z"
19
+ * }
7
20
  */
8
21
 
9
22
  import crypto from 'crypto';
@@ -23,50 +36,124 @@ const OAUTH_CONFIG = {
23
36
  };
24
37
 
25
38
  const AUTH_DIR = path.join(os.homedir(), '.codex');
26
- const AUTH_FILE = path.join(AUTH_DIR, 'codedeck-auth.json');
39
+ const AUTH_FILE = path.join(AUTH_DIR, 'auth.json');
40
+ const LEGACY_AUTH_FILE = path.join(AUTH_DIR, 'codedeck-auth.json');
41
+
42
+ /** Refresh access tokens this many ms before they actually expire. */
43
+ const REFRESH_LEEWAY_MS = 5 * 60 * 1000;
27
44
 
28
45
  let codeVerifier: string | null = null;
29
46
  let oauthState: string | null = null;
30
47
  let callbackServer: http.Server | null = null;
31
48
 
32
- /* ── Helpers ── */
49
+ interface AuthDotJson {
50
+ OPENAI_API_KEY: string | null;
51
+ auth_mode?: 'apikey' | 'chatgpt' | 'chatgptAuthTokens' | 'agentIdentity';
52
+ tokens?: {
53
+ id_token: string;
54
+ access_token: string;
55
+ refresh_token: string;
56
+ account_id?: string | null;
57
+ };
58
+ last_refresh?: string;
59
+ [key: string]: unknown;
60
+ }
33
61
 
34
- function stopCallbackServer(): void {
35
- if (callbackServer) {
36
- try { callbackServer.close(); } catch {}
37
- callbackServer = null;
62
+ /* ── File I/O ── */
63
+
64
+ function readAuthFile(): AuthDotJson | null {
65
+ try {
66
+ if (!fs.existsSync(AUTH_FILE)) return null;
67
+ return JSON.parse(fs.readFileSync(AUTH_FILE, 'utf-8'));
68
+ } catch {
69
+ return null;
38
70
  }
39
71
  }
40
72
 
41
- function storeCredentials(tokens: any): void {
42
- if (!fs.existsSync(AUTH_DIR)) {
43
- fs.mkdirSync(AUTH_DIR, { recursive: true });
73
+ function writeAuthFile(auth: AuthDotJson): void {
74
+ fs.mkdirSync(AUTH_DIR, { recursive: true });
75
+ fs.writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2), 'utf-8');
76
+ try { fs.chmodSync(AUTH_FILE, 0o600); } catch {}
77
+ }
78
+
79
+ /** Decode a JWT and return its parsed payload, or null on failure. */
80
+ function decodeJwt(token: string): Record<string, any> | null {
81
+ try {
82
+ const parts = token.split('.');
83
+ if (parts.length < 2) return null;
84
+ return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8'));
85
+ } catch {
86
+ return null;
44
87
  }
88
+ }
45
89
 
46
- const credentials: Record<string, any> = {
47
- access_token: tokens.access_token,
48
- token_type: tokens.token_type || 'Bearer',
49
- };
90
+ /** Read the JWT `exp` claim as a Unix epoch (seconds). Null if missing/invalid. */
91
+ function jwtExpiryMs(token: string): number | null {
92
+ const payload = decodeJwt(token);
93
+ if (!payload || typeof payload.exp !== 'number') return null;
94
+ return payload.exp * 1000;
95
+ }
50
96
 
51
- if (tokens.refresh_token) credentials.refresh_token = tokens.refresh_token;
52
- if (tokens.id_token) credentials.id_token = tokens.id_token;
53
- if (tokens.expires_in) {
54
- credentials.expires_at = Date.now() + (tokens.expires_in - 300) * 1000;
97
+ /** Pull `chatgpt_account_id` out of the id_token JWT claims, if present. */
98
+ function extractAccountId(idToken: string): string | undefined {
99
+ const payload = decodeJwt(idToken);
100
+ const authClaims = payload?.['https://api.openai.com/auth'] || {};
101
+ return authClaims.chatgpt_account_id || undefined;
102
+ }
103
+
104
+ /** One-shot migration from the old `codedeck-auth.json` layout (pre-codex-native). */
105
+ function migrateLegacyFile(): void {
106
+ if (fs.existsSync(AUTH_FILE)) {
107
+ // If auth.json already has chatgpt tokens, nothing to do.
108
+ const existing = readAuthFile();
109
+ if (existing?.tokens?.refresh_token) return;
55
110
  }
111
+ if (!fs.existsSync(LEGACY_AUTH_FILE)) return;
56
112
 
57
- // Decode JWT claims for account info
58
113
  try {
59
- const payload = JSON.parse(Buffer.from(tokens.access_token.split('.')[1], 'base64url').toString());
60
- const authClaims = payload['https://api.openai.com/auth'] || {};
61
- if (authClaims.chatgpt_account_id) credentials.chatgpt_account_id = authClaims.chatgpt_account_id;
62
- if (authClaims.chatgpt_plan_type) credentials.chatgpt_plan_type = authClaims.chatgpt_plan_type;
63
- } catch {
64
- log.warn('Codex: failed to decode JWT claims');
114
+ const legacy = JSON.parse(fs.readFileSync(LEGACY_AUTH_FILE, 'utf-8'));
115
+ if (!legacy.access_token || !legacy.refresh_token) return;
116
+
117
+ const existing = readAuthFile() || { OPENAI_API_KEY: null };
118
+ const next: AuthDotJson = {
119
+ ...existing,
120
+ auth_mode: 'chatgpt',
121
+ tokens: {
122
+ id_token: legacy.id_token || '',
123
+ access_token: legacy.access_token,
124
+ refresh_token: legacy.refresh_token,
125
+ account_id: legacy.chatgpt_account_id || extractAccountId(legacy.access_token),
126
+ },
127
+ last_refresh: new Date().toISOString(),
128
+ };
129
+ writeAuthFile(next);
130
+ try { fs.unlinkSync(LEGACY_AUTH_FILE); } catch {}
131
+ log.ok('Codex: migrated legacy codedeck-auth.json → auth.json');
132
+ } catch (err: any) {
133
+ log.warn(`Codex: legacy migration failed — ${err.message}`);
65
134
  }
135
+ }
66
136
 
67
- fs.writeFileSync(AUTH_FILE, JSON.stringify(credentials, null, 2), 'utf-8');
68
- try { fs.chmodSync(AUTH_FILE, 0o600); } catch {}
69
- log.ok('Codex credentials stored');
137
+ /* ── Token exchange & refresh ── */
138
+
139
+ function storeTokens(tokens: { access_token: string; refresh_token?: string; id_token?: string }): void {
140
+ const existing = readAuthFile() || { OPENAI_API_KEY: null };
141
+ const prev = existing.tokens;
142
+ const idToken = tokens.id_token ?? prev?.id_token ?? '';
143
+
144
+ const next: AuthDotJson = {
145
+ ...existing,
146
+ OPENAI_API_KEY: existing.OPENAI_API_KEY ?? null,
147
+ auth_mode: 'chatgpt',
148
+ tokens: {
149
+ id_token: idToken,
150
+ access_token: tokens.access_token,
151
+ refresh_token: tokens.refresh_token ?? prev?.refresh_token ?? '',
152
+ account_id: idToken ? extractAccountId(idToken) ?? prev?.account_id : prev?.account_id,
153
+ },
154
+ last_refresh: new Date().toISOString(),
155
+ };
156
+ writeAuthFile(next);
70
157
  }
71
158
 
72
159
  async function exchangeCode(code: string): Promise<{ success: boolean; error?: string }> {
@@ -94,27 +181,61 @@ async function exchangeCode(code: string): Promise<{ success: boolean; error?: s
94
181
  }
95
182
 
96
183
  const tokens = await response.json();
97
- storeCredentials(tokens);
184
+ if (!tokens.access_token) {
185
+ return { success: false, error: 'OAuth response missing access_token.' };
186
+ }
187
+ storeTokens(tokens);
98
188
  codeVerifier = null;
99
189
  oauthState = null;
190
+ log.ok('Codex credentials stored');
191
+ // Clean up the legacy file on first successful new auth.
192
+ try { fs.unlinkSync(LEGACY_AUTH_FILE); } catch {}
100
193
  return { success: true };
101
194
  } catch (err: any) {
102
195
  return { success: false, error: err.message };
103
196
  }
104
197
  }
105
198
 
199
+ async function refreshTokens(refreshToken: string): Promise<boolean> {
200
+ try {
201
+ log.ok('Codex: refreshing access token...');
202
+ const response = await fetch(OAUTH_CONFIG.TOKEN_URL, {
203
+ method: 'POST',
204
+ headers: { 'Content-Type': 'application/json' },
205
+ body: JSON.stringify({
206
+ client_id: OAUTH_CONFIG.CLIENT_ID,
207
+ grant_type: 'refresh_token',
208
+ refresh_token: refreshToken,
209
+ }),
210
+ });
211
+ if (!response.ok) {
212
+ log.warn(`Codex: refresh failed (${response.status})`);
213
+ return false;
214
+ }
215
+ const tokens = await response.json();
216
+ if (!tokens.access_token) {
217
+ log.warn('Codex: refresh response missing access_token');
218
+ return false;
219
+ }
220
+ storeTokens(tokens);
221
+ log.ok('Codex: token refreshed');
222
+ return true;
223
+ } catch (err: any) {
224
+ log.warn(`Codex: refresh error — ${err.message}`);
225
+ return false;
226
+ }
227
+ }
228
+
106
229
  /* ── Public API ── */
107
230
 
108
231
  export function startCodexOAuth(): Promise<{ success: boolean; authUrl?: string; error?: string }> {
109
232
  return new Promise((resolve) => {
110
233
  stopCallbackServer();
111
234
 
112
- // Generate PKCE
113
235
  codeVerifier = crypto.randomBytes(32).toString('base64url');
114
236
  const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
115
237
  oauthState = crypto.randomUUID();
116
238
 
117
- // Start local callback server
118
239
  callbackServer = http.createServer((req, res) => {
119
240
  if (!req.url?.startsWith('/auth/callback')) {
120
241
  res.writeHead(404);
@@ -172,28 +293,71 @@ export function cancelCodexOAuth(): void {
172
293
  oauthState = null;
173
294
  }
174
295
 
175
- export function getCodexAuthStatus(): { authenticated: boolean; plan?: string; error?: string } {
296
+ export async function getCodexAuthStatus(): Promise<{
297
+ authenticated: boolean;
298
+ plan?: string;
299
+ error?: string;
300
+ }> {
176
301
  try {
177
- if (!fs.existsSync(AUTH_FILE)) return { authenticated: false };
178
- const creds = JSON.parse(fs.readFileSync(AUTH_FILE, 'utf-8'));
179
- if (!creds.access_token) return { authenticated: false };
180
- if (creds.expires_at && Date.now() >= creds.expires_at) {
181
- return { authenticated: false, error: 'Token expired' };
302
+ migrateLegacyFile();
303
+ const auth = readAuthFile();
304
+ const tokens = auth?.tokens;
305
+ if (!tokens?.access_token) return { authenticated: false };
306
+
307
+ const expMs = jwtExpiryMs(tokens.access_token);
308
+ const valid = expMs ? Date.now() + REFRESH_LEEWAY_MS < expMs : true;
309
+
310
+ if (!valid && tokens.refresh_token) {
311
+ const ok = await refreshTokens(tokens.refresh_token);
312
+ if (!ok) return { authenticated: false, error: 'Token expired and refresh failed' };
182
313
  }
183
- return { authenticated: true, plan: creds.chatgpt_plan_type || 'plus' };
314
+
315
+ const fresh = readAuthFile();
316
+ const idClaims = fresh?.tokens?.id_token ? decodeJwt(fresh.tokens.id_token) : null;
317
+ const plan = idClaims?.['https://api.openai.com/auth']?.chatgpt_plan_type || 'plus';
318
+ return { authenticated: true, plan };
184
319
  } catch (err: any) {
185
320
  return { authenticated: false, error: err.message };
186
321
  }
187
322
  }
188
323
 
324
+ /**
325
+ * Read a valid Codex access token, refreshing if expired. Returns null if unavailable.
326
+ * This is what the Codex harness should call before each app-server invocation.
327
+ */
328
+ export async function getCodexAccessToken(): Promise<string | null> {
329
+ migrateLegacyFile();
330
+ const auth = readAuthFile();
331
+ const tokens = auth?.tokens;
332
+ if (!tokens?.access_token) return null;
333
+
334
+ const expMs = jwtExpiryMs(tokens.access_token);
335
+ if (!expMs || Date.now() + REFRESH_LEEWAY_MS < expMs) {
336
+ return tokens.access_token;
337
+ }
338
+
339
+ if (!tokens.refresh_token) return null;
340
+ const ok = await refreshTokens(tokens.refresh_token);
341
+ if (!ok) return null;
342
+
343
+ return readAuthFile()?.tokens?.access_token ?? null;
344
+ }
345
+
346
+ /** Synchronous accessor — returns the stored access token without refreshing. */
189
347
  export function readCodexAccessToken(): string | null {
190
- try {
191
- if (!fs.existsSync(AUTH_FILE)) return null;
192
- const creds = JSON.parse(fs.readFileSync(AUTH_FILE, 'utf-8'));
193
- if (!creds.access_token) return null;
194
- if (creds.expires_at && Date.now() >= creds.expires_at) return null;
195
- return creds.access_token;
196
- } catch {
197
- return null;
348
+ const auth = readAuthFile();
349
+ const token = auth?.tokens?.access_token;
350
+ if (!token) return null;
351
+ const expMs = jwtExpiryMs(token);
352
+ if (expMs && Date.now() >= expMs) return null;
353
+ return token;
354
+ }
355
+
356
+ /* ── Helpers ── */
357
+
358
+ function stopCallbackServer(): void {
359
+ if (callbackServer) {
360
+ try { callbackServer.close(); } catch {}
361
+ callbackServer = null;
198
362
  }
199
363
  }
package/worker/index.ts CHANGED
@@ -9,7 +9,7 @@ import { initDb, closeDb, listConversations, createConversation, deleteConversat
9
9
  import webpush from 'web-push';
10
10
  import { TOTP } from 'otpauth';
11
11
  import QRCode from 'qrcode';
12
- import { startCodexOAuth, cancelCodexOAuth, getCodexAuthStatus, readCodexAccessToken } from './codex-auth.js';
12
+ import { startCodexOAuth, cancelCodexOAuth, getCodexAuthStatus } from './codex-auth.js';
13
13
  import { startClaudeOAuth, exchangeClaudeCode, getClaudeAuthStatus, readClaudeAccessToken } from './claude-auth.js';
14
14
  import { checkAvailability, registerHandle, claimReservedHandle, releaseHandle, updateTunnelUrl, startHeartbeat, stopHeartbeat } from '../shared/relay.js';
15
15
  import { ensureFileDirs } from '../supervisor/file-saver.js';
@@ -181,8 +181,8 @@ app.post('/api/auth/codex/cancel', (_req, res) => {
181
181
  res.json({ ok: true });
182
182
  });
183
183
 
184
- app.get('/api/auth/codex/status', (_req, res) => {
185
- res.json(getCodexAuthStatus());
184
+ app.get('/api/auth/codex/status', async (_req, res) => {
185
+ res.json(await getCodexAuthStatus());
186
186
  });
187
187
 
188
188
  // ── Claude OAuth routes ──
@@ -729,10 +729,10 @@ app.post('/api/onboard', (req, res) => {
729
729
  currentCfg.ai.model = model || '';
730
730
  currentCfg.ai.baseUrl = baseUrl || undefined;
731
731
 
732
- // Read OAuth token if no API key provided
733
- if (!apiKey && provider === 'openai') {
734
- currentCfg.ai.apiKey = readCodexAccessToken() || '';
735
- } else if (!apiKey && provider === 'anthropic') {
732
+ // OAuth providers store their tokens in their own credentials files
733
+ // (~/.codex/auth.json, ~/.claude/.credentials.json) and refresh them as
734
+ // needed — config.ai.apiKey only holds raw API keys.
735
+ if (!apiKey && provider === 'anthropic') {
736
736
  currentCfg.ai.apiKey = readClaudeAccessToken() || '';
737
737
  } else {
738
738
  currentCfg.ai.apiKey = apiKey || '';
@@ -335,6 +335,16 @@ MPPX_PRIVATE_KEY=$(jq -r .wallet.privateKey ~/.bloby/config.json) \
335
335
 
336
336
  If you see `ACCOUNT_NOT_FOUND - No account found.`, you forgot to set `MPPX_PRIVATE_KEY` — the CLI looked in its empty keychain. Re-run with the env var.
337
337
 
338
+ **Buying a skill, blueprint, or bundle autonomously.** Same shape as the service call — point mppx at `/api/marketplace/buy/<productId>` instead. The relay tries your owner's credits first, then falls back to MPP from your wallet. Returns `{ skills: [{ url, sha256, ... }] }` — follow each `url` to download the .tar.gz and extract to `skills/`.
339
+
340
+ ```bash
341
+ MPPX_PRIVATE_KEY=$(jq -r .wallet.privateKey ~/.bloby/config.json) \
342
+ npx -y mppx https://api.bloby.bot/api/marketplace/buy/<productId> \
343
+ -X POST -H "X-Bloby-Token: $RELAY_TOKEN"
344
+ ```
345
+
346
+ **Don't use `/marketplace/checkout/bot` for autonomous buys.** That endpoint is the cart-style flow — balance-only, no MPP fallback. If you hit it and credits are short you'll get a 402 with no way to retry. Always prefer `/marketplace/buy/:productId`.
347
+
338
348
  **Wallet on disk:** `~/.bloby/config.json` field `wallet` — `address` is public, `privateKey` is secret. Never print the private key in chat. The address is fine to share.
339
349
 
340
350
  **Running low?** Tell your human you need funds. Don't mention USDC, Tempo, or crypto unless they ask — to them, it's just adding dollars to your wallet via the "Add Funds" button in the chat header.
@@ -16,9 +16,9 @@ function DashboardError() {
16
16
  playsInline
17
17
  style={{ height: 120, width: 120, borderRadius: '50%', objectFit: 'cover', marginBottom: 32 }}
18
18
  />
19
- <h1 style={{ fontSize: 20, fontWeight: 600, marginBottom: 8 }}>Your app crashed</h1>
19
+ <h1 style={{ fontSize: 20, fontWeight: 600, marginBottom: 8 }}>Oopss.. Something wrong is not right</h1>
20
20
  <p style={{ fontSize: 14, color: 'rgba(255,255,255,0.5)', maxWidth: 320, lineHeight: 1.5 }}>
21
- Ask the agent to fix it using the chat.
21
+ If your agent is working, this is normal. If not, go poke them
22
22
  </p>
23
23
  </div>
24
24
  );