cccc console
bluebut#53stalled
cb40ccb2-6ce4-41e8-8ae4-e3e938edd2bc · pbox · Jul 24, 03:03:45 PM → 26h ago
- team
- solo
- turns
- 43
- tools
- 42
- tokens
- 2.1M
- spend
- $1.89
- took
- 5m 4s
- model
- claude-opus-4-8
- errors
- 1
claude --resume cb40ccb2-6ce4-41e8-8ae4-e3e938edd2bclead agent
claude-opus-4-8
GOAL: fix GitLab issue #53 in devdashco/bluebut in an isolated worktree, push, LAND IT ON main, and close.
SUBAGENTS: spawn AT MOST 4 parallel subagent(s) (Task tool) at a time. NEVER fan heavy commands (installs, full test suites, builds) out across subagents in parallel — that is what melts the box.
You are in the devdashco/bluebut repository (you are already assigned to this issue). This repo
lives on our self-hosted GitLab (gitlab.hostbun.cc) — use the `glab` CLI for all issue /
MR work (it is installed and a…
Jul 24, 03:03:45 PM
Bash{"command": "pwd && git branch --show-current && PROG='python3 /home/philip/Documents/GitHub/claudectl/app/progress.py devdashco/bluebut 53' && $PROG read start && git fetch origin -q && git log --oneline origin/main..fix/issue-53 2>/dev/null && echo \"---MRs---\" && glab mr list --repo devdashco/bluebut --source-branch fix/issue-53 2>/dev/null", "description": "Confirm worktree, branch, existing work"}
/home/philip/Documents/GitHub/bluebut-issue-53 fix/issue-53 ---MRs--- No open merge requests match your search in devdashco/bluebut.
Read{"file_path": "/home/philip/Documents/GitHub/bluebut-issue-53/ui/src/pages/Accounts/useAccountActions.ts"}
1 // Accounts — the action layer extracted from Accounts.tsx: all the per-account
2 // verb handlers (Smart Load, mint, bank, type-login, auto-login, refresh, save
3 // creds, connect-page) plus the state they own (results, loading/typing flags,
4 // mint/proxy-pick state, in-app Control). The parent computes the read-side maps
5 // (sessByAcct, cloakByAcct, fleet…) and passes them in; this hook returns every
6 // piece of state + handler the parent's render / rows / detail view consume.
7 import { useState, useCallback, useRef, useEffect } from "react";
8 import toast from "react-hot-toast";
9 import { useMutation, type QueryClient } from "@tanstack/react-query";
10 import {
11 runCliLive, createAccount, upsertAccount, checkAccountsHealth, startProfile,
12 mintAccount, bankMintedSession, verifyProxy, setAccountProxy, typeLogin, autoLogin,
13 connectMessenger,
14 type Session, type ProxybunProxy, type ProxyCheck, type CloakboxRow, type CloakboxCatalog,
15 } from "../../api";
16 import { store } from "../../api/store";
17 import { useJobs } from "../../state/jobs";
18 import { accountLabel, isBurnedStatus } from "../../lib/util";
19 import { proxyEgressKey } from "./helpers";
20
21 type Sharer = { account: string; burned: boolean; status: string | null };
22 type ResultRow = { status: string; ip: string; country: string; display_name?: string; account_type?: string; error?: string; egress_ip?: string | null; via?: string; device?: { ua: string; platform: string } | null };
23
24 export interface UseAccountActionsArgs {
25 sessions: Session[];
26 sessByAcct: Record<string, Session>;
27 cloakByAcct: Record<string, CloakboxRow>;
28 cloakCat: CloakboxCatalog | undefined;
29 fleet: ProxybunProxy[];
30 acctsByProxyEgress: Record<string, Sharer[]>;
31 instByAcct: Record<string, { id: string | null; seed: string | null }>;
32 selectedAccount: string | null;
33 onSelectAccount?: (a: string | null) => void;
34 qcAccounts: QueryClient;
35 setShowAdd: (v: boolean) => void;
36 startJob: ReturnType<typeof useJobs>["startJob"];
37 }
38
39 export function useAccountActions(args: UseAccountActionsArgs) {
40 const {
41 sessions, sessByAcct, cloakByAcct, cloakCat, fleet, acctsByProxyEgress,
42 instByAcct, selectedAccount, onSelectAccount, qcAccounts, setShowAdd, startJob,
43 } = args;
44
45 const [loadingAcct, setLoadingAcct] = useState<string | null>(null);
46 const [typingAcct, setTypingAcct] = useState<string | null>(null);
47 const [autoLoginAcct, setAutoLoginAcct] = useState<string | null>(null);
48 const [savingAcct, setSavingAcct] = useState<string | null>(null);
49 const [results, setResults] = useState<Record<string, ResultRow>>({});
50
51 // ── Add-account modal state ────────────────────────────────────────────
52 const [adding, setAdding] = useState(false);
53 const [addForm, setAddForm] = useState({ account: "", display_name: "", fb_email: "", fb_phone: "", fb_password: "" });
54 const addSlugValid = /^[a-z0-9][a-z0-9_-]{1,39}$/.test(addForm.account);
55 const addSlugDup = sessions.some(s => s.account === addForm.account);
56
57 // ── Guided-Load / proxy state ──────────────────────────────────────────
58 const [mintSlug, setMintSlug] = useState("");
59 const [mintProxyId, setMintProxyId] = useState(""); // fleet proxy id chosen for a proxyless account
60 const [minting, setMinting] = useState(false);
61 const [mintErr, setMintErr] = useState<string | null>(null);
62 const [banking, setBanking] = useState(false);
63 const [bankErr, setBankErr] = useState<string | null>(null);
64 const [showProxyPick, setShowProxyPick] = useState(false);
65 const [mintedSeed, setMintedSeed] = useState<{ acct: string; seed: string } | null>(null);
66 const [loadedSeed, setLoadedSeed] = useState<{ acct: string; seed: string } | null>(null);
67 const [mintProxyCheck, setMintProxyCheck] = useState<ProxyCheck | null>(null);
68 const [verifyingProxy, setVerifyingProxy] = useState(false);
69 const mintProxy = fleet.find(p => p.id === mintProxyId) || null;
70 // Request token: only the LATEST verify may apply its verdict.
71 const verifySeq = useRef(0);
72 const doVerifyProxy = useCallback(async (id: string) => {
73 if (!id) return;
74 const seq = ++verifySeq.current;
75 setVerifyingProxy(true); setMintErr(null);
76 let result: ProxyCheck;
77 try { result = await verifyProxy(id); }
78 catch (e) { result = { ok: false, id, egress_ip: null, reputation: null, error: e instanceof Error ? e.message : String(e) }; }
79 if (seq !== verifySeq.current) return; // a newer verify superseded this one — drop the stale verdict
80 setMintProxyCheck(result);
81 setVerifyingProxy(false);
82 }, []);
83 // Any change to the chosen proxy (or account) invalidates a prior verification
84 // and AUTO-fires a fresh live test — no manual "Test proxy" click.
85 useEffect(() => {
86 setMintProxyCheck(null);
87 if (mintProxyId) void doVerifyProxy(mintProxyId);
88 }, [mintProxyId, mintSlug, doVerifyProxy]);
89 const mintProxyVerified = !!mintProxyCheck && mintProxyCheck.ok && mintProxyCheck.id === mintProxyId;
90 const mintProxyEgress = proxyEgressKey(mintProxy?.host, mintProxy?.port);
91 const mintProxySharers = (mintProxyEgress ? (acctsByProxyEgress[mintProxyEgress] ?? []).filter(x => x.account !== mintSlug) : []);
92
93 async function submitAdd() {
94 if (!addSlugValid || addSlugDup || adding) return;
95 const acct = addForm.account;
96 setAdding(true);
97 const job = startJob({
98 label: `Add account · ${acct}`, account: acct,
99 site: "facebook", cmd: "POST /accounts/create", args: [],
100 });
101 try {
102 job.event("Creating account — keyvault stores identity (mint a device to run it)");
103 await createAccount(acct, {
104 display_name: addForm.display_name.trim() || undefined,
105 fb_email: addForm.fb_email.trim() || undefined,
106 fb_phone: addForm.fb_phone.trim() || undefined,
107 fb_password: addForm.fb_password || undefined,
108 });
109 await Promise.all([
110 qcAccounts.invalidateQueries({ queryKey: ["sessions"] }),
111 qcAccounts.invalidateQueries({ queryKey: ["cloakbox"] }),
112 ]);
113 job.succeed(`added — mint a Windows device to make it runnable`);
114 setShowAdd(false);
115 setAddForm({ account: "", display_name: "", fb_email: "", fb_phone: "", fb_password: "" });
116 } catch (e) {
117 job.fail(String(e));
118 }
119 setAdding(false);
120 }
121
122 // Inline edits (password / email / phone) — one mutation backs all four fields.
123 const saveAccountField = useMutation({
124 mutationFn: (vars: { acct: string; patch: Record<string, unknown> }) => upsertAccount(vars.acct, vars.patch),
125 onSuccess: () => { qcAccounts.invalidateQueries({ queryKey: ["sessions"] }); },
126 });
127 async function saveField(acct: string, patch: Record<string, unknown>) {
128 try {
129 await saveAccountField.mutateAsync({ acct, patch });
130 } catch (e) {
131 console.error(e);
132 toast.error(`Couldn’t save ${acct}: ${(e as Error)?.message || e}`);
133 }
134 }
135 async function savePassword(acct: string, val: string) {
136 await saveField(acct, { fb_password: val || null });
137 }
138 async function saveIdField(acct: string, field: "fb_email" | "fb_phone", val: string) {
139 await saveField(acct, { [field]: val.trim() || null });
140 }
141 async function saveCode(acct: string, val: string) {
142 await saveField(acct, { fb_2fa_code: val.trim() || null });
143 }
144 async function saveTotp(acct: string, val: string) {
145 await saveField(acct, { fb_totp_secret: val.trim() || null });
146 }
147
148 // Per-profile refresh — re-derives EVERY live truth for ONE account.
149 const [refreshingAcct, setRefreshingAcct] = useState<string | null>(null);
150 async function refreshAcct(acct: string) {
151 if (refreshingAcct) return;
152 setRefreshingAcct(acct);
153 try {
154 const { accounts } = await checkAccountsHealth(acct);
155 const a = accounts[0];
156 if (a) {
157 setResults(prev => ({
158 ...prev,
159 [acct]: {
160 status: a.status,
161 ip: a.egress_ip ?? (a.via === "proxy" ? "proxy" : "box"),
162 country: a.error ? `err: ${a.error.slice(0, 40)}` : "—",
163 display_name: a.name ?? undefined,
164 egress_ip: a.egress_ip ?? null,
165 via: a.via,
166 device: a.device ?? null,
167 error: a.error ?? undefined,
168 },
169 }));
170 qcAccounts.invalidateQueries({ queryKey: ["sessions"] }); // sticky-green writeback may have bumped last_status
171 qcAccounts.invalidateQueries({ queryKey: ["accountIntel"] }); // re-read jar + FB-confirmed date → fresh Cookie-truth + Verified
172 }
173 } catch (e) {
174 setResults(prev => ({ ...prev, [acct]: { status: "error", ip: "—", country: "—", error: String(e).slice(0, 80) } }));
175 } finally {
176 setRefreshingAcct(null);
177 }
178 }
179
180 // ── In-app Control of the account's live cloakbox Chrome ────────────────
181 const [controlling, setControlling] = useState(false);
182 const wantControlRef = useRef<string | null>(null);
183 const autoControlledRef = useRef<string | null>(null);
184 useEffect(() => {
185 autoControlledRef.current = null; // new navigation → allow one auto-open decision
186 if (selectedAccount && wantControlRef.current === selectedAccount) {
187 setControlling(true);
188 wantControlRef.current = null;
189 autoControlledRef.current = selectedAccount; // came from a live Browser click → handled
190 } else {
191 setControlling(false);
192 }
193 }, [selectedAccount]);
194 // AUTO-CHECK: landing on an account that ALREADY has a live cloakbox browser
195 // opens Control on its own — no manual click.
196 useEffect(() => {
197 if (!selectedAccount || autoControlledRef.current === selectedAccount) return;
198 if (instByAcct[selectedAccount]?.seed) {
199 autoControlledRef.current = selectedAccount;
200 setControlling(true);
201 }
202 }, [selectedAccount, instByAcct]);
203 // Open the account's LIVE cloakbox window in the IN-APP Control (HTML
204 // screencast) — NOT a noVNC tab. Navigate to the account's detail page and
205 // auto-enable Control on arrival (or toggle it on if already there).
206 const onOpenBrowser = useCallback((row: { account: string }) => {
207 if (selectedAccount === row.account) { setControlling(true); return; }
208 wantControlRef.current = row.account;
209 onSelectAccount?.(row.account);
210 }, [selectedAccount, onSelectAccount]);
211
212 // Connect — a direct cdp_load that pins the live tab into the Control mirror.
213 async function connectSession(acct: string) {
214 setLoadingAcct(acct);
215 setControlling(true);
216 try {
217 const r = await startProfile(acct, "https://www.facebook.com/");
218 if (r.ok) {
219 if (r.seed) setLoadedSeed({ acct, seed: r.seed });
220 setResults(prev => ({ ...prev, [acct]: { status: "loaded", ip: "?", country: "?" } }));
221 } else {
222 toast.error(`Load failed: ${r.error ?? "unknown"}`);
223 setResults(prev => ({ ...prev, [acct]: { status: "error", ip: "—", country: "—", error: r.error } }));
224 }
225 } catch (e) {
226 toast.error(String(e));
227 setResults(prev => ({ ...prev, [acct]: { status: "error", ip: "—", country: "—", error: String(e) } }));
228 }
229 setLoadingAcct(null);
230 }
231
232 // ── Smart Load — the ONE guided "get me driving this account" verb ─────────
233 function smartLoad(acct: string) {
234 const s = sessByAcct[acct];
235 if (isBurnedStatus(s?.last_status) || isBurnedStatus(s?.kv_status) || !!s?.checkpoint_type) {
236 toast.error(`${acct} is burned — clear the FB wall over VNC before loading.`);
237 return;
238 }
239 if (!s?.proxy) {
240 // Step 1 — no egress IP. Open the picker; continueLoad fires on assign.
241 setMintSlug(acct); setMintProxyId(""); setMintProxyCheck(null); setShowProxyPick(true);
242 return;
243 }
244 void continueLoad(acct);
245 }
246 // Steps 2–4, after a proxy is guaranteed.
247 async function continueLoad(acct: string) {
248 const cb = cloakByAcct[acct];
249 if (cloakCat?.enabled && (cb?.device_count ?? 0) === 0) { await smartMint(acct); return; }
250 await connectSession(acct);
251 }
252 // Step 3 — mint a Windows device on the account's pinned proxy, then drop the
253 // operator into the in-app Control mirror on FB's login (creds pre-filled).
254 const mintTypeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
255 useEffect(() => () => { if (mintTypeTimer.current) clearTimeout(mintTypeTimer.current); }, []);
256 async function smartMint(acct: string) {
257 if (minting) return;
258 setControlling(true);
259 setMinting(true); setMintErr(null);
260 const job = startJob({ label: `Mint & load · ${acct}`, account: acct, site: "facebook", cmd: "POST /accounts/mint", args: ["--account", acct] });
261 job.event("Minting a Windows cloakbox device on the account's proxy…");
262 try {
263 const r = await mintAccount(acct);
264 setMintedSeed({ acct, seed: r.seed });
265 qcAccounts.invalidateQueries({ queryKey: ["cloakbox"] });
266 job.warn(`device minted (seed ${r.seed}) — log in in the Control mirror, then Save cookies`);
267 toast.success(`${acct}: device minted — finish login in Control below, then Save cookies.`);
268 if (mintTypeTimer.current) clearTimeout(mintTypeTimer.current);
269 mintTypeTimer.current = setTimeout(() => { void typeLoginInto(acct); }, 1800);
270 } catch (e) {
271 const msg = e instanceof Error ? e.message : String(e);
272 setMintErr(msg); job.fail(msg);
273 toast.error(`Mint failed for ${acct}: ${msg}`);
274 }
275 setMinting(false);
276 }
277 // Bank the live cookie jar after the inline Control login. This banks COOKIES
278 // only (the reliable live capture); the udd on-disk session converges separately
279 // on stop/heartbeat — hence the "Save cookies" label, not "Save session".
280 async function saveMintedSession(acct: string) {
281 const seed = mintedSeed?.acct === acct ? mintedSeed.seed : null;
282 if (!seed || banking) return;
283 setBanking(true); setBankErr(null);
284 try {
285 await bankMintedSession(acct, seed);
286 toast.success(`${acct}: cookies banked ✓ — device is now runnable (udd converges on stop).`);
287 setMintedSeed(null);
288 qcAccounts.invalidateQueries({ queryKey: ["sessions"] });
289 qcAccounts.invalidateQueries({ queryKey: ["cloakbox"] });
290 qcAccounts.invalidateQueries({ queryKey: ["accountIntel"] }); // re-read jar so the cookie/jar panel isn't stale after Save session
291 } catch (e) {
292 const msg = e instanceof Error ? e.message : String(e);
293 setBankErr(msg);
294 toast.error(`Save cookies failed: ${msg}`);
295 }
296 setBanking(false);
297 }
298 // ── Live-browser verbs sitting next to Load (issue #48) ─────────────────────
299 const [savingSessionAcct, setSavingSessionAcct] = useState<string | null>(null);
300 const [connectingE2eeAcct, setConnectingE2eeAcct] = useState<string | null>(null);
301 const [checkingExtAcct, setCheckingExtAcct] = useState<string | null>(null);
302
303 // Save session — bank the LIVE cookie jar of THIS account's running browser to the
304 // store WITHOUT stopping it (POST /api/store/profiles/:id/save → orchestrator
305 // /api/save). Cookies only; the udd converges on stop/heartbeat. Needs a live browser.
306 async function saveLiveSession(acct: string) {
307 if (savingSessionAcct) return;
308 const pid = instByAcct[acct]?.id ?? null;
309 if (!pid) { toast.error(`${acct}: no live browser to save — Load it first.`); return; }
310 setSavingSessionAcct(acct);
311 try {
312 const r = await store.profiles.save(pid);
313 if (r.ok) {
314 toast.success(`${acct}: session saved ✓ — ${r.cookies_banked} cookies banked${r.has_datr ? " (datr present)" : ""}.`);
315 qcAccounts.invalidateQueries({ queryKey: ["sessions"] });
316 qcAccounts.invalidateQueries({ queryKey: ["accountIntel"] });
317 } else {
318 toast.error(`${acct}: save reported not-ok — check the browser is still live.`);
319 }
320 } catch (e) {
321 toast.error(`Save session failed for ${acct}: ${(e as Error)?.message || e}`);
322 }
323 setSavingSessionAcct(null);
324 }
325
326 // Create E2E socket — (re)connect this account's encrypted-messaging (whatsmeow)
327 // socket BROWSER-FREE via the stored CAT (POST /messenger/connect → e2ee-keepalive
328 // adapter, --account --force). Fire-and-forget: the job shows in the Jobs UI + the
329 // socket comes up on the next socket-status poll. NEVER a burn signal (inv #5).
330 async function connectE2ee(acct: string) {
331 if (connectingE2eeAcct) return;
332 setConnectingE2eeAcct(acct);
333 const t = toast.loading(`Creating E2E socket · ${acct} — reconnecting via stored CAT (browser-free)…`);
334 try {
335 await connectMessenger(acct);
336 toast.success(`${acct}: E2E connect triggered — watch the socket status come up (browser-free).`, { id: t });
337 } catch (e) {
338 toast.error(`E2E connect failed for ${acct}: ${(e as Error)?.message || e}`, { id: t });
339 }
340 setConnectingE2eeAcct(null);
341 }
342
343 // Check extension — is the capture harvester loaded in THIS account's running browser
344 // actually alive/reachable? Reads the orchestrator's per-browser heartbeat snapshot
345 // (GET /api/store/harvester/diag) and reports the verdict for this account's profile.
346 async function checkExtension(acct: string) {
347 if (checkingExtAcct) return;
348 const pid = instByAcct[acct]?.id ?? null;
349 if (!pid) { toast.error(`${acct}: no live browser — Load it first, then check the extension.`); return; }
350 setCheckingExtAcct(acct);
351 try {
352 const d = await store.harvester.diag();
353 const b = d.browsers.find(x => x.profile_id === pid);
354 if (!b) {
355 toast.error(`${acct}: extension NOT reporting — no harvester heartbeat for this browser (never loaded / crashed).`);
356 } else if (b.live) {
357 toast.success(`${acct}: extension connected ✓ — beat ${b.beat_age_s}s ago${b.ext_version ? `, v${b.ext_version}` : ""}${b.captures != null ? `, ${b.captures} captures` : ""}.`);
358 } else {
359 toast.error(`${acct}: extension STALE — last heartbeat ${b.beat_age_s}s ago (> ${d.beat_live_s}s live window). It may have crashed — try reloading the browser.`);
360 }
361 } catch (e) {
362 toast.error(`Extension check failed for ${acct}: ${(e as Error)?.message || e}`);
363 }
364 setCheckingExtAcct(null);
365 }
366
367 // Assign the chosen fleet proxy to the account (keyvault) and resume the chain.
368 async function assignProxyAndResume(acct: string, proxyId: string) {
369 const p = fleet.find(x => x.id === proxyId);
370 if (!p) return;
371 try {
372 await setAccountProxy(acct, { scheme: p.scheme ?? "http", host: p.host, port: p.port, username: p.username ?? "", password: p.password ?? "" });
373 await qcAccounts.invalidateQueries({ queryKey: ["sessions"] });
374 toast.success(`Proxy ${p.host}:${p.port} assigned to ${acct}.`);
375 setShowProxyPick(false);
376 void continueLoad(acct);
377 } catch (e) {
378 toast.error(`Proxy assign failed: ${(e as Error)?.message || e}`);
379 }
380 }
381
382 // Type login — fill the account's stored creds into its LIVE cloakbox window.
383 async function typeLoginInto(acct: string) {
384 if (typingAcct) return;
385 setTypingAcct(acct);
386 try {
387 const r = await typeLogin(acct);
388 toast.success(`Typed ${r.typed.join(" + ")} into ${acct}'s window (port ${r.instance}) — review & click "Logga in" over VNC.`);
389 } catch (e) {
390 toast.error(`Type login failed: ${(e as Error)?.message || e}`);
391 }
392 setTypingAcct(null);
393 }
394
395 // Auto login — the FULL flow on the account's live cloakbox window.
396 async function autoLoginInto(acct: string) {
397 if (autoLoginAcct) return;
398 setAutoLoginAcct(acct);
399 const t = toast.loading(`Auto-login · ${acct} — filling, submitting, handling 2FA…`);
400 try {
401 const r = await autoLogin(acct);
402 if (r.logged_in) {
403 const banked = r.saved ? "cookies banked ✓" : "⚠ NOT banked (cdp_save failed) — try Mint → Save";
404 toast.success(`${acct} logged in${r.fb_id ? ` (fb_id ${r.fb_id})` : ""}${r.twofa ? ` · 2FA ${r.twofa}` : ""} · ${banked}`, { id: t });
405 qcAccounts.invalidateQueries({ queryKey: ["sessions"] });
406 qcAccounts.invalidateQueries({ queryKey: ["cloakbox"] });
407 refreshAcct(acct);
408 } else if (r.outcome === "needs_2fa") {
409 toast.error(`${acct}: 2FA code required but none stored — add a TOTP secret or code under Credentials, or finish over VNC.`, { id: t });
410 } else if (r.outcome === "captcha") {
411 toast.error(`${acct}: captcha — solve it over VNC (Operations → Open live desktop), then Auto login again.`, { id: t });
412 } else if (r.outcome === "checkpoint") {
413 toast.error(`${acct}: FB checkpoint — finish it over VNC. If it asks for a selfie/ID it's a hard wall.`, { id: t });
414 } else {
415 toast.error(`${acct}: not logged in (${r.outcome}) — finish over VNC.`, { id: t });
416 }
417 } catch (e) {
418 toast.error(`Auto-login failed: ${(e as Error)?.message || e}`, { id: t });
419 }
420 setAutoLoginAcct(null);
421 }
422
423 // Connect a PAGE (pointer model) — run the act-as verb AS the manager.
424 async function savePageSession(pg: Session) {
425 const manager = pg.managing_account;
426 const pageId = pg.fb_id;
427 if (!manager || !pageId) { return; }
428 const mgrStatus = sessByAcct[manager]?.last_status ?? null;
429 setSavingAcct(pg.account);
430 const job = startJob({
431 label: `Connect page · ${pg.display_name ?? pageId}`, account: manager,
432 site: "facebook", cmd: "whoami", args: ["--account", manager, "--as-page", String(pageId)],
433 });
434 job.event(`Act as Page ${pg.display_name ?? pageId} via manager ${accountLabel(manager, sessions)}`);
435 try {
436 if (mgrStatus !== "logged_in") {
437 job.event(`manager ${accountLabel(manager, sessions)} is ${mgrStatus ?? "unknown"} → connecting it first`);
438 const m = await runCliLive("facebook", "load-session", ["--account", manager, "--auto-reauth", "true"], { timeout: 180, jobId: job.id, onEvent: job.event });
439 if (m.status !== "logged_in") {
440 job.fail(`manager ${accountLabel(manager, sessions)} could not log in (status=${m.status ?? "?"}) — a Page can't be acted-as without a logged-in manager`, { diagnostic: m.diagnostic });
441 setSavingAcct(null); return;
442 }
443 job.event(`manager logged in (fb_id=${m.fb_id ?? "?"}) → switching into Page`);
444 }
445 const r = await runCliLive("facebook", "whoami", ["--account", manager, "--as-page", String(pageId)], { timeout: 180, jobId: job.id, onEvent: job.event })Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-53/ui && grep -n \"startProfile\\|login_ok\\|cookies_injected\" src/api.ts src/api/*.ts 2>/dev/null | head -40", "description": "Find startProfile definition"}
src/api/instances.ts:137:export async function startProfile(account: string, url: string): Promise<CloakOpenResult> {Read{"file_path": "/home/philip/Documents/GitHub/bluebut-issue-53/ui/src/api/instances.ts", "offset": 80, "limit": 120}
80 // download (Lane B) = full-fidelity: download each closed .har.gz, stream-parse, run
81 // ALL wires on ALL layers untruncated. Default is the download lane.
82 u.searchParams.set("mode", opts.mode ?? "download");
83 if (API_TOKEN) u.searchParams.set("token", API_TOKEN);
84 return u.toString();
85 }
86 // LIVE fleet harvest — every open wiretap session with its full-harvest-layer
87 // counts (HTTP/GraphQL · document bodies · WebSocket frames). Powers the Accounts
88 // page's floating "Live wiretap" card. Refetch on a short interval for a pulse.
89 export interface WiretapLiveSession {
90 id: string; seed: string | null; account: string | null; slug: string | null;
91 egress: string | null; proxy: string | null; started_at: string | null;
92 http: number | null; ws: number | null; docBodies: number | null;
93 live: boolean; lastActivity: string | null;
94 }
95 export interface WiretapLive {
96 ok: boolean;
97 sessions: WiretapLiveSession[];
98 totals: { sessions: number; http: number; ws: number; docBodies: number } | null;
99 error?: string;
100 }
101 export async function getWiretapLive(): Promise<WiretapLive> {
102 try {
103 const r = await fetch(`${API}/wiretap/live`, { headers: bridgeAuth() });
104 if (!r.ok) return { ok: false, sessions: [], totals: null };
105 return await r.json();
106 } catch { return { ok: false, sessions: [], totals: null }; }
107 }
108 // Hand ONE running browser to the operator on its own display; the backend calls
109 // cdp_focus and returns a self-contained noVNC viewer_url (autoconnect+password).
110 // Prefer the instance PORT (a profile + its act-as-page twin share a seed).
111 // Open ONE FB url INSIDE a chosen account's trusted cloakbox browser (cdp_load
112 // with goto=url), then focus it onto its own noVNC display. The "Open on Facebook"
113 // verb: a bare facebook.com tab is logged-out — the operator wants to see the URL
114 // AS the account. Returns the focused viewer_url (wrap in /desktop for the label).
115 // Slow (~several s: it boots a Windows browser) — callers show a spinner.
116 // Resolve a caller-supplied HANDLE (slug, profile_id, or legacy seed) → the store's
117 // profile_id. The /api/store surface is keyed by profile_id (invariant: the outside sees
118 // profiles), but the UI still holds slugs/seeds in places — one lookup bridges them until
119 // the seed→profile_id purge lands. Returns null if nothing matches.
120 async function resolveStoreProfileId(handle: string): Promise<string | null> {
121 const r = await fetch(`${API}/api/store/profiles`, { headers: bridgeAuth() });
122 if (!r.ok) return null;
123 const d = await r.json().catch(() => null) as { ok?: boolean; profiles?: Array<Record<string, unknown>> } | null;
124 const list = d?.profiles ?? [];
125 const h = String(handle);
126 const hit = list.find((p) =>
127 p.id === h || p.slug === h ||
128 (p.fingerprint && typeof p.fingerprint === "object" && String((p.fingerprint as { seed?: unknown }).seed) === h));
129 return hit ? String(hit.id) : null;
130 }
131
132 export interface CloakOpenResult { ok: boolean; account?: string; seed?: string | null; viewer_url?: string | null; url?: string; error?: string }
133 // Load an account's trusted browser and return its per-profile noVNC viewer. Repointed to
134 // the /api/store engine seam: resolve slug→profile_id, POST /profiles/:id/start (launch),
135 // hand back the view_url. The engine renders the RESTORED session; the operator drives the
136 // target url in the noVNC view (the old cloakbox/open + in-browser goto is retired).
137 export async function startProfile(account: string, url: string): Promise<CloakOpenResult> {
138 try {
139 const pid = await resolveStoreProfileId(account);
140 if (!pid) return { ok: false, account, error: `no store profile for "${account}"` };
141 // 90s cap — booting the browser is slow but must never spin "Booting…" forever.
142 // `goto` = where to land AFTER cookies are injected — else the browser parks on the
143 // egress-check page (api.ipify.org) instead of the account. The engine navigates there
144 // post-inject, so the account loads logged-in.
145 const r = await fetch(`${API}/api/store/profiles/${encodeURIComponent(pid)}/start`, {
146 method: "POST", headers: { ...bridgeAuth(), "Content-Type": "application/json" },
147 body: JSON.stringify({ goto: url }),
148 signal: AbortSignal.timeout(90000),
149 });
150 const d = await r.json().catch(() => ({ ok: false, error: `bad response (${r.status})` }));
151 if (!r.ok && !d?.error) return { ok: false, error: `open failed (${r.status})` };
152 return { ok: !!d?.ok, account, viewer_url: d?.view_url ?? null, url, error: d?.error };
153 } catch (e) {
154 const msg = (e as Error)?.name === "TimeoutError"
155 ? "load timed out after 90s — the engine never responded (profile/render/proxy unreachable?). Check the backend logs."
156 : String((e as Error)?.message || e);
157 return { ok: false, error: msg };
158 }
159 }
160 // Return the per-profile noVNC viewer for an already-loaded browser. Repointed to
161 // GET /api/store/profiles/:id/view — the operator watches/drives it in the noVNC view
162 // (in-place CDP navigation is retired; the outside sees profiles, not seeds).
163 export async function gotoProfile(seed: string, url: string): Promise<{ ok: boolean; url?: string; error?: string }> {
164 try {
165 const pid = await resolveStoreProfileId(seed);
166 if (!pid) return { ok: false, error: `no store profile for "${seed}"` };
167 const r = await fetch(`${API}/api/store/profiles/${encodeURIComponent(pid)}/view`, { headers: bridgeAuth() });
168 const d = await r.json().catch(() => ({ ok: false, error: `bad response (${r.status})` }));
169 return { ok: !!d?.ok, url: d?.view_url ?? url, error: d?.error };
170 } catch (e) { return { ok: false, error: String((e as Error)?.message || e) }; }
171 }
172 export async function getWiretapDiagnostics(session: string): Promise<{ ok: boolean; diagnostics: unknown }> {
173 try {
174 const r = await fetch(`${API}/wiretap/diagnostics?session=${encodeURIComponent(session)}`, { headers: bridgeAuth() });
175 if (!r.ok) return { ok: false, diagnostics: null };
176 return await r.json();
177 } catch { return { ok: false, diagnostics: null }; }
178 }
179
180 // LIVE cloakbox browser instances — the cloakbox replacement for the retired
181 // chromebox /chrome/profiles. One row per running Chrome on the box. Powers the
182 // Browser wall.
183 export interface CloakInstance {
184 account: string | null; // 'facebook/philip' | null (scratch)
185 slug: string | null; // 'philip'
186 service: string | null; // 'facebook'
187 seed: string | null; // fingerprint — SHARED by a profile and its act-as-page twin
188 udd: string | null; // user-data-dir = UNIQUE instance key (twin: 88521__p<pageid>). The screencast handle.
189 port: number | null;
190 device: string | null;
191 owner: string | null;
192 live: boolean;
193 pages: number;
194 proxy: string | null;
195 }
196 export async function listRunningProfiles(): Promise<{ ok: boolean; instances: CloakInstance[] }> {
197 try {
198 // Repointed to the /api/store profile repository — the RUNNING profiles ARE the live
199 // instances (invariant: the outside sees profiles, not seeds/udds). The screencast handleRead{"file_path": "/home/philip/Documents/GitHub/bluebut-issue-53/ui/src/api/store.ts"}
1 // api/store — the typed client for the /api/store Hono surface (the identity
2 // store: profiles + accounts + proxies · Target Architecture §6c).
3 //
4 // EVERY shape here is z.infer'd from @bluebut/contract — the same schemas the
5 // backend PARSES its responses through — so the two sides cannot disagree
6 // (invariant #20). No hand-written response interfaces in this file, ever.
7 // When a legacy ui/src/api/index.ts caller is repointed at /api/store, its fetch
8 // moves here and its hand-written interface dies.
9 import {
10 ListProfilesResponse, GetProfileResponse, StartProfileResponse, StopProfileResponse,
11 ViewProfileResponse, SaveProfileResponse, HarvesterDiagResponse,
12 ListAccountsResponse, GetAccountResponse, ListInstancesResponse,
13 ListCaptureProfilesResponse, GetCaptureSummaryResponse, LiveRegistryResponse,
14 StartSchemaEngineResponse, SchemaEngineStatusResponse,
15 GetFlowsResponse, type FlowGraph, type FlowGraphNode, type FlowGraphEdge, type FlowNodeLive, type FlowBatches,
16 CanvasDoc, GetCanvasResponse, type CanvasDoc as CanvasDocT,
17 type Profile, type Account, type CreateProfileRequest, type UpdateProfileRequest,
18 type UpsertAccountRequest, type RunningInstance, type CaptureProfile, type CaptureRow,
19 type LiveBrowser,
20 type SchemaEngineStatusResponse as SchemaEngineStatus,
21 } from "@bluebut/contract";
22 import { API } from "./index";
23 import { env } from "../env";
24
25 // Mirror the backend token fallback (API_TOKEN || BRIDGE_TOKEN) and _core.ts: prod
26 // bakes the UI token under VITE_BRIDGE_TOKEN, so read that too or /api/store 401s.
27 const API_TOKEN: string = env("VITE_API_TOKEN") || env("VITE_BRIDGE_TOKEN");
28 const auth = (): Record<string, string> => (API_TOKEN ? { Authorization: `Bearer ${API_TOKEN}` } : {});
29
30 async function call<T>(parse: { parse: (v: unknown) => T }, path: string, init?: RequestInit & { timeoutMs?: number }): Promise<T> {
31 const r = await fetch(`${API}/api/store${path}`, {
32 ...init,
33 headers: { ...auth(), ...(init?.body ? { "content-type": "application/json" } : {}), ...init?.headers },
34 signal: AbortSignal.timeout(init?.timeoutMs ?? 30_000),
35 });
36 const body: unknown = await r.json();
37 if (!r.ok) {
38 const msg = (body as { error?: string })?.error ?? `HTTP ${r.status}`;
39 throw new Error(`${path}: ${msg}`);
40 }
41 return parse.parse(body); // schema-parse IN too — drift is loud on both sides
42 }
43
44 export type { Profile, Account, RunningInstance, CaptureProfile, CaptureRow, LiveBrowser };
45 export type { FlowGraph, FlowGraphNode, FlowGraphEdge, FlowNodeLive, FlowBatches };
46 export type { CanvasDocT as CanvasDoc };
47
48 export const store = {
49 // The acquisition flows as a live node-graph (green/red per element). Poll for real-time.
50 flows: () => call(GetFlowsResponse, "/flows"),
51 // The editable canvas overlay — user positions/groups/edges/notes, persisted whole.
52 canvas: (id = "default") => call(GetCanvasResponse, `/canvas/${id}`),
53 saveCanvas: (doc: CanvasDocT, id = "default") =>
54 call(CanvasDoc, `/canvas/${id}`, { method: "PUT", body: JSON.stringify(doc) }),
55 profiles: {
56 list: (filter?: { accountId?: string; running?: boolean }) => {
57 const q = new URLSearchParams();
58 if (filter?.accountId) q.set("accountId", filter.accountId);
59 if (filter?.running !== undefined) q.set("running", String(filter.running));
60 const qs = q.toString();
61 return call(ListProfilesResponse, `/profiles${qs ? `?${qs}` : ""}`).then((r) => r.profiles);
62 },
63 get: (id: string) => call(GetProfileResponse, `/profiles/${id}`).then((r) => r.profile),
64 create: (req: CreateProfileRequest) =>
65 call(GetProfileResponse, "/profiles", { method: "POST", body: JSON.stringify(req) }).then((r) => r.profile),
66 update: (id: string, patch: UpdateProfileRequest) =>
67 call(GetProfileResponse, `/profiles/${id}`, { method: "PATCH", body: JSON.stringify(patch) }).then((r) => r.profile),
68 start: (id: string) => call(StartProfileResponse, `/profiles/${id}/start`, { method: "POST" }),
69 stop: (id: string) => call(StopProfileResponse, `/profiles/${id}/stop`, { method: "POST" }),
70 view: (id: string) => call(ViewProfileResponse, `/profiles/${id}/view`).then((r) => r.view_url),
71 /** Bank the LIVE cookie jar (carries datr) of a running browser WITHOUT stopping it. Cookies only. */
72 save: (id: string) => call(SaveProfileResponse, `/profiles/${id}/save`, { method: "POST" }),
73 },
74 /** Capture-extension health per live browser — the harvester heartbeat snapshot (Check-extension verb). */
75 harvester: {
76 diag: () => call(HarvesterDiagResponse, "/harvester/diag"),
77 },
78 /** Every RUNNING browser (the Browser-wall feed) — engine truth, per-profile noVNC view_url. */
79 instances: {
80 list: () => call(ListInstancesResponse, "/instances").then((r) => r.running),
81 },
82 /** NATIVE wiretap view — what the engine's CDP tap banked into cloak_captures. */
83 captures: {
84 profiles: () => call(ListCaptureProfilesResponse, "/captures/profiles").then((r) => r.profiles),
85 summary: (id: string) => call(GetCaptureSummaryResponse, `/captures/${id}`),
86 },
87 /** Live harvest health per browser — heartbeat ⊕ DB truth (the green-light source). */
88 live: () => call(LiveRegistryResponse, "/live").then((r) => r.browsers),
89 /** Wire-schema census over cloak_captures — runs in the background; start then poll status. */
90 schemaEngine: {
91 start: () => call(StartSchemaEngineResponse, "/schema-engine/run", { method: "POST" }),
92 status: (): Promise<SchemaEngineStatus> => call(SchemaEngineStatusResponse, "/schema-engine/status"),
93 },
94 accounts: {
95 list: (service = "facebook") =>
96 call(ListAccountsResponse, `/accounts?service=${encodeURIComponent(service)}`).then((r) => r.accounts),
97 upsert: (req: UpsertAccountRequest) =>
98 call(GetAccountResponse, "/accounts", { method: "POST", body: JSON.stringify(req) }).then((r) => r.account),
99 setStatus: (id: string, status: string) =>
100 call(GetAccountResponse, `/accounts/${id}`, { method: "PATCH", body: JSON.stringify({ status }) }).then((r) => r.account),
101 },
102 };
103Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-53 && grep -rn \"StartProfileResponse\\|login_ok\\|cookies_injected\" --include=*.ts packages/ shared/ contract/ 2>/dev/null | grep -i \"start\\|login_ok\\|cookies_inj\" | head -30", "description": "Find StartProfileResponse contract"}
shared/contract/index.ts:106:export const StartProfileResponse = z.object({
shared/contract/index.ts:113:export type StartProfileResponse = z.infer<typeof StartProfileResponse>;Read{"file_path": "/home/philip/Documents/GitHub/bluebut-issue-53/shared/contract/index.ts", "offset": 95, "limit": 80}
95 });
96 export type CreateProfileRequest = z.infer<typeof CreateProfileRequest>;
97 export const UpdateProfileRequest = z.object({
98 fingerprint: FingerprintRecipe.optional(),
99 proxy: z.union([z.string(), z.object({ url: z.string() }), z.null()]).optional(),
100 labels: z.record(z.string(), z.unknown()).optional(),
101 status: z.string().optional(),
102 });
103 export type UpdateProfileRequest = z.infer<typeof UpdateProfileRequest>;
104
105 // POST /api/store/profiles/:id/start → the engine launch result the UI needs
106 export const StartProfileResponse = z.object({
107 ok: z.literal(true),
108 cdp_url: z.string(),
109 view_url: z.string(),
110 run_id: z.string().optional(),
111 egress_ip: z.string().nullable().optional(),
112 });
113 export type StartProfileResponse = z.infer<typeof StartProfileResponse>;
114 export const StopProfileResponse = z.object({ ok: z.literal(true) });
115 export type StopProfileResponse = z.infer<typeof StopProfileResponse>;
116
117 // DELETE /api/store/profiles/:id
118 export const DeleteProfileResponse = z.object({ ok: z.literal(true) });
119 export type DeleteProfileResponse = z.infer<typeof DeleteProfileResponse>;
120 export const ViewProfileResponse = z.object({ ok: z.literal(true), view_url: z.string() });
121 export type ViewProfileResponse = z.infer<typeof ViewProfileResponse>;
122
123 // POST /api/store/profiles/:id/save → the engine banks the LIVE cookie jar to the
124 // store WITHOUT stopping the browser (orchestrator POST /api/save). Cookies only —
125 // the udd on-disk session converges separately on stop/heartbeat.
126 export const SaveProfileResponse = z.object({
127 ok: z.boolean(),
128 profile_id: z.string(),
129 cookies_banked: z.number(),
130 has_datr: z.boolean(),
131 kept_alive: z.boolean(),
132 });
133 export type SaveProfileResponse = z.infer<typeof SaveProfileResponse>;
134
135 // GET /api/store/harvester/diag → the capture-extension health snapshot the
136 // orchestrator self-reports per live browser (GET /api/harvester/diag). Used by the
137 // "Check extension" button to prove the harvester loaded in a browser is alive.
138 export const HarvesterDiagBrowser = z.object({
139 profile_id: z.string(),
140 run_id: z.string().nullable().optional(),
141 beat_age_s: z.number(),
142 live: z.boolean(),
143 ext_version: z.string().nullable().optional(),
144 captures: z.number().nullable().optional(),
145 session_ok: z.boolean().nullable().optional(),
146 diag: z.unknown().optional(),
147 });
148 export type HarvesterDiagBrowser = z.infer<typeof HarvesterDiagBrowser>;
149 export const HarvesterDiagResponse = z.object({
150 ok: z.literal(true),
151 now: z.number(),
152 beat_live_s: z.number(),
153 count: z.number(),
154 browsers: z.array(HarvesterDiagBrowser),
155 });
156 export type HarvesterDiagResponse = z.infer<typeof HarvesterDiagResponse>;
157
158 // GET /api/store/accounts · POST /api/store/accounts (upsert) · PATCH /api/store/accounts/:id
159 export const ListAccountsResponse = z.object({ ok: z.literal(true), accounts: z.array(Account) });
160 export type ListAccountsResponse = z.infer<typeof ListAccountsResponse>;
161 export const GetAccountResponse = z.object({ ok: z.literal(true), account: Account });
162 export type GetAccountResponse = z.infer<typeof GetAccountResponse>;
163 export const UpsertAccountRequest = z.object({
164 service: z.string().optional(),
165 login: z.string(),
166 email: z.string().nullable().optional(),
167 phone: z.string().nullable().optional(),
168 status: z.string().optional(),
169 labels: z.record(z.string(), z.unknown()).optional(),
170 });
171 export type UpsertAccountRequest = z.infer<typeof UpsertAccountRequest>;
172 export const PatchAccountRequest = z.object({ status: z.string() });
173 export type PatchAccountRequest = z.infer<typeof PatchAccountRequest>;
174Bash{"command": "grep -rn \"login_ok\\|cookies_injected\" --include=*.ts --include=*.py server/ orchestrator/ 2>/dev/null | head -30; echo \"---all---\"; grep -rln \"login_ok\\|cookies_injected\" . 2>/dev/null | grep -v node_modules | head -30", "description": "Find login_ok in server code"}
server/store/engine.ts:38: cookies_injected: z.number().nullable().optional(), server/store/engine.ts:39: login_ok: z.boolean().nullable().optional(), ---all--- cloakbox/orchestrator/launcher.py cloakbox/orchestrator/session_capture.py cloakbox/orchestrator/api.py server/store/engine.ts .claude/skills/job-architecture/SKILL.md .claude/skills/run-preamble/SKILL.md scripts/_lib/adapter-gates.ts scripts/_lib/adapter-gates.test.mjs scripts/_lib/adapter-setup.ts
Read{"file_path": "/home/philip/Documents/GitHub/bluebut-issue-53/server/store/engine.ts", "offset": 20, "limit": 120}
20 // required (nullable where the value can legitimately be null), so a rename that drops a key is a LOUD
21 // throw at the seam, not a silent wrong value. Python enforces the SAME shape via `response_model=`
22 // (StartResp/SaveResp in cloakbox/orchestrator/api.py) — one contract, validated on both sides.
23 // `.loose()` keeps forward-compatible extra keys from failing the parse.
24 export const EngineStartResponse = z
25 .object({
26 run_id: z.string(),
27 profile_id: z.string(),
28 cdp_url: z.string(),
29 view_url: z.string(),
30 gate: z.string().nullable(),
31 egress_ip: z.string().nullable(),
32 /** True iff the CDP wiretap tap attached (CLOAKBOX_CAPTURE=1). False = nothing is being captured. */
33 capture: z.boolean(),
34 port: z.number().nullable().optional(),
35 render: z.string().nullable().optional(),
36 vnc_port: z.number().nullable().optional(),
37 /** COUNT of cookies injected into the live browser from the banked jar (0 = none/no jar). Not a boolean. */
38 cookies_injected: z.number().nullable().optional(),
39 login_ok: z.boolean().nullable().optional(),
40 })
41 .loose()
42 export type EngineStart = z.infer<typeof EngineStartResponse>
43
44 export const EngineSaveResponse = z
45 .object({
46 ok: z.boolean(),
47 profile_id: z.string(),
48 cookies_banked: z.number(),
49 has_datr: z.boolean(),
50 kept_alive: z.boolean(),
51 })
52 .loose()
53 export type EngineSave = z.infer<typeof EngineSaveResponse>
54
55 // Per-path response validators. `call()` runs the matching schema (loud BoxDownError on drift) so
56 // EVERY caller of the engine seam — engine.start here AND cloak-client-core.ts's raw call — is guarded
57 // in ONE place. Dynamic paths (/api/view/<id>) are unvalidated by design.
58 const RESPONSE_SCHEMAS: Record<string, z.ZodType> = {
59 '/api/start': EngineStartResponse,
60 '/api/save': EngineSaveResponse,
61 }
62
63 export interface EngineRunning {
64 profile_id: string
65 status: string
66 labels: Record<string, unknown>
67 run_id: string
68 port: number | null
69 egress_ip: string | null
70 gate: string | null
71 started_at: string
72 proxy: string | null
73 view_url: string
74 }
75
76 // The ONE orchestrator HTTP client (invariant #3). Exported so adapters/_lib/cloak-client.ts
77 // delegates here instead of reimplementing the fetch/timeout/error dance (was a copy-paste twin).
78 export async function call<T>(method: 'GET' | 'POST', path: string, body?: Record<string, unknown>): Promise<T> {
79 let res: Response
80 try {
81 res = await fetch(`${BASE}${path}`, {
82 method,
83 headers: { ...(body ? { 'content-type': 'application/json' } : {}) },
84 body: body ? JSON.stringify(body) : undefined,
85 signal: AbortSignal.timeout(150_000), // a cold cloakserve spawn + gate is legitimately slow
86 })
87 } catch (e) {
88 throw new BoxDownError(
89 `engine ${method} ${path}: unreachable (${e instanceof Error ? e.message : e}) — is the orchestrator up on ${BASE}?`,
90 { cause: e, meta: { base: BASE } },
91 )
92 }
93 const text = await res.text().catch(() => '')
94 let json: Record<string, unknown>
95 try { json = text ? JSON.parse(text) : {} } catch { throw new BoxDownError(`engine ${path}: non-JSON — ${text.slice(0, 160)}`) }
96 if (!res.ok) throw new BoxDownError(`engine ${path}: HTTP ${res.status} — ${json.detail || text.slice(0, 200)}`)
97 // #188.1: validate the cross-runtime response shape (the Python dict) instead of blind-casting, so a
98 // renamed/dropped key on the orchestrator side is a loud failure here — not a silent undefined.
99 const schema = RESPONSE_SCHEMAS[path]
100 if (schema) {
101 const parsed = schema.safeParse(json)
102 if (!parsed.success) {
103 const issues = parsed.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`).join('; ')
104 throw new BoxDownError(
105 `engine ${path}: response contract drift — ${issues}. Got keys: [${Object.keys(json).join(', ')}]. ` +
106 'The orchestrator (cloakbox/orchestrator/api.py) response shape and this schema disagree.',
107 )
108 }
109 return parsed.data as T
110 }
111 return json as T
112 }
113
114 export const engine = {
115 /** Launch a profile → { cdp_url, view_url, run_id, gate, egress_ip }. */
116 start: (profileId: string, opts: Record<string, unknown> = {}) =>
117 call<EngineStart>('POST', '/api/start', { profile_id: profileId, ...opts }),
118 /** Bank the LIVE cookie jar to the store WITHOUT stopping the browser (§7b.8). Cookies only. */
119 save: (profileId: string) => call<EngineSave>('POST', '/api/save', { profile_id: profileId }),
120 /** Snapshot the session + end the run. */
121 stop: (profileId: string) => call<{ ok: boolean }>('POST', '/api/stop', { profile_id: profileId }),
122 /** Capture-extension health per live browser — the harvester's self-reported heartbeat snapshot. */
123 harvesterDiag: () => call<{ now: number; beat_live_s: number; count: number; browsers: unknown[] }>('GET', '/api/harvester/diag'),
124 /** Running profiles (profile_id, run, proxy, gate, labels, per-profile view_url). */
125 list: () => call<{ running: EngineRunning[] }>('GET', '/api/list').then((r) => r.running),
126 /** Live registry — every browser's harvester heartbeat merged with DB capture/session truth. */
127 live: () => call<{ now: number; beat_live_s: number; count: number; browsers: unknown[] }>('GET', '/api/live'),
128 /** The per-profile noVNC url (+ raises that browser's window). */
129 view: (profileId: string) => call<{ view_url: string }>('GET', `/api/view/${profileId}`),
130 /** Liveness + store reachability. */
131 health: () => call<{ ok: boolean }>('GET', '/api/health'),
132 }
133Bash{"command": "grep -rn \"profiles/:id/start\\|/start\\b\\|StartProfileResponse\\|view_url\" server/store/*.ts | grep -i \"start\\|route\\|post\" | head; echo \"---\"; ls server/store/", "description": "Find store start route"}
server/store/engine.ts:59: '/api/start': EngineStartResponse,
server/store/engine.ts:117: call<EngineStart>('POST', '/api/start', { profile_id: profileId, ...opts }),
---
accounts-read.ts
accounts.ts
captures.ts
config.ts
contract.test.mjs
db.ts
engine.ts
profiles.ts
schema-engine.ts
tombstone.test.mjs
workflows.test.mjs
workflows.tsBash{"command": "grep -n \"start\\|view_url\\|egress_ip\\|login_ok\\|cookies_injected\\|StartProfileResponse\\|\\.post(\" server/store/profiles.ts | head -40", "description": "Find start handler in profiles.ts"}
80: /** slug → profile_id — the resolution "Load account philip" does before engine.start(). */ 82: // NOT_DELETED — a tombstoned account (issue #22) must not resolve to a startable
Bash{"command": "grep -rn \"/start\\|engine.start\\|view_url\\|profiles/:id\\|StartProfileResponse\" server/**/*.ts 2>/dev/null | grep -iv \"test\" | head -30", "description": "Find route mapping engine.start"}
server/store/engine.ts:4:// is the one place backend code reaches the engine — routes call engine.start(profileId), never the
server/store/engine.ts:29: view_url: z.string(),
server/store/engine.ts:56:// EVERY caller of the engine seam — engine.start here AND cloak-client-core.ts's raw call — is guarded
server/store/engine.ts:59: '/api/start': EngineStartResponse,
server/store/engine.ts:73: view_url: string
server/store/engine.ts:115: /** Launch a profile → { cdp_url, view_url, run_id, gate, egress_ip }. */
server/store/engine.ts:117: call<EngineStart>('POST', '/api/start', { profile_id: profileId, ...opts }),
server/store/engine.ts:124: /** Running profiles (profile_id, run, proxy, gate, labels, per-profile view_url). */
server/store/engine.ts:129: view: (profileId: string) => call<{ view_url: string }>('GET', `/api/view/${profileId}`),
server/store/profiles.ts:80: /** slug → profile_id — the resolution "Load account philip" does before engine.start(). */Bash{"command": "grep -rln \"engine.start\\|/profiles/:id/start\\|StartProfileResponse\\|goto\" server/ | grep -v node_modules; echo \"---routes---\"; grep -rn \"profiles\" server/routes*.ts server/index.ts server/app.ts 2>/dev/null | head", "description": "Find where start route lives"}
server/http/routes/cloakbox.ts server/http/routes/store.ts server/http/routes/accounts-mint.ts server/store/engine.ts server/store/profiles.ts server/http/index.mjs ---routes---
Bash{"command": "grep -n \"start\\|engine.start\\|view_url\\|goto\\|login_ok\\|cookies_injected\\|StartProfileResponse\" server/http/routes/store.ts | head -40", "description": "Find start route in store.ts"}
30: StartProfileResponse, StopProfileResponse, DeleteProfileResponse, ViewProfileResponse,
47:import { startSchemaEngineRun, schemaEngineStatus } from "../../store/schema-engine.ts";
154:// "Load account philip" = resolve slug→profile_id, then POST /profiles/:id/start.
186:app.post("/profiles/:id/start", async (c) => {
187: // `goto` = where to land after the engine injects the banked cookies. Without it the
190: const body = await c.req.json().catch(() => ({} as { goto?: string }));
191: const goto = typeof body?.goto === "string" && body.goto ? body.goto : "https://www.facebook.com/";
192: const r = await engine.start(c.req.param("id"), { goto });
193: return c.json(StartProfileResponse.parse({ ok: true, ...r }));
211: return c.json(ViewProfileResponse.parse({ ok: true, view_url: r.view_url }));
215:// from the engine's /api/list (open run + per-profile noVNC view_url; the
253: return c.json(StartSchemaEngineResponse.parse({ ok: true, ...startSchemaEngineRun() }));
314:// the pure executor) persists that pin onto a `workflow_run` row via workflowRuns.start/
333: const runId = await workflowRuns.start(w);Bash{"command": "cd ui && grep -rn \"socket\\|e2ee\\|messenger\\|connectMessenger\" src/api.ts src/api/*.ts | head -30", "description": "Find messenger/socket status api"}
ugrep: warning: src/api.ts: No such file or directory
src/api/catalog.ts:7: site: string; // facebook | messenger — which adapter tree
src/api/har-traces.ts:91: socketCount: number; frameTotal: number;
src/api/har-traces.ts:92: byTransport: Record<string, number>; methods: Record<string, number>; sockets: WsSocket[];
src/api/har-traces.ts:105: return d.socketCount ? d : null;
src/api/db-types.gen.ts:281: is_messenger_user: boolean | null;
src/api/db-types.gen.ts:615: is_e2ee: boolean | null;
src/api/jobs.ts:294: // accepts then hangs (mid-deploy half-open socket) leaves the fetch unsettled, so
src/api/jobs.ts:348:// Live whatsmeow socket status per account — is the encrypted-messaging socket
src/api/jobs.ts:350:// any last error. Proxied by the backend off the bluebut-e2ee daemon's /accounts.
src/api/jobs.ts:355: // When the current unbroken whatsmeow socket came up (daemon-tracked). Uptime =
src/api/jobs.ts:356: // now − connected_since. null when the socket is down (or a pre-uptime daemon).
src/api/jobs.ts:361: // Stored CAT (bluebut/e2ee-cat/<slug>): fresh = younger than the 20h reuse window
src/api/jobs.ts:371: const r = await fetch(`${API}/messenger/socket-status`, { headers: bridgeAuth(), signal: AbortSignal.timeout(12_000) });
src/api/jobs.ts:372: if (!r.ok) throw new Error(`/messenger/socket-status HTTP ${r.status}`);
src/api/jobs.ts:376:// the check that catches a half-dead socket (connected but can't send). `peer`
src/api/jobs.ts:393: const r = await fetch(`${API}/messenger/health?${qs.toString()}`, { headers: bridgeAuth(), signal: AbortSignal.timeout(25_000) });
src/api/jobs.ts:395: if (!r.ok && !("alive" in d)) throw new Error(d.error || `/messenger/health HTTP ${r.status}`);
src/api/jobs.ts:398:// Create/(re)connect ONE account's encrypted-messaging (whatsmeow) socket, BROWSER-FREE
src/api/jobs.ts:399:// via the stored CAT — the "Create E2E socket" operator verb. Triggers the e2ee-keepalive
src/api/jobs.ts:401:// and the socket comes up on the next socket-status poll. Never a burn signal (inv #5).
src/api/jobs.ts:402:export async function connectMessenger(account: string): Promise<{ ok: boolean; triggered?: boolean; error?: string }> {
src/api/jobs.ts:403: const r = await fetch(`${API}/messenger/connect`, {
src/api/jobs.ts:408: if (!r.ok) throw new Error(d?.error || `/messenger/connect HTTP ${r.status}`);
src/api/entities.ts:27: e2ee?: boolean; // a bluebut-e2ee device is registered for this account (can decrypt/send type-15 encrypted Messenger). false/unset = not set up.
src/api/entities.ts:28: cat_present?: boolean; // a CAT ignition ticket is stored (keyvault bluebut/e2ee-cat/<slug>) — needed to open the encrypted socket.
src/api/entities.ts:246:// Messenger box — mirrors the messenger tables in db/schema.ts. A thread is
src/api/entities.ts:277: is_e2ee: boolean | null; // encrypted — decrypted by the bluebut-e2ee daemonRead{"file_path": "/home/philip/Documents/GitHub/bluebut-issue-53/ui/src/api/jobs.ts", "offset": 346, "limit": 70}
346 };
347
348 // Live whatsmeow socket status per account — is the encrypted-messaging socket
349 // connected right now (receiving in realtime), when did it last see an event, and
350 // any last error. Proxied by the backend off the bluebut-e2ee daemon's /accounts.
351 export interface SocketStatusAccount {
352 slug: string;
353 connected: boolean;
354 fbid: number | string | null;
355 // When the current unbroken whatsmeow socket came up (daemon-tracked). Uptime =
356 // now − connected_since. null when the socket is down (or a pre-uptime daemon).
357 connected_since: string | null;
358 last_event_at: string | null;
359 last_error: string | null;
360 wa_device_id: number | null;
361 // Stored CAT (bluebut/e2ee-cat/<slug>): fresh = younger than the 20h reuse window
362 // → the daemon can reconnect this account browser-free. null = no CAT ever stored.
363 cat: { minted_at: string | null; age_ms: number | null; fresh: boolean } | null;
364 }
365 export interface SocketStatus {
366 accounts: SocketStatusAccount[];
367 connected: number;
368 total: number;
369 }
370 export async function fetchSocketStatus(): Promise<SocketStatus> {
371 const r = await fetch(`${API}/messenger/socket-status`, { headers: bridgeAuth(), signal: AbortSignal.timeout(12_000) });
372 if (!r.ok) throw new Error(`/messenger/socket-status HTTP ${r.status}`);
373 return r.json();
374 }
375 // TRUE 100% liveness — a usync round-trip on the daemon (no message, no browser),
376 // the check that catches a half-dead socket (connected but can't send). `peer`
377 // (a numeric fbid) also verifies that recipient is reachable on encrypted chat.
378 export interface MessengerHealth {
379 ok: boolean;
380 slug?: string;
381 fbid?: number | string | null;
382 alive: boolean; // usync round-tripped → send+receive genuinely work
383 devices?: number; // our own device count returned by the usync
384 reason?: string; // why alive=false (e.g. "usync failed: … timeout")
385 peer_reachable?: boolean; // present only when peer= was passed
386 peer_devices?: number;
387 peer_reason?: string;
388 error?: string;
389 }
390 export async function verifyMessenger(account: string, peer?: string): Promise<MessengerHealth> {
391 const qs = new URLSearchParams({ account });
392 if (peer) qs.set("peer", peer);
393 const r = await fetch(`${API}/messenger/health?${qs.toString()}`, { headers: bridgeAuth(), signal: AbortSignal.timeout(25_000) });
394 const d = await r.json().catch(() => ({}));
395 if (!r.ok && !("alive" in d)) throw new Error(d.error || `/messenger/health HTTP ${r.status}`);
396 return d as MessengerHealth;
397 }
398 // Create/(re)connect ONE account's encrypted-messaging (whatsmeow) socket, BROWSER-FREE
399 // via the stored CAT — the "Create E2E socket" operator verb. Triggers the e2ee-keepalive
400 // adapter scoped to this account (--account <slug> --force); the job shows in the Jobs UI
401 // and the socket comes up on the next socket-status poll. Never a burn signal (inv #5).
402 export async function connectMessenger(account: string): Promise<{ ok: boolean; triggered?: boolean; error?: string }> {
403 const r = await fetch(`${API}/messenger/connect`, {
404 method: "POST", headers: { ...bridgeAuth(), "Content-Type": "application/json" },
405 body: JSON.stringify({ account }), signal: AbortSignal.timeout(15_000),
406 });
407 const d = await r.json().catch(() => ({}));
408 if (!r.ok) throw new Error(d?.error || `/messenger/connect HTTP ${r.status}`);
409 return d as { ok: boolean; triggered?: boolean };
410 }
411
412 // Operator stop — kill a running/queued bridge job by id. The bridge SIGKILLs
413 // the spawned runner/agent child (or dequeues it if it hasn't started), which
414 // terminates the FB automation mid-flight. Used by the Stop buttons in the jobs
415 // dock + Jobs page.Bash{"command": "cd ui && grep -rn \"results\\[\" src/pages/Accounts/*.tsx | head; echo \"---status vals---\"; grep -rn \"\\.status ===\\|status ==\\|'loaded'\\|\\\"loaded\\\"\\|status:\" src/pages/Accounts/*.tsx | grep -i \"loaded\\|error\\|status ==\" | head -20", "description": "Check how result status is rendered"}
/bin/bash: line 1: cd: ui: No such file or directory
---status vals---
src/pages/Accounts/AccountDetail.tsx:21:type ResultRow = { status: string; ip: string; country: string; display_name?: string; account_type?: string; error?: string; egress_ip?: string | null; via?: string; device?: { ua: string; platform: string } | null };Bash{"command": "grep -rn \"results\\b\\|\\.status\" src/pages/Accounts/AccountDetail.tsx src/pages/Accounts/AccountRow.tsx 2>/dev/null | grep -i \"result\\|status\" | head -25", "description": "Check result status render"}
src/pages/Accounts/AccountDetail.tsx:27: results: Record<string, ResultRow>; groupCounts: Record<string, { total: number; admin: number }>;
src/pages/Accounts/AccountDetail.tsx:52: sessions, pageSessions, byManager, account, results, groupCounts, accountInfo, proxyHostOwner,
src/pages/Accounts/AccountDetail.tsx:349: res={results[acct]}Bash{"command": "grep -rn \"res\\b\\|res\\.\\|res?\\.\" src/pages/Accounts/AccountDetail.tsx | grep -i \"res\" | sed -n '1,30p'; echo \"===grep component using res===\"; grep -rln \"res:.*ResultRow\\|res?: ResultRow\\|res: ResultRow\" src/pages/Accounts/*.tsx", "description": "Find res consumer"}
src/pages/Accounts/AccountDetail.tsx:349: res={results[acct]}
===grep component using res===Read{"file_path": "/home/philip/Documents/GitHub/bluebut-issue-53/ui/src/pages/Accounts/AccountDetail.tsx", "offset": 335, "limit": 25}
335 <span style={{ fontSize: 11, color: "var(--color-faint)" }}>checks proxy → mints a device if needed → spawns Chrome.</span>
336 </div>
337 )}
338 </div>
339 )}
340 {isBurned && (
341 <Alert type="error">
342 <strong>FB has this account walled{wall ? ` (${wall})` : ""}.</strong> {sess.checkpoint_type ? "A hard identity wall — this account can't run and can't be recovered by automation." : "Dead until a human clears the wall."} The Cloakbox verdict + Load below are unusable while burned.
343 </Alert>
344 )}
345 <div style={{ marginTop: 14 }}>
346 <AccountCard
347 acct={acct}
348 sess={sess}
349 res={results[acct]}
350 groups={groupCounts[acct] ?? { total: 0, admin: 0 }}
351 isActive={account === acct}
352 loading={loadingAcct === acct}
353 // Load = the ONE guided verb: proxy? (add-proxy window) → device?
354 // (mint inline) → load-session + Control. Always runnable.
355 onConnect={() => smartLoad(acct)}
356 onRefresh={() => refreshAcct(acct)}
357 refreshing={refreshingAcct === acct}
358 onTypeLogin={() => typeLoginInto(acct)}
359 typingLogin={typingAcct === acct}Bash{"command": "grep -n \"res\\b\\|res\\.\\|res?\\.\" src/pages/Accounts/AccountCard.tsx | head -30", "description": "Find AccountCard res usage"}
ugrep: warning: src/pages/Accounts/AccountCard.tsx: No such file or directory
Bash{"command": "grep -rln \"AccountCard\" src/ ; echo \"---\"; f=$(grep -rln \"function AccountCard\\|const AccountCard\\|export.*AccountCard\" src/); echo \"file=$f\"; grep -n \"res\" $f | head -30", "description": "Locate AccountCard and res usage"}
src/components/AccountCard.tsx
src/components/StatusChip.tsx
src/components/AccountSwitcher.tsx
src/components/ProxyPanel/helpers.tsx
src/components/ui/copy.tsx
src/components/AccountCard/parts.tsx
src/components/ui/fields.tsx
src/pages/Jobs.tsx
src/pages/Accounts.tsx
src/pages/Accounts/AccountDetail.tsx
---
file=src/components/AccountCard.tsx
23: egress_ip?: string | null; via?: string; device?: { ua: string; platform: string } | null;
24: rotated?: string[]; // auth cookies FB renewed on the probe → jar refreshed, not just checked
30: res?: LiveResult;
44: // Refresh = the headless cookie probe for THIS account: replays the jar
45: // against facebook.com through the account's own proxy (real egress IP) with
47: onRefresh: () => void;
48: refreshing?: boolean;
55: // Auto login = the FULL flow on the live window: fill → press Login → enter a
68: // Proxy is MANDATORY to connect: FB sessions must egress through the account's
80: /** Live whatsmeow (encrypted Messenger) socket + stored-CAT freshness. NON-stale
81: * reality: connected NOW, when it last saw an event, CAT fresh (browser-free reconnect). */
96:// Cookie-probe (Refresh) verdict → headline. These describe the STORED COOKIES,
106: checkpoint: { label: "Checkpoint — resting", tone: "warn", hint: "recoverable FB challenge — let it REST ~24h, don't run jobs; self-clears on the next login" },
123: const { acct, sess, res, groups, isActive } = p;
125: // • cookie probe (Refresh, `res`): are the STORED cookies valid? Replayed
127: // box browser is logged in — Refresh never injects them; Connect does.
130: const probe = res ?? null; // headless cookie check
139: // A fresh probe makes the headline speak about COOKIES (not a browser-login
144: const freshness = probe
146: // Refresh actually RENEWED the jar — FB rotated these auth cookies and we
147: // persisted the fresh ones (the next replay carries the new xs).
148: ? { label: `jar refreshed · ${probe.rotated.join(", ")}`, tone: "ok" as const }
159: : boxStatus === "error" ? (res?.error ?? "check Jobs panel")
164: // freshness). Feeding it the SAME inputs the table uses (a fresh probe wins
172: const at = res?.account_type ?? sess?.account_type;
179: // Presence from cloak_accounts (has_password/has_totp) is the source of truth —
220: the EXACT evidence behind it (raw box/jar signals + freshness) so nothing
248: <span className={`rounded px-1.5 py-px text-micro font-semibold ${TONE[freshness.tone]}`}>{freshness.label}</span>
276: title="last time FB itself confirmed this jar logged_in (FB's own Date header) — the freshness behind the verdict">
279: {p.intel?.session_expires_in_days != null && (Bash{"command": "grep -n \"boxStatus\\|loaded\\|res?.status\\|res.status\\|status ===\" src/components/AccountCard.tsx | head -30", "description": "boxStatus mapping"}
131: const boxStatus = sess?.last_status ?? null; // last browser-side status
132: const status = boxStatus ?? probe?.status; // for StatusDot / fbSays downstream
133: const authed = boxStatus === "logged_in";
135: const lastTs = sess?.last_loaded_at ? new Date(sess.last_loaded_at).getTime() : null;
143: : (VERDICT[boxStatus ?? ""] ?? { label: "Unknown", tone: "muted" as const, hint: "Connect to check" });
149: : { label: "cookies checked now", tone: probe.status === "logged_in" ? "ok" as const : "warn" as const }
155: ? (probe.status === "logged_in"
156: ? "cookies valid — but NOT loaded in the browser yet. Click Connect to log the box browser in."
158: : boxStatus === "logged_in" && isStale ? "unverified in the browser — click Connect to confirm"
159: : boxStatus === "error" ? (res?.error ?? "check Jobs panel")
160: : (VERDICT[boxStatus ?? ""]?.hint);
167: const live = probe?.status ?? boxStatus;
230: {(sess?.kv_status === "deleted" || sess?.kv_status === "suspended") && (
265: <ReadField label="Box status" mono tone={boxStatus === "logged_in" ? "ok" : boxStatus ? "warn" : undefined}
267: {boxStatus ?? "never connected"}
292: {sess?.last_loaded_at && <ReadField label="Last checked">{relTime(sess.last_loaded_at)}</ReadField>}Bash{"command": "python3 /home/philip/Documents/GitHub/claudectl/app/progress.py devdashco/bluebut 53 read done \"root cause: connectSession stamps flat status:'loaded' & discards orchestrator login_ok/cookies_injected; no verify (ext/e2ee), no save, no close. Also server StartProfileResponse contract strips those fields.\"; python3 /home/philip/Documents/GitHub/claudectl/app/progress.py devdashco/bluebut 53 worktree done \"in fix/issue-53 worktree\"", "description": "Report read+worktree done"}
(Bash completed with no output)
Bash{"command": "python3 /home/philip/Documents/GitHub/claudectl/app/progress.py devdashco/bluebut 53 fix start \"surfacing login_ok/cookies_injected end-to-end + verify-and-finalize workflow\"", "description": "fix start"}
(Bash completed with no output)
Now the contract change to surface the fields the server currently strips:
Jul 24, 03:06:14 PM
Edit{"replace_all": false, "file_path": "/home/philip/Documents/GitHub/bluebut-issue-53/shared/contract/index.ts", "old_string": "export const StartProfileResponse = z.object({\n ok: z.literal(true),\n cdp_url: z.string(),\n view_url: z.string(),\n run_id: z.string().optional(),\n egress_ip: z.string().nullable().optional(),\n});", "new_string": "export const StartProfileResponse = z.object({\n ok: z.literal(true),\n cdp_url: z.string(),\n view_url: z.string(),\n run_id: z.string().optional(),\n profile_id: z.string().optional(),\n egress_ip: z.string().nullable().optional(),\n /** COUNT of cookies the engine injected into the live browser from the banked jar (0 = none). */\n cookies_injected: z.number().nullable().optional(),\n /** Orchestrator's post-inject verdict: is the rest
The file /home/philip/Documents/GitHub/bluebut-issue-53/shared/contract/index.ts has been updated successfully. (file state is current in your context — no need to Read it back)
Edit{"replace_all": false, "file_path": "/home/philip/Documents/GitHub/bluebut-issue-53/ui/src/api/instances.ts", "old_string": "export interface CloakOpenResult { ok: boolean; account?: string; seed?: string | null; viewer_url?: string | null; url?: string; error?: string }", "new_string": "export interface CloakOpenResult {\n ok: boolean; account?: string; seed?: string | null; viewer_url?: string | null; url?: string; error?: string;\n // Store profile_id of the launched browser \u2014 the handle Load's verify-and-finalize\n // steps (harvester diag / save / stop) key on. Resolved slug\u2192profile_id before start.\n profile_id?: string | null;\n // Orchestrator's post-inject truth \u2014 NOT re-derived by the caller, just surfaced:\n // cookies_injected = COUNT of cookies restored
The file /home/philip/Documents/GitHub/bluebut-issue-53/ui/src/api/instances.ts has been updated successfully. (file state is current in your context — no need to Read it back)
Edit{"replace_all": false, "file_path": "/home/philip/Documents/GitHub/bluebut-issue-53/ui/src/api/instances.ts", "old_string": " const d = await r.json().catch(() => ({ ok: false, error: `bad response (${r.status})` }));\n if (!r.ok && !d?.error) return { ok: false, error: `open failed (${r.status})` };\n return { ok: !!d?.ok, account, viewer_url: d?.view_url ?? null, url, error: d?.error };", "new_string": " const d = await r.json().catch(() => ({ ok: false, error: `bad response (${r.status})` }));\n if (!r.ok && !d?.error) return { ok: false, error: `open failed (${r.status})` };\n return {\n ok: !!d?.ok, account, viewer_url: d?.view_url ?? null, url, error: d?.error,\n profile_id: (d?.profile_id ?? pid) ?? null,\n cookies_injected: d?.cookies_injected ?? nu
The file /home/philip/Documents/GitHub/bluebut-issue-53/ui/src/api/instances.ts has been updated successfully. (file state is current in your context — no need to Read it back)
Bash{"command": "grep -rn \"fetchSocketStatus\\|export \\* from\\|jobs\" src/api/index.ts | head", "description": "Check api index exports"}
src/api/index.ts:3:export * from "./_core"; src/api/index.ts:4:export * from "./listings"; src/api/index.ts:5:export * from "./workflow-runs"; src/api/index.ts:6:export * from "./jobs"; src/api/index.ts:7:export * from "./catalog"; src/api/index.ts:8:export * from "./har-traces"; src/api/index.ts:9:export * from "./instances"; src/api/index.ts:10:export * from "./proxy"; src/api/index.ts:11:export * from "./accounts"; src/api/index.ts:12:export * from "./entities";
Edit{"replace_all": false, "file_path": "/home/philip/Documents/GitHub/bluebut-issue-53/ui/src/pages/Accounts/useAccountActions.ts", "old_string": " mintAccount, bankMintedSession, verifyProxy, setAccountProxy, typeLogin, autoLogin,\n connectMessenger,", "new_string": " mintAccount, bankMintedSession, verifyProxy, setAccountProxy, typeLogin, autoLogin,\n connectMessenger, fetchSocketStatus,"}
The file /home/philip/Documents/GitHub/bluebut-issue-53/ui/src/pages/Accounts/useAccountActions.ts has been updated successfully. (file state is current in your context — no need to Read it back)
Now rewrite `connectSession` into the verify-and-finalize workflow:
Jul 24, 03:06:44 PM
Edit{"replace_all": false, "file_path": "/home/philip/Documents/GitHub/bluebut-issue-53/ui/src/pages/Accounts/useAccountActions.ts", "old_string": " // Connect \u2014 a direct cdp_load that pins the live tab into the Control mirror.\n async function connectSession(acct: string) {\n setLoadingAcct(acct);\n setControlling(true);\n try {\n const r = await startProfile(acct, \"https://www.facebook.com/\");\n if (r.ok) {\n if (r.seed) setLoadedSeed({ acct, seed: r.seed });\n setResults(prev => ({ ...prev, [acct]: { status: \"loaded\", ip: \"?\", country: \"?\" } }));\n } else {\n toast.error(`Load failed: ${r.error ?? \"unknown\"}`);\n setResults(prev => ({ ...prev, [acct]: { status: \"error\", ip: \"\u2014\", country: \"\u2014\", error: r.error
The file /home/philip/Documents/GitHub/bluebut-issue-53/ui/src/pages/Accounts/useAccountActions.ts has been updated successfully. (file state is current in your context — no need to Read it back)
Bash{"command": "grep -rn \"warn:\\|warn(\\|succeed:\\|event:\\|fail:\" src/state/jobs.ts src/api/jobs.ts 2>/dev/null | grep -i \"warn\\|succeed\" | head", "description": "Verify job.warn exists"}
src/api/jobs.ts:178: console.warn(`[cli:stream] ${tag} FAIL ${ms}ms: ${reason}`, { site, cmd, account, duration_ms: ms, ok: false, reason });Bash{"command": "grep -rn \"warn\\b\\|succeed\\b\\|event\\b\\|fail\\b\" src/state/jobs.ts | grep -i \"warn\\|succeed\\|event\\|fail\" | head -20", "description": "Find job handle methods"}
ugrep: warning: src/state/jobs.ts: No such file or directory
Bash{"command": "f=$(grep -rln \"export function useJobs\\|startJob\" src/state/ src/ | head -1); echo \"$f\"; grep -n \"warn\\|succeed\\|event\\|fail\" \"$f\" | grep -i \"warn:\\|warn =\\|succeed:\\|event:\\|fail:\\|warn(\" | head", "description": "Locate job methods def"}
src/state/jobs.tsx
55: event: (text: string) => void;
56: succeed: (summary?: string, extra?: { attrs?: LogAttrs }) => void;
58: warn: (summary: string, extra?: { diagnostic?: unknown; attrs?: LogAttrs }) => void;
59: fail: (error: string, extra?: { diagnostic?: unknown; attrs?: LogAttrs }) => void;
179: event: (text: string) => {
186: succeed: (summary?: string, extra?: { attrs?: LogAttrs }) => {
192: warn: (summary: string, extra?: { diagnostic?: unknown; attrs?: LogAttrs }) => {
199: fail: (error: string, extra?: { diagnostic?: unknown; attrs?: LogAttrs }) => {Bash{"command": "cd /home/philip/Documents/GitHub/bluebut-issue-53 && git add -A && git commit -q -F - <<'EOF' && git log --oneline -1\nfeat(accounts): Load is a verify-and-finalize workflow (#53)\n\nClicking Load on /accounts/<slug> was fire-and-forget: connectSession\ncalled startProfile and, on r.ok, stamped a flat status:'loaded',\ndiscarding the login_ok/cookies_injected the orchestrator already\ncomputed. Nothing waited for or recognised a successful load.\n\nLoad now:\n - surfaces login_ok/cookies_injected/profile_id end-to-end\n (StartProfileResponse contract \u2192 store route \u2192 api.startProfile;\n the fields were being stripped at the /api/store seam),\n - recognises success: gates on the engine's login_ok verdict \u2014\n a NOT-logged-in load leaves the browser open
18fb9f40 feat(accounts): Load is a verify-and-finalize workflow (#53)
Bash{"command": "python3 /home/philip/Documents/GitHub/claudectl/app/progress.py devdashco/bluebut 53 fix done \"workflow implemented across contract+server+ui\" && python3 /home/philip/Documents/GitHub/claudectl/app/progress.py devdashco/bluebut 53 push start && git fetch origin main -q && git merge --no-edit origin/main 2>&1 | tail -3 && git push -u origin fix/issue-53 2>&1 | tail -5", "description": "Merge main and push"}
direct push to main is blocked - open a merge request (glab mr create).
Bash{"command": "git branch --show-current && git status -s | head && git push -u origin fix/issue-53 2>&1 | tail -8", "description": "Push branch alone"}
fix/issue-53 remote: remote: To create a merge request for fix/issue-53, visit: remote: https://gitlab.hostbun.cc/devdashco/bluebut/-/merge_requests/new?merge_request%5Bsource_branch%5D=fix%2Fissue-53 remote: To https://gitlab.hostbun.cc/devdashco/bluebut.git * [new branch] fix/issue-53 -> fix/issue-53 branch 'fix/issue-53' set up to track 'origin/fix/issue-53'.
Bash{"command": "glab mr create --repo devdashco/bluebut --source-branch fix/issue-53 --target-branch main --title \"fix: Load is a verify-and-finalize workflow (#53)\" --description \"Closes #53 \u2014 Load on /accounts/<slug> was fire-and-forget (connectSession stamped a flat status:'loaded', discarding orchestrator login_ok/cookies_injected). Load now surfaces login_ok/cookies_injected/profile_id end-to-end (they were stripped at the /api/store contract seam), gates success on the engine's login_ok verdict, verifies the capture extension is connected + brings up the e2ee socket, auto-banks the good session, and closes the browser on success (only after banking). Replaces the three manual buttons from #48 with one job-tracked verb.\" --yes 2>&1 | tail -8", "description": "Create MR"}
Creating merge request for fix/issue-53 into main in devdashco/bluebut https://gitlab.hostbun.cc/devdashco/bluebut/-/merge_requests/16
Bash{"command": "glab mr merge fix/issue-53 --repo devdashco/bluebut --yes 2>&1 | tail -12", "description": "Merge MR"}
ERROR
All attempts fail:
#1: PUT https://gitlab.hostbun.cc/api/v4/projects/devdashco%2Fbluebut/merge_requests/16/merge: 400 {message: SHA
must be provided when merging}.Bash{"command": "SHA=$(git rev-parse fix/issue-53) && echo \"sha=$SHA\" && glab mr merge 16 --repo devdashco/bluebut --sha \"$SHA\" --yes 2>&1 | tail -12", "description": "Merge with explicit SHA"}
sha=18fb9f406ba59d86daa168b7d8fb07a77fe0eabf ! No pipeline running on fix/issue-53 ✓ Merged! https://gitlab.hostbun.cc/devdashco/bluebut/-/merge_requests/16
Bash{"command": "python3 /home/philip/Documents/GitHub/claudectl/app/progress.py devdashco/bluebut 53 push done \"MR !16 merged to main\" && python3 /home/philip/Documents/GitHub/claudectl/app/progress.py devdashco/bluebut 53 close start && glab issue note 53 --repo devdashco/bluebut --message \"fixed on fix/issue-53 (MR !16, merged to main). Load is now a verify-and-finalize workflow instead of fire-and-forget: connectSession (ui/src/pages/Accounts/useAccountActions.ts) launches the browser, gates on the orchestrator's login_ok verdict (previously discarded), verifies the capture extension is connected (harvester heartbeat poll), brings up the e2ee messenger socket (browser-free via stored CAT + socket-status poll), auto-banks the good session (profiles.save, cookies+datr), then closes the br
https://gitlab.hostbun.cc/devdashco/bluebut/-/work_items/53#note_569
Bash{"command": "glab issue close 53 --repo devdashco/bluebut 2>&1 | tail -3 && python3 /home/philip/Documents/GitHub/claudectl/app/progress.py devdashco/bluebut 53 close done \"issue #53 closed; MR !16 landed on main\"", "description": "Close issue"}
no result shipped
Solo run
The lead agent did the work itself — no workers were dispatched.
drag to pan · ⌘/ctrl + scroll to zoom · double-click to fit
100%
Goes into the live fixer's pane (or resumes its session if it has none) — the same path a Telegram reply takes.