/** * pi-unshare — revoke pi session shares. * * /unshare find and delete the secret gist(s) `/share` created for * the CURRENT session, after one explicit confirmation * /unshare --yes skip the confirmation (required in non-interactive modes) * /shares inspect every pi session share on the account; revoke * from the list * * Design: docs/design.md. In short — GitHub is the source of truth. Candidates * are pruned by shape (secret, single file `session.html`, created after the * session started), but a gist is only ever deleted when the session UUID * embedded in its content exactly matches this session's UUID, and only after * the user confirms. Auth is the user's own `gh` CLI login, the same * prerequisite pi's `/share` has. */ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { CONFIG_DIR_NAME, type ExtensionAPI, type ExtensionContext, } from "@earendil-works/pi-coding-agent"; const run = promisify(execFile); const MARKER = 'id="session-data" type="application/json">'; const DETECTED = "unshare:detected"; const REVOKED = "unshare:revoked"; // --------------------------------------------------------------------------- // gh CLI wrapper // --------------------------------------------------------------------------- type GhOk = { ok: true; stdout: string }; type GhErr = { ok: false; kind: "missing" | "auth" | "http" | "other"; status?: number; message: string; }; async function gh(args: string[]): Promise { try { const { stdout } = await run("gh", args, { maxBuffer: 32 * 1024 * 1024 }); return { ok: true, stdout }; } catch (err) { const e = err as NodeJS.ErrnoException & { stderr?: string }; if (e.code === "ENOENT") { return { ok: false, kind: "missing", message: "GitHub CLI (gh) is not installed. Install it from https://cli.github.com/", }; } const stderr = String(e.stderr ?? e.message ?? "").trim(); const http = stderr.match(/HTTP (\d{3})/); if (http) { return { ok: false, kind: "http", status: Number(http[1]), message: stderr }; } return { ok: false, kind: "other", message: stderr || "gh failed" }; } } /** Same gate `/share` itself uses. */ async function ghReady(): Promise<{ ok: true } | { ok: false; message: string }> { const r = await gh(["auth", "status"]); if (r.ok) return { ok: true }; if (r.kind === "missing") return { ok: false, message: r.message }; return { ok: false, message: "GitHub CLI is not logged in. Run 'gh auth login' first." }; } // --------------------------------------------------------------------------- // Discovery: list candidates, verify by embedded session UUID // --------------------------------------------------------------------------- interface Candidate { id: string; createdAt: string; htmlUrl: string; rawUrl?: string; } interface VerifiedShare extends Candidate { sessionId: string | null; // null = content could not be verified cwd?: string; startedAt?: string; } /** Secret gists whose single file is `session.html` — the exact shape `/share` produces. */ async function listCandidates(): Promise { const jq = '.[] | select(.public == false) | select((.files | keys) == ["session.html"]) | ' + '{id, created_at, html_url, raw: .files["session.html"].raw_url}'; const r = await gh(["api", "gists?per_page=100", "--paginate", "--jq", jq]); if (!r.ok) return { error: r.message }; const out: Candidate[] = []; for (const line of r.stdout.split("\n")) { const t = line.trim(); if (!t) continue; try { const o = JSON.parse(t) as { id: string; created_at: string; html_url: string; raw?: string }; out.push({ id: o.id, createdAt: o.created_at, htmlUrl: o.html_url, rawUrl: o.raw }); } catch { /* skip malformed line */ } } out.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)); return out; } /** * Extract the session header from an exported HTML prelude. The export embeds * base64 JSON whose first bytes are the session header; the block sits after * ~30 KB of CSS, so a 256 KB prefix always contains it. */ export function extractSessionHeader( html: string, ): { id: string; startedAt?: string; cwd?: string } | null { const at = html.indexOf(MARKER); if (at < 0) return null; let b64 = html.slice(at + MARKER.length, at + MARKER.length + 2048); const close = b64.indexOf("<"); if (close >= 0) b64 = b64.slice(0, close); b64 = b64.replace(/[^A-Za-z0-9+/=]/g, ""); b64 = b64.slice(0, b64.length - (b64.length % 4)); if (!b64) return null; let text: string; try { text = Buffer.from(b64, "base64").toString("utf8"); } catch { return null; } // The decoded JSON starts with {"header":{...}} — the first "id" is the session UUID. const head = text.slice(0, 1000); const id = head.match(/"id"\s*:\s*"([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})"/); if (!id) return null; const ts = head.match(/"timestamp"\s*:\s*"([^"]+)"/); const cwd = head.match(/"cwd"\s*:\s*"([^"]+)"/); return { id: id[1], startedAt: ts?.[1], cwd: cwd?.[1] }; } /** Fetch enough of the gist content to read the embedded header. */ async function fetchPrelude(c: Candidate): Promise { if (c.rawUrl) { try { const resp = await fetch(c.rawUrl, { headers: { Range: "bytes=0-262143" } }); if (resp.ok) return await resp.text(); } catch { /* fall through to API */ } } const r = await gh(["api", `gists/${c.id}`]); if (!r.ok) return null; try { const g = JSON.parse(r.stdout) as { files?: Record; }; return g.files?.["session.html"]?.content?.slice(0, 262144) ?? null; } catch { return null; } } // --- cross-session verification cache (gistId → sessionId) ------------------ interface CacheShape { v: 1; gists: Record; } const cachePath = join(homedir(), CONFIG_DIR_NAME, "agent", "unshare-cache.json"); function loadCache(): CacheShape { try { if (existsSync(cachePath)) { const c = JSON.parse(readFileSync(cachePath, "utf8")) as CacheShape; if (c && c.v === 1 && c.gists) return c; } } catch { /* corrupt cache → start fresh */ } return { v: 1, gists: {} }; } function saveCache(cache: CacheShape, liveIds: Set): void { for (const id of Object.keys(cache.gists)) { if (!liveIds.has(id)) delete cache.gists[id]; // gist gone → drop } try { mkdirSync(dirname(cachePath), { recursive: true }); writeFileSync(cachePath, JSON.stringify(cache)); } catch { /* cache is an optimization; never fail on it */ } } const CONCURRENCY = 8; async function verifyAll(candidates: Candidate[]): Promise { const cache = loadCache(); const out: VerifiedShare[] = []; const queue = [...candidates]; const workers = Array.from({ length: Math.min(CONCURRENCY, queue.length) }, async () => { for (;;) { const c = queue.shift(); if (!c) return; const hit = cache.gists[c.id]; if (hit) { out.push({ ...c, sessionId: hit.sessionId, cwd: hit.cwd, startedAt: hit.startedAt }); continue; } const prelude = await fetchPrelude(c); const header = prelude ? extractSessionHeader(prelude) : null; if (header) { cache.gists[c.id] = { sessionId: header.id, cwd: header.cwd, startedAt: header.startedAt }; } out.push({ ...c, sessionId: header?.id ?? null, cwd: header?.cwd, startedAt: header?.startedAt }); } }); await Promise.all(workers); saveCache(cache, new Set(candidates.map((c) => c.id))); out.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)); return out; } // --------------------------------------------------------------------------- // Deletion // --------------------------------------------------------------------------- type DeleteOutcome = "deleted" | "gone" | "forbidden" | { error: string }; async function deleteGist(id: string): Promise { const r = await gh(["api", "-X", "DELETE", `gists/${id}`]); if (r.ok) return "deleted"; if (r.kind === "http" && r.status === 404) return "gone"; if (r.kind === "http" && (r.status === 403 || r.status === 401)) return "forbidden"; return { error: r.message }; } // --------------------------------------------------------------------------- // Session memory (cache only — never a deletion basis) and status lamp // --------------------------------------------------------------------------- interface CustomEntryLike { type: string; customType?: string; data?: unknown; } function replayMemory(ctx: ExtensionContext): { alive: string[]; revokedAt: string | null } { let detected: string[] = []; const revoked = new Set(); let revokedAt: string | null = null; for (const entry of ctx.sessionManager.getEntries() as CustomEntryLike[]) { if (entry.type !== "custom") continue; const data = (entry.data ?? {}) as { gistIds?: string[]; gistId?: string; at?: string }; if (entry.customType === DETECTED && Array.isArray(data.gistIds)) detected = data.gistIds; if (entry.customType === REVOKED && data.gistId) { revoked.add(data.gistId); revokedAt = data.at ?? revokedAt; } } return { alive: detected.filter((id) => !revoked.has(id)), revokedAt }; } function updateLamp(ctx: ExtensionContext, aliveCount: number): void { if (!ctx.hasUI) return; ctx.ui.setStatus("unshare", aliveCount > 0 ? `🔗 shared${aliveCount > 1 ? ` ×${aliveCount}` : ""}` : undefined); } function recordDetected(pi: ExtensionAPI, ctx: ExtensionContext, aliveIds: string[]): void { const { alive } = replayMemory(ctx); const same = alive.length === aliveIds.length && aliveIds.every((id) => alive.includes(id)); if (!same) pi.appendEntry(DETECTED, { gistIds: aliveIds, at: new Date().toISOString() }); updateLamp(ctx, aliveIds.length); } // --------------------------------------------------------------------------- // Shared helpers // --------------------------------------------------------------------------- function viewerUrl(gistId: string): string { // Mirrors pi's own construction (config.js getShareViewerUrl). const base = process.env.PI_SHARE_VIEWER_URL || "https://pi.dev/session/"; return `${base}#${gistId}`; } function fmtTime(iso: string): string { const d = new Date(iso); if (Number.isNaN(d.getTime())) return iso; const p = (n: number) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; } function shortCwd(cwd?: string): string { if (!cwd) return "?"; const home = homedir(); return cwd.startsWith(home) ? `~${cwd.slice(home.length)}` : cwd; } function say(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error" = "info"): void { if (ctx.hasUI) ctx.ui.notify(message, level); else (level === "error" ? console.error : console.log)(message); } function hasFlag(args: string | undefined, flag: string): boolean { return new RegExp(`(^|\\s)${flag}(\\s|$)`).test(args ?? ""); } interface SessionScope { sessionId: string; sinceMs: number | null; } function currentScope(ctx: ExtensionContext): SessionScope { const header = ctx.sessionManager.getHeader() as { timestamp?: string } | undefined; const started = header?.timestamp ? Date.parse(header.timestamp) : NaN; return { sessionId: ctx.sessionManager.getSessionId(), // 120 s of skew tolerance; pruning only — never a correctness input. sinceMs: Number.isNaN(started) ? null : started - 120_000, }; } async function discover(scope: SessionScope): Promise< { matches: VerifiedShare[]; othersOnAccount: number } | { error: string } > { const candidates = await listCandidates(); if ("error" in candidates) return candidates; const inWindow = scope.sinceMs === null ? candidates : candidates.filter((c) => Date.parse(c.createdAt) >= (scope.sinceMs as number)); const verified = await verifyAll(inWindow); const matches = verified.filter((v) => v.sessionId === scope.sessionId); return { matches, othersOnAccount: candidates.length - matches.length }; } async function revokeMany( pi: ExtensionAPI, ctx: ExtensionContext, targets: VerifiedShare[], ): Promise { const results: string[] = []; const now = () => new Date().toISOString(); for (const t of targets) { const outcome = await deleteGist(t.id); if (outcome === "deleted") { pi.appendEntry(REVOKED, { gistId: t.id, at: now(), sharedAt: t.createdAt }); results.push(`✓ revoked ${fmtTime(t.createdAt)} (${t.id.slice(0, 8)}) — link is dead`); } else if (outcome === "gone") { pi.appendEntry(REVOKED, { gistId: t.id, at: now(), sharedAt: t.createdAt, alreadyGone: true }); results.push(`✓ ${t.id.slice(0, 8)} was already deleted — state reconciled`); } else if (outcome === "forbidden") { results.push( `✗ ${t.id.slice(0, 8)}: not deletable by the current gh account — was it shared while logged into a different account?`, ); } else { results.push(`✗ ${t.id.slice(0, 8)}: ${outcome.error}`); } } return results.join("\n"); } // --------------------------------------------------------------------------- // Extension entry point // --------------------------------------------------------------------------- export default async function unshareExtension(pi: ExtensionAPI) { // -- /unshare ------------------------------------------------------------ pi.registerCommand("unshare", { description: "Revoke this session's /share gist(s) — deletes them from GitHub", getArgumentCompletions: (prefix: string) => { const items = [{ value: "--yes", label: "--yes (skip confirmation)" }]; const filtered = items.filter((i) => i.value.startsWith(prefix)); return filtered.length > 0 ? filtered : null; }, handler: async (args: string, ctx: ExtensionContext) => { const yes = hasFlag(args, "--yes"); const ready = await ghReady(); if (!ready.ok) return say(ctx, ready.message, "error"); const scope = currentScope(ctx); const found = await discover(scope); if ("error" in found) return say(ctx, `GitHub query failed: ${found.error}`, "error"); const memory = replayMemory(ctx); if (found.matches.length === 0) { recordDetected(pi, ctx, []); if (memory.revokedAt) { return say(ctx, `Nothing to do — this session's share was already revoked at ${fmtTime(memory.revokedAt)}.`); } if (memory.alive.length > 0) { return say( ctx, "Local memory expected a live share but GitHub has none under the current gh account. " + "It was either deleted from the web UI, or shared from a different gh login.", "warning", ); } const hint = found.othersOnAccount > 0 ? ` (${found.othersOnAccount} share(s) from other sessions exist — try /shares)` : ""; return say(ctx, `This session has never been shared.${hint}`); } recordDetected(pi, ctx, found.matches.map((m) => m.id)); // Decide targets. let targets: VerifiedShare[] = []; if (found.matches.length === 1) { targets = found.matches; } else if (yes) { targets = found.matches; } else if (!ctx.hasUI) { const list = found.matches.map((m) => ` ${fmtTime(m.createdAt)} ${viewerUrl(m.id)}`).join("\n"); return say(ctx, `This session was shared ${found.matches.length} times:\n${list}\nRun '/unshare --yes' to revoke all.`); } else { const labels = [ `Revoke all (${found.matches.length})`, ...found.matches.map((m) => `Revoke only ${fmtTime(m.createdAt)} · ${m.id.slice(0, 8)}`), "Cancel", ]; const choice = await ctx.ui.select(`This session was shared ${found.matches.length} times:`, labels); if (!choice || choice === "Cancel") return say(ctx, "Cancelled — nothing deleted."); targets = choice.startsWith("Revoke all") ? found.matches : found.matches.filter((m) => choice.includes(m.id.slice(0, 8))); } // Confirm (the gate). --yes is the only bypass; non-TUI requires it. if (!yes) { if (!ctx.hasUI) { const list = targets.map((t) => ` ${fmtTime(t.createdAt)} ${viewerUrl(t.id)}`).join("\n"); return say(ctx, `Found:\n${list}\nRun '/unshare --yes' to revoke.`); } const what = targets.length === 1 ? `Revoke the share from ${fmtTime(targets[0].createdAt)}?` : `Revoke all ${targets.length} shares of this session?`; const detail = targets.map((t) => viewerUrl(t.id)).join("\n") + "\nThe link(s) die immediately. Copies already opened by others cannot be recalled."; const ok = await ctx.ui.confirm(what, detail); if (!ok) return say(ctx, "Cancelled — nothing deleted."); } const report = await revokeMany(pi, ctx, targets); const { alive } = replayMemory(ctx); updateLamp(ctx, alive.length); say(ctx, report); }, }); // -- /shares ------------------------------------------------------------- pi.registerCommand("shares", { description: "List every pi session share on this GitHub account; revoke from the list", handler: async (_args: string, ctx: ExtensionContext) => { const ready = await ghReady(); if (!ready.ok) return say(ctx, ready.message, "error"); const currentId = ctx.sessionManager.getSessionId(); for (;;) { const candidates = await listCandidates(); if ("error" in candidates) return say(ctx, `GitHub query failed: ${candidates.error}`, "error"); if (candidates.length === 0) return say(ctx, "No pi session shares on this account."); const verified = await verifyAll(candidates); const describe = (v: VerifiedShare): string => { const who = v.sessionId === currentId ? "this session" : v.sessionId ? `${shortCwd(v.cwd)} · ${v.sessionId.slice(0, 8)}` : "unverified content"; return `${fmtTime(v.createdAt)} · ${who} · ${v.id.slice(0, 8)}`; }; if (!ctx.hasUI) { const list = verified.map((v) => ` ${describe(v)} ${viewerUrl(v.id)}`).join("\n"); return say(ctx, `${verified.length} pi session share(s):\n${list}\nUse /unshare inside a session (or the web UI) to revoke.`); } const choice = await ctx.ui.select( `${verified.length} pi session share(s) — pick one to revoke:`, [...verified.map(describe), "Done"], ); if (!choice || choice === "Done") return; const target = verified.find((v) => choice.includes(v.id.slice(0, 8))); if (!target) return; const ok = await ctx.ui.confirm( `Revoke the share from ${fmtTime(target.createdAt)}?`, `${viewerUrl(target.id)}\n${ target.sessionId === currentId ? "This is the current session's share." : `Belongs to session ${target.sessionId?.slice(0, 8) ?? "(unverified)"} (${shortCwd(target.cwd)}).` }\nThe link dies immediately. Copies already opened cannot be recalled.`, ); if (!ok) continue; const outcome = await deleteGist(target.id); if (outcome === "deleted" || outcome === "gone") { if (target.sessionId === currentId) { pi.appendEntry(REVOKED, { gistId: target.id, at: new Date().toISOString(), sharedAt: target.createdAt }); const { alive } = replayMemory(ctx); updateLamp(ctx, alive.length); } say(ctx, outcome === "deleted" ? "✓ Revoked — the link is dead." : "✓ Already gone."); } else if (outcome === "forbidden") { say(ctx, "✗ Not deletable by the current gh account.", "error"); } else { say(ctx, `✗ ${outcome.error}`, "error"); } } }, }); // -- transcript cards for revocations ------------------------------------- try { const { Box, Text } = await import("@earendil-works/pi-tui"); pi.registerEntryRenderer(REVOKED, (entry: { data?: unknown }, _opts: unknown, theme: any) => { const d = (entry.data ?? {}) as { gistId?: string; at?: string; sharedAt?: string }; const box = new Box(1, 0); box.addChild( new Text( theme.fg( "dim", `✂️ share revoked · gist ${String(d.gistId ?? "").slice(0, 8)} · shared ${d.sharedAt ? fmtTime(d.sharedAt) : "?"} · revoked ${d.at ? fmtTime(d.at) : "?"}`, ), ), ); return box; }); } catch { /* renderer is cosmetic; never block loading on it */ } // -- status lamp: replay instantly, reconcile lazily ---------------------- pi.on("session_start", async (_event: unknown, ctx: ExtensionContext) => { const { alive } = replayMemory(ctx); updateLamp(ctx, alive.length); if (!ctx.hasUI) return; // the lamp is a UI concern; print mode has no use for reconciliation let reconcile = true; try { const cfgPath = join(homedir(), CONFIG_DIR_NAME, "agent", "unshare.json"); if (existsSync(cfgPath)) { const cfg = JSON.parse(readFileSync(cfgPath, "utf8")) as { reconcileOnStart?: boolean }; if (cfg.reconcileOnStart === false) reconcile = false; } } catch { /* bad config → default behavior */ } if (!reconcile) return; // One quiet listing a few seconds after startup; usually zero extra // fetches (no candidates inside the session's time window). Failures are // silent — the lamp falls back to replayed memory. const timer = setTimeout(async () => { try { const found = await discover(currentScope(ctx)); if (!("error" in found)) recordDetected(pi, ctx, found.matches.map((m) => m.id)); } catch { /* silent */ } }, 3000); timer.unref?.(); // never keep the process alive for a cosmetic refresh }); }