diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5c573993e..27c7298a7 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,21 +5,24 @@ - Added `/collab view` command to create a read-only spectator join link - Added read-only hints and status text for guest-only participation in collab sessions +- Added `share.serverUrl` and `share.redactSecrets` settings: the share server/viewer base for `/share` links (default `https://my.omp.sh/s`) and a toggle (default on) that runs the secret obfuscator over the shared snapshot before upload +- HTML session exports now embed subagent transcripts: sub-session files stored next to the session (`/.jsonl`, recursively) ride along in the export payload, agent ids in task tool cards become drill-down links, and a breadcrumbed overlay renders each subagent's full transcript — including its own tool cards and deeper nested agents — with Esc/backdrop navigation ### Changed - Changed collab links so full links with a write token grant mutation rights while links without a token now join as read-only +- `/share` no longer uploads a plaintext HTML export to a gist for gistpreview. It now snapshots the session JSON, gzips and seals it with a fresh AES-256-GCM key, and pushes the blob to a secret gist (when `gh` is authenticated) or to the share server (capped at 1 MB; oversized sessions are trimmed — images first, then long strings, then oldest entries). The share link is `https://my.omp.sh/s/#`: the viewer fetches the blob (hex ids from the gist API, others from the relay store) and decrypts it in-browser, so the key never leaves the client. Configured secrets are additionally redacted from the snapshot unless `share.redactSecrets` is off. Custom `~/.omp/agent/share.{ts,js,mjs}` handlers keep the legacy HTML-file contract; `/share` also works for in-memory (`--no-session`) sessions now - `/collab` now prints a join hint with both link forms: the compact `omp join` link for terminals and a click-to-join browser deep link (`https:///#`, displayed scheme-less, OSC 8-linked) — the relay serves the collab web client at `/`, and the room id + key ride in the URL fragment, so they never appear in any HTTP request. `/join`, `omp join`, and the web connect screen accept either form - npm installs no longer download fastembed's ~270MB ONNX native dependency tree eagerly: `fastembed` and `onnxruntime-node` are external to the bundle and optional peers of `@oh-my-pi/pi-mnemopi`, fetched on demand only when Mnemopi local embeddings are first used - The `job` tool prompt now documents that omitting `poll` waits on all running jobs, so agents stop enumerating every job id to poll everything - Collapsed task tool blocks now cap the per-agent list at 4 rows: the live progress view keeps the running/pending tail visible behind a `… N more agents (…)` summary line with per-status counts, finalized batches keep failed/aborted rows visible while folding the slowest successes, and the streaming call preview caps at the same 4 (expand shows the full list) - The anchored Subagents HUD above the editor now lists only detached background spawns: sync task calls (the parent turn is blocked and the inline tool block already renders live progress) and eval `agent()` helpers (rendered by their eval cell's own progress tree) no longer appear in the list - -### Fixed - - Completed rows in the `job` tool output now show the standard done checkmark instead of the watch glyph that replaced the spinner - HTML session exports render tool calls through the same React tool renderers the collab web client uses: per-tool views for all built-in tools (bash, edit diffs, todo boards, eval cells, task batches, LSP, search, browser screenshots, …) are bundled as an `` web component into the export instead of the previous string-built dummy renderers - Modernized the HTML export page chrome to match the tool-card design language: hairline borders, dense mono typography, compact role-tinted message cards, refined tree sidebar and filter controls, themed scrollbars, collapsed-by-default thinking blocks, and mobile sidebar drawer — derived from the active theme's variables so light and dark themes both render correctly + +### Fixed + - Fixed read-only collab sessions so prompting, interrupts, and other write actions are blocked with a read-only warning instead of being applied - Fixed Mnemopi local embeddings in bundled and compiled installs failing with `Cannot find module '../bin/napi-v3/.../onnxruntime_binding.node'`: the Bun bundle inlined fastembed's loader so its relative native require resolved against `dist/cli.js`. `fastembed`/`onnxruntime-node` are no longer bundled; on first use Mnemopi `bun install`s the pinned pair into `~/.omp/cache/fastembed-runtime/` and loads the binding from there ([#2389](https://github.com/can1357/oh-my-pi/issues/2389)) - Fixed the interactive Model scope startup banner so models without an explicit thinking level do not show `:undefined`, and entries that were scoped without a `:level` are no longer rendered with the global default thinking level (which `applyRootSessionOptions` pre-fills on the cycling array for Ctrl+P) ([#2385](https://github.com/can1357/oh-my-pi/issues/2385)). diff --git a/packages/coding-agent/scripts/generate-share-viewer.ts b/packages/coding-agent/scripts/generate-share-viewer.ts new file mode 100755 index 000000000..5815d0da6 --- /dev/null +++ b/packages/coding-agent/scripts/generate-share-viewer.ts @@ -0,0 +1,34 @@ +#!/usr/bin/env bun +/** + * Build the standalone share-viewer page the omp relay serves at `GET /s/`. + * + * Same template as HTML exports, but with no embedded session: share-loader.js + * (injected right after the empty #session-data tag) fetches the sealed blob + * (gist or relay store), decrypts it with the `#` fragment in-browser, and + * hands the JSON to template.js via `window.__OMP_SESSION_DATA__`. + * + * The relay repo's build script runs this and embeds the output via go:embed. + */ +import * as path from "node:path"; +import { generateThemeVars, getTemplate } from "../src/export/html"; + +const outPath = process.argv[2]; +if (!outPath) { + console.error("usage: bun scripts/generate-share-viewer.ts "); + process.exit(2); +} + +const loaderJs = await Bun.file(new URL("../src/export/html/share-loader.js", import.meta.url).pathname).text(); +// Pin a built-in theme: the viewer is a public artifact, not a per-user export. +const themeVars = await generateThemeVars("dark"); + +const html = getTemplate() + .replace("", () => ``) + .replace("Session Export", () => "omp session") + .replace("{{SESSION_DATA}}", () => `\n `); + +if (html.includes("{{SESSION_DATA}}")) throw new Error("session-data placeholder survived substitution"); +if (!html.includes("__OMP_SESSION_DATA__")) throw new Error("share loader not injected"); + +await Bun.write(outPath, html); +console.log(`Generated ${path.resolve(outPath)} (${(html.length / 1024).toFixed(0)} KB)`); diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 16ad64edc..96ae67593 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -1,4 +1,5 @@ import { THINKING_EFFORTS } from "@oh-my-pi/pi-ai"; +import { DEFAULT_SHARE_URL } from "@oh-my-pi/pi-wire"; import { SHAPE_VARIANT_NAMES } from "@oh-my-pi/snapcompact"; import { DEFAULT_RELAY_URL } from "../collab/protocol"; import { AUTO_THINKING, getConfiguredThinkingLevelMetadata, getThinkingLevelMetadata } from "../thinking"; @@ -1353,6 +1354,29 @@ export const SETTINGS_SCHEMA = { }, }, + "share.serverUrl": { + type: "string", + default: DEFAULT_SHARE_URL, + ui: { + tab: "interaction", + group: "Collab", + label: "Share Server", + description: + "Share viewer/upload base used by /share (encrypted blob upload + viewer; links are /#)", + }, + }, + + "share.redactSecrets": { + type: "boolean", + default: true, + ui: { + tab: "interaction", + group: "Collab", + label: "Share Secret Redaction", + description: "Run the secret obfuscator over /share snapshots before upload (uses the secrets.* config)", + }, + }, + // Speech-to-text "stt.enabled": { type: "boolean", diff --git a/packages/coding-agent/src/export/custom-share.ts b/packages/coding-agent/src/export/custom-share.ts index 14b994b0b..c97c60ecb 100644 --- a/packages/coding-agent/src/export/custom-share.ts +++ b/packages/coding-agent/src/export/custom-share.ts @@ -17,7 +17,7 @@ export interface CustomShareResult { export type CustomShareFn = (htmlPath: string) => Promise; -interface LoadedCustomShare { +export interface LoadedCustomShare { path: string; fn: CustomShareFn; } diff --git a/packages/coding-agent/src/export/html/index.ts b/packages/coding-agent/src/export/html/index.ts index b98d5e813..6f4bccad8 100644 --- a/packages/coding-agent/src/export/html/index.ts +++ b/packages/coding-agent/src/export/html/index.ts @@ -1,3 +1,4 @@ +import * as fs from "node:fs/promises"; import * as path from "node:path"; import type { AgentState } from "@oh-my-pi/pi-agent-core"; import { APP_NAME, isEnoent } from "@oh-my-pi/pi-utils"; @@ -39,6 +40,8 @@ export function getTemplate(): string { export interface ExportOptions { outputPath?: string; themeName?: string; + /** Embed subagent session transcripts found next to the session file (default true). */ + includeSubSessions?: boolean; } /** Parse a color string to RGB values. */ @@ -101,8 +104,8 @@ function deriveExportColors(baseColor: string): { pageBg: string; cardBg: string }; } -/** Generate CSS custom properties for theme. */ -async function generateThemeVars(themeName?: string): Promise { +/** Generate CSS custom properties for theme. Exported for the share-viewer build script. */ +export async function generateThemeVars(themeName?: string): Promise { const colors = await getResolvedThemeColors(themeName); const lines: string[] = []; for (const [key, value] of Object.entries(colors)) { @@ -120,12 +123,83 @@ async function generateThemeVars(themeName?: string): Promise { return lines.join(" "); } -interface SessionData { +/** Embedded subagent session transcript, keyed by slash-joined agent path in `SessionData.subSessions`. */ +export interface SubSession { + /** Bare agent id (session file stem), e.g. "ToolAsk". */ + agentId: string; + /** Key of the parent sub-session, or null when spawned by the main session. */ + parent: string | null; + header: SessionHeader | null; + entries: SessionEntry[]; + leafId: string | null; +} + +export interface SessionData { header: SessionHeader | null; entries: SessionEntry[]; leafId: string | null; systemPrompt?: string; tools?: { name: string; description: string }[]; + subSessions?: Record; +} + +/** Snapshot the session (plus optional agent state) into the JSON shape the viewer renders. */ +export function buildSessionData(sm: SessionManager, state?: AgentState): SessionData { + return { + header: sm.getHeader(), + entries: sm.getEntries(), + leafId: sm.getLeafId(), + systemPrompt: state?.systemPrompt.join("\n\n"), + tools: state?.tools?.map(t => ({ name: t.name, description: t.description })), + }; +} + +/** + * Collect subagent session transcripts stored next to a session file. + * + * A session at `/.jsonl` keeps its subagent sessions at `//.jsonl`; + * each subagent's own children nest the same way under `///`. Keys in the + * returned record are slash-joined ids relative to the main session ("ToolAsk", "ToolAsk/Helper"). + * Corrupt or empty files are skipped silently. + */ +export async function collectSubSessions(sessionFile: string): Promise> { + const result: Record = {}; + if (!sessionFile.endsWith(".jsonl")) return result; + await collectSubSessionsFromDir(sessionFile.slice(0, -6), null, result); + return result; +} + +async function collectSubSessionsFromDir( + dir: string, + parentKey: string | null, + out: Record, +): Promise { + let names: string[]; + try { + names = await fs.readdir(dir); + } catch (err) { + if (isEnoent(err)) return; + throw err; + } + for (const name of names) { + if (!name.endsWith(".jsonl") || name.includes(".bak")) continue; + const agentId = name.slice(0, -6); + const key = parentKey ? `${parentKey}/${agentId}` : agentId; + const fileEntries = await loadEntriesFromFile(path.join(dir, name)); + // Empty/corrupt files (no valid session header) load as [] — skip silently. + if (fileEntries.length > 0) { + const header = (fileEntries.find(e => e.type === "session") as SessionHeader | undefined) ?? null; + const entries = fileEntries.filter((e): e is SessionEntry => e.type !== "session"); + out[key] = { + agentId, + parent: parentKey, + header, + entries, + leafId: entries.length > 0 ? entries[entries.length - 1].id : null, + }; + } + await collectSubSessionsFromDir(path.join(dir, agentId), key, out); + } } /** Generate HTML from bundled template with runtime substitutions. */ @@ -152,13 +226,11 @@ export async function exportSessionToHtml( const sessionFile = sm.getSessionFile(); if (!sessionFile) throw new Error("Cannot export in-memory session to HTML"); - const sessionData: SessionData = { - header: sm.getHeader(), - entries: sm.getEntries(), - leafId: sm.getLeafId(), - systemPrompt: state?.systemPrompt.join("\n\n"), - tools: state?.tools?.map(t => ({ name: t.name, description: t.description })), - }; + const sessionData = buildSessionData(sm, state); + if (opts.includeSubSessions !== false) { + const subSessions = await collectSubSessions(sessionFile); + if (Object.keys(subSessions).length > 0) sessionData.subSessions = subSessions; + } const html = await generateHtml(sessionData, opts.themeName); const outputPath = opts.outputPath || `${APP_NAME}-session-${path.basename(sessionFile, ".jsonl")}.html`; @@ -184,6 +256,10 @@ export async function exportFromFile(inputPath: string, options?: ExportOptions entries: sm.getEntries(), leafId: sm.getLeafId(), }; + if (opts.includeSubSessions !== false) { + const subSessions = await collectSubSessions(inputPath); + if (Object.keys(subSessions).length > 0) sessionData.subSessions = subSessions; + } const html = await generateHtml(sessionData, opts.themeName); const outputPath = opts.outputPath || `${APP_NAME}-session-${path.basename(inputPath, ".jsonl")}.html`; diff --git a/packages/coding-agent/src/export/html/share-loader.js b/packages/coding-agent/src/export/html/share-loader.js new file mode 100644 index 000000000..ce01a6064 --- /dev/null +++ b/packages/coding-agent/src/export/html/share-loader.js @@ -0,0 +1,102 @@ + (function() { + 'use strict'; + + // ============================================================ + // SHARE VIEWER BOOTSTRAP + // ============================================================ + // + // Served by the omp relay at /s/; the AES-256-GCM key rides in the + // URL fragment and never leaves the browser. Resolves the session JSON + // and hands it to template.js via `window.__OMP_SESSION_DATA__`: + // 1. hex ids -> secret GitHub gist holding base64(sealed blob) + // 2. anything else -> relay blob store at /s//raw + // Sealed layout: [12B IV][AES-256-GCM(gzip(session JSON))]. + + var GIST_ID_RE = /^[0-9a-f]{20,64}$/; + var SHARE_PATH_RE = /\/s\/([A-Za-z0-9_-]{10,64})\/?$/; + + function decodeBase64(text) { + var binary = atob(text); + var bytes = new Uint8Array(binary.length); + for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; + } + + function decodeBase64Url(text) { + var b64 = text.replace(/-/g, '+').replace(/_/g, '/'); + while (b64.length % 4) b64 += '='; + return decodeBase64(b64); + } + + async function fetchGistBlob(id) { + var res = await fetch('https://api.github.com/gists/' + id, { + headers: { Accept: 'application/vnd.github+json' }, + }); + if (res.status === 404) throw new Error('This share no longer exists (gist deleted?).'); + if (!res.ok) throw new Error('Gist fetch failed: HTTP ' + res.status); + var gist = await res.json(); + var files = Object.values(gist.files || {}); + var file = files.find(function(f) { return /\.ompshare\.txt$/.test(f.filename); }) || files[0]; + if (!file) throw new Error('Gist has no files.'); + var text = file.content; + if (!text || file.truncated) { + var raw = await fetch(file.raw_url); + if (!raw.ok) throw new Error('Gist raw fetch failed: HTTP ' + raw.status); + text = await raw.text(); + } + return decodeBase64(text.replace(/\s+/g, '')); + } + + async function fetchServerBlob(id) { + var res = await fetch('/s/' + id + '/raw'); + if (res.status === 404 || res.status === 410) { + throw new Error('This share no longer exists (expired or deleted).'); + } + if (!res.ok) throw new Error('Share fetch failed: HTTP ' + res.status); + return new Uint8Array(await res.arrayBuffer()); + } + + async function load() { + var match = SHARE_PATH_RE.exec(location.pathname); + if (!match) throw new Error('Bad share URL; expected /s/.'); + var keyText = location.hash.replace(/^#/, ''); + if (!keyText) throw new Error('Share link is missing its #key fragment; paste the full link.'); + var keyBytes; + try { + keyBytes = decodeBase64Url(keyText); + } catch (_err) { + throw new Error('Share key is not valid base64url.'); + } + if (keyBytes.length !== 32) throw new Error('Share key must decode to 32 bytes.'); + + var id = match[1]; + var sealed = await (GIST_ID_RE.test(id) ? fetchGistBlob(id) : fetchServerBlob(id)); + if (sealed.length <= 12) throw new Error('Sealed session blob is truncated.'); + + var key = await crypto.subtle.importKey('raw', keyBytes, 'AES-GCM', false, ['decrypt']); + var plain; + try { + plain = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: sealed.subarray(0, 12) }, + key, + sealed.subarray(12) + ); + } catch (_err) { + throw new Error('Decryption failed: wrong or corrupted #key.'); + } + + var data = await new Response( + new Blob([plain]).stream().pipeThrough(new DecompressionStream('gzip')) + ).json(); + if (data && data.header && data.header.title) { + document.title = data.header.title + ' — omp session'; + } + return data; + } + + var pending = load(); + // template.js surfaces the failure in-page; swallow the duplicate here + // so the console does not report an unhandled rejection. + pending.catch(function() {}); + window.__OMP_SESSION_DATA__ = pending; + })(); diff --git a/packages/coding-agent/src/export/share.ts b/packages/coding-agent/src/export/share.ts new file mode 100644 index 000000000..5d8e8fd66 --- /dev/null +++ b/packages/coding-agent/src/export/share.ts @@ -0,0 +1,268 @@ +/** + * Session sharing. + * + * The session JSON is gzipped and sealed with a fresh AES-256-GCM key + * (`[12B IV][ciphertext+tag]`, same layout as collab frames), then pushed to + * one of two stores: + * + * 1. A secret GitHub gist (preferred — free, durable, no relay storage) + * holding base64 of the sealed blob, when an authenticated `gh` exists. + * 2. The share server (`POST ` → `{"id":"…"}`), capped at 1 MB; + * oversized sessions are truncated (images first, then long strings, + * then oldest entries) until the sealed blob fits. + * + * Either way the link is `/#`. The viewer page + * served there fetches the blob (gist ids are hex; server ids never are), + * decrypts with the fragment key — which never leaves the browser — and + * renders the same template as `/export`. + */ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { AgentState } from "@oh-my-pi/pi-agent-core"; +import { $which, logger } from "@oh-my-pi/pi-utils"; +import { DEFAULT_SHARE_URL } from "@oh-my-pi/pi-wire"; +import { $ } from "bun"; +import type { SecretObfuscator } from "../secrets/obfuscator"; +import type { SessionManager } from "../session/session-manager"; +import { buildSessionData, type SessionData } from "./html"; + +export { DEFAULT_SHARE_URL }; + +/** Hard cap for blobs accepted by the share server (mirrors relay shareMaxBytes). */ +export const SERVER_MAX_SEALED_BYTES = 1_000_000; +/** Gist raw fetches cap at 10 MB; keep base64 (×4/3) comfortably under it. */ +const GIST_MAX_SEALED_BYTES = 5_000_000; + +const IV_LENGTH = 12; +const SHARE_KEY_BYTES = 32; +/** The viewer picks the gist file by this suffix. */ +const GIST_FILENAME = "session.ompshare.txt"; +/** Gist ids are hex; the relay never issues pure-hex ids, so the viewer can route on shape. */ +const GIST_ID_RE = /^[0-9a-f]{20,64}$/; + +/** Progressively harsher per-string caps applied when the sealed blob is over budget. */ +const TEXT_CAPS = [32_768, 8_192, 2_048, 512]; +/** 1×1 transparent GIF; stands in for stripped data-URL images so tags stay valid. */ +const BLANK_IMAGE_DATA_URL = "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="; +const IMAGE_OMITTED_TEXT = "[image omitted from share]"; + +export interface ShareSessionOptions { + /** Share server/viewer base URL; defaults to {@link DEFAULT_SHARE_URL}. */ + serverUrl?: string; + /** Agent state for system prompt + tool descriptions in the snapshot. */ + state?: AgentState; + /** + * Redacts the snapshot before sealing: deep-walks every string (entries, + * header, system prompt, tool descriptions) through the obfuscator, so + * secrets that landed in persisted entries (tool outputs reading .env, + * etc.) never leave the machine. Pass undefined to skip. + */ + obfuscator?: SecretObfuscator; +} + +export interface ShareSessionResult { + /** Viewer link: `/#`. */ + url: string; + method: "gist" | "server"; + /** Underlying gist URL (gist method only). */ + gistUrl?: string; + /** True when content was trimmed to fit the upload budget. */ + truncated: boolean; + sealedBytes: number; +} + +/** Build the snapshot that gets sealed and uploaded, redacted when an obfuscator is provided. */ +export function buildShareSnapshot(sm: SessionManager, options?: ShareSessionOptions): SessionData { + const data = buildSessionData(sm, options?.state); + return options?.obfuscator?.hasSecrets() ? options.obfuscator.obfuscateObject(data) : data; +} + +/** Share the session; tries a secret gist first, then the share server. */ +export async function shareSession(sm: SessionManager, options?: ShareSessionOptions): Promise { + const data = buildShareSnapshot(sm, options); + const keyBytes = new Uint8Array(SHARE_KEY_BYTES); + crypto.getRandomValues(keyBytes); + const key = await crypto.subtle.importKey("raw", keyBytes, "AES-GCM", false, ["encrypt"]); + const keyText = Buffer.from(keyBytes).toString("base64url"); + const base = normalizeShareServerUrl(options?.serverUrl); + + const forGist = await sealToFit(key, data, GIST_MAX_SEALED_BYTES); + const gist = await tryCreateGist(forGist.sealed); + if (gist) { + return { + url: `${base}/${gist.id}#${keyText}`, + method: "gist", + gistUrl: gist.url, + truncated: forGist.truncated, + sealedBytes: forGist.sealed.byteLength, + }; + } + + const forServer = + forGist.sealed.byteLength <= SERVER_MAX_SEALED_BYTES + ? forGist + : await sealToFit(key, data, SERVER_MAX_SEALED_BYTES); + const id = await uploadToServer(forServer.sealed, base); + return { + url: `${base}/${id}#${keyText}`, + method: "server", + truncated: forServer.truncated, + sealedBytes: forServer.sealed.byteLength, + }; +} + +/** Strip trailing slashes so `/` composes cleanly. */ +export function normalizeShareServerUrl(serverUrl?: string): string { + const base = (serverUrl ?? DEFAULT_SHARE_URL).trim().replace(/\/+$/, ""); + return base || DEFAULT_SHARE_URL; +} + +interface SealedSession { + sealed: Uint8Array; + truncated: boolean; +} + +/** Seal `data`, trimming content until the sealed blob fits `maxBytes`. Exported for tests. */ +export async function sealToFit(key: CryptoKey, data: SessionData, maxBytes: number): Promise { + let sealed = await sealSessionData(key, data); + if (sealed.byteLength <= maxBytes) return { sealed, truncated: false }; + + // Work on a deep copy; the caller may re-fit the original at another budget. + const working = structuredClone(data); + stripImagePayloads(working); + sealed = await sealSessionData(key, working); + if (sealed.byteLength <= maxBytes) return { sealed, truncated: true }; + + for (const cap of TEXT_CAPS) { + capLongStrings(working, cap); + sealed = await sealSessionData(key, working); + if (sealed.byteLength <= maxBytes) return { sealed, truncated: true }; + } + + // Last resort: drop oldest entries (orphaned children render as roots). + while (working.entries.length > 4) { + working.entries = working.entries.slice(Math.ceil(working.entries.length / 2)); + sealed = await sealSessionData(key, working); + if (sealed.byteLength <= maxBytes) return { sealed, truncated: true }; + } + + throw new Error(`Session too large to share: ${sealed.byteLength} bytes sealed exceeds the ${maxBytes} byte limit`); +} + +/** `[12B IV][AES-256-GCM(gzip(JSON))]` — decrypted and gunzipped by share-loader.js. */ +async function sealSessionData(key: CryptoKey, data: SessionData): Promise> { + const compressed = Bun.gzipSync(new TextEncoder().encode(JSON.stringify(data))); + const iv = new Uint8Array(IV_LENGTH); + crypto.getRandomValues(iv); + const ciphertext = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, compressed)); + const out = new Uint8Array(IV_LENGTH + ciphertext.byteLength); + out.set(iv, 0); + out.set(ciphertext, IV_LENGTH); + return out; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +/** Replace inline image payloads (image blocks + data: URLs) with tiny placeholders, in place. */ +function stripImagePayloads(value: unknown): void { + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + const item: unknown = value[i]; + if (isRecord(item) && item.type === "image" && typeof item.data === "string" && item.data.length > 1024) { + value[i] = { type: "text", text: IMAGE_OMITTED_TEXT }; + continue; + } + stripImagePayloads(item); + } + return; + } + if (!isRecord(value)) return; + for (const k in value) { + const v = value[k]; + if (typeof v === "string") { + if (v.length > 1024 && v.startsWith("data:")) value[k] = BLANK_IMAGE_DATA_URL; + continue; + } + stripImagePayloads(v); + } +} + +/** Truncate every string longer than `cap`, in place. */ +function capLongStrings(value: unknown, cap: number): void { + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + const item: unknown = value[i]; + if (typeof item === "string" && item.length > cap) value[i] = `${item.slice(0, cap)}\n…[truncated for share]`; + else capLongStrings(item, cap); + } + return; + } + if (!isRecord(value)) return; + for (const k in value) { + const v = value[k]; + if (typeof v === "string") { + if (v.length > cap) value[k] = `${v.slice(0, cap)}\n…[truncated for share]`; + continue; + } + capLongStrings(v, cap); + } +} + +/** Create a secret gist holding base64 of the sealed blob; null when `gh` is unusable. */ +async function tryCreateGist(sealed: Uint8Array): Promise<{ id: string; url: string } | null> { + if (!$which("gh")) return null; + const auth = await $`gh auth status`.quiet().nothrow(); + if (auth.exitCode !== 0) { + logger.debug("share: gh present but not authenticated; falling back to share server"); + return null; + } + + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-share-")); + try { + const file = path.join(dir, GIST_FILENAME); + await Bun.write(file, Buffer.from(sealed).toString("base64")); + const result = await $`gh gist create --public=false ${file}`.quiet().nothrow(); + if (result.exitCode !== 0) { + logger.warn("share: gist creation failed; falling back to share server", { + stderr: result.stderr.toString("utf-8").trim().slice(0, 500), + }); + return null; + } + const url = result.text().trim().split("\n").pop()?.trim() ?? ""; + const id = url.split("/").pop() ?? ""; + if (!GIST_ID_RE.test(id)) { + logger.warn("share: could not parse gist id from gh output", { url }); + return null; + } + return { id, url }; + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +} + +/** POST the sealed blob to the share server; returns the assigned id. */ +async function uploadToServer(sealed: Uint8Array, base: string): Promise { + let res: Response; + try { + res = await fetch(base, { + method: "POST", + headers: { "Content-Type": "application/octet-stream" }, + body: sealed, + }); + } catch (err) { + throw new Error(`Share upload to ${base} failed: ${err instanceof Error ? err.message : String(err)}`); + } + if (!res.ok) { + const detail = (await res.text().catch(() => "")).trim().slice(0, 200); + throw new Error(`Share upload to ${base} failed: HTTP ${res.status}${detail ? ` (${detail})` : ""}`); + } + const body = (await res.json().catch(() => null)) as { id?: unknown } | null; + const id = body && typeof body.id === "string" ? body.id : ""; + if (!/^[A-Za-z0-9_-]{10,64}$/.test(id)) { + throw new Error(`Share upload to ${base} failed: server returned no usable id`); + } + return id; +} diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index 4e642a0d3..cc580c11f 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -11,8 +11,9 @@ import { } from "@oh-my-pi/pi-ai"; import { Loader, Markdown, padding, Spacer, Text, visibleWidth } from "@oh-my-pi/pi-tui"; import { formatDuration, Snowflake } from "@oh-my-pi/pi-utils"; -import { $ } from "bun"; import { shouldEnableAppendOnlyContext } from "../../config/append-only-context-mode"; +import { type LoadedCustomShare, loadCustomShare } from "../../export/custom-share"; +import { shareSession } from "../../export/share"; import type { CompactOptions } from "../../extensibility/extensions/types"; import { diffMentalModelContent, @@ -117,126 +118,84 @@ export class CommandController { } async handleShareCommand(): Promise { - const tmpFile = path.join(os.tmpdir(), `${Snowflake.next()}.html`); - const cleanupTempFile = async () => { - try { - await fs.rm(tmpFile, { force: true }); - } catch { - // Ignore cleanup errors - } - }; + let customShare: LoadedCustomShare | null; try { - await this.ctx.session.exportToHtml(tmpFile); - } catch (error: unknown) { - this.ctx.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`); - return; - } - - try { - const { loadCustomShare } = await import("../../export/custom-share"); - const customShare = await loadCustomShare(); - if (customShare) { - const loader = new BorderedLoader(this.ctx.ui, theme, "Sharing..."); - this.ctx.editorContainer.clear(); - this.ctx.editorContainer.addChild(loader); - this.ctx.ui.setFocus(loader); - this.ctx.ui.requestRender(); - - const restoreEditor = async () => { - loader.dispose(); - this.ctx.editorContainer.clear(); - this.ctx.editorContainer.addChild(this.ctx.editor); - this.ctx.ui.setFocus(this.ctx.editor); - await cleanupTempFile(); - }; - - try { - const result = await customShare.fn(tmpFile); - await restoreEditor(); - - if (typeof result === "string") { - this.ctx.showStatus(`Share URL: ${result}`); - this.openInBrowser(result); - } else if (result) { - const parts: string[] = []; - if (result.url) parts.push(`Share URL: ${result.url}`); - if (result.message) parts.push(result.message); - if (parts.length > 0) this.ctx.showStatus(parts.join("\n")); - if (result.url) this.openInBrowser(result.url); - } else { - this.ctx.showStatus("Session shared"); - } - return; - } catch (err) { - await restoreEditor(); - this.ctx.showError(`Custom share failed: ${err instanceof Error ? err.message : String(err)}`); - return; - } - } + customShare = await loadCustomShare(); } catch (err) { - await cleanupTempFile(); this.ctx.showError(err instanceof Error ? err.message : String(err)); return; } - try { - const authResult = await $`gh auth status`.quiet().nothrow(); - if (authResult.exitCode !== 0) { - await cleanupTempFile(); - this.ctx.showError("GitHub CLI is not logged in. Run 'gh auth login' first."); - return; - } - } catch { - await cleanupTempFile(); - this.ctx.showError("GitHub CLI (gh) is not installed. Install it from https://cli.github.com/"); - return; - } - - const loader = new BorderedLoader(this.ctx.ui, theme, "Creating gist..."); + const loader = new BorderedLoader(this.ctx.ui, theme, "Sharing session..."); this.ctx.editorContainer.clear(); this.ctx.editorContainer.addChild(loader); this.ctx.ui.setFocus(loader); this.ctx.ui.requestRender(); - const restoreEditor = async () => { + const restoreEditor = () => { loader.dispose(); this.ctx.editorContainer.clear(); this.ctx.editorContainer.addChild(this.ctx.editor); this.ctx.ui.setFocus(this.ctx.editor); - await cleanupTempFile(); }; - loader.onAbort = () => { - void restoreEditor(); + restoreEditor(); this.ctx.showStatus("Share cancelled"); }; + // Custom share scripts keep their legacy contract: they receive a path + // to a standalone HTML export. No fallback to the default flow on error. + if (customShare) { + const tmpFile = path.join(os.tmpdir(), `${Snowflake.next()}.html`); + try { + await this.ctx.session.exportToHtml(tmpFile); + const result = await customShare.fn(tmpFile); + if (loader.signal.aborted) return; + restoreEditor(); + + if (typeof result === "string") { + this.ctx.showStatus(`Share URL: ${result}`); + this.openInBrowser(result); + } else if (result) { + const parts: string[] = []; + if (result.url) parts.push(`Share URL: ${result.url}`); + if (result.message) parts.push(result.message); + if (parts.length > 0) this.ctx.showStatus(parts.join("\n")); + if (result.url) this.openInBrowser(result.url); + } else { + this.ctx.showStatus("Session shared"); + } + } catch (err) { + if (!loader.signal.aborted) { + restoreEditor(); + this.ctx.showError(`Custom share failed: ${err instanceof Error ? err.message : String(err)}`); + } + } finally { + await fs.rm(tmpFile, { force: true }).catch(() => {}); + } + return; + } + + // Default: encrypted snapshot to a secret gist (preferred) or the share + // server; the key rides in the link fragment and never leaves the client. try { - const result = await $`gh gist create --public=false ${tmpFile}`.quiet().nothrow(); + const result = await shareSession(this.ctx.session.sessionManager, { + serverUrl: this.ctx.settings.get("share.serverUrl"), + state: this.ctx.session.state, + obfuscator: this.ctx.settings.get("share.redactSecrets") ? this.ctx.session.obfuscator : undefined, + }); if (loader.signal.aborted) return; + restoreEditor(); - await restoreEditor(); - - if (result.exitCode !== 0) { - const errorMsg = result.stderr.toString("utf-8").trim() || "Unknown error"; - this.ctx.showError(`Failed to create gist: ${errorMsg}`); - return; - } - - const gistUrl = result.stdout.toString("utf-8").trim(); - const gistId = gistUrl.split("/").pop(); - if (!gistId) { - this.ctx.showError("Failed to parse gist ID from gh output"); - return; - } - - const previewUrl = `https://gistpreview.github.io/?${gistId}`; - this.ctx.showStatus(`Share URL: ${previewUrl}\nGist: ${gistUrl}`); - this.openInBrowser(previewUrl); + const lines = [`Share URL: ${result.url}`]; + if (result.gistUrl) lines.push(`Gist: ${result.gistUrl}`); + if (result.truncated) lines.push("Note: large content was trimmed to fit the share size limit."); + this.ctx.showStatus(lines.join("\n")); + this.openInBrowser(result.url); } catch (error: unknown) { if (!loader.signal.aborted) { - await restoreEditor(); - this.ctx.showError(`Failed to create gist: ${error instanceof Error ? error.message : "Unknown error"}`); + restoreEditor(); + this.ctx.showError(`Failed to share session: ${error instanceof Error ? error.message : "Unknown error"}`); } } } diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index e3d13e083..dd3076fe5 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -1418,6 +1418,11 @@ export class AgentSession { return this.#ttsrManager; } + /** Secret obfuscator, when secrets are configured; /share redaction reuses it. */ + get obfuscator(): SecretObfuscator | undefined { + return this.#obfuscator; + } + /** Whether a TTSR abort is pending (stream was aborted to inject rules) */ get isTtsrAbortPending(): boolean { return this.#ttsrAbortPending; diff --git a/packages/coding-agent/src/slash-commands/builtin-registry.ts b/packages/coding-agent/src/slash-commands/builtin-registry.ts index be3db03f3..98362f350 100644 --- a/packages/coding-agent/src/slash-commands/builtin-registry.ts +++ b/packages/coding-agent/src/slash-commands/builtin-registry.ts @@ -4,8 +4,7 @@ import * as path from "node:path"; import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth"; import { setNextRequestDebugPath } from "@oh-my-pi/pi-ai/utils/request-debug"; import type { AutocompleteItem } from "@oh-my-pi/pi-tui"; -import { APP_NAME, Snowflake, setProjectDir } from "@oh-my-pi/pi-utils"; -import { $ } from "bun"; +import { APP_NAME, setProjectDir } from "@oh-my-pi/pi-utils"; import { COLLAB_GUEST_ALLOWED_COMMANDS, CollabGuestLink } from "../collab/guest"; import { CollabHost } from "../collab/host"; import type { SettingPath, SettingValue } from "../config/settings"; @@ -15,6 +14,7 @@ import { resolveActiveProjectRegistryPath, resolveOrDefaultProjectRegistryPath, } from "../discovery/helpers.js"; +import { shareSession } from "../export/share"; import { PluginManager } from "../extensibility/plugins"; import { getInstalledPluginsRegistryPath, @@ -440,31 +440,21 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ }, { name: "share", - description: "Share session as a secret GitHub gist", + description: "Share session via an encrypted link (secret gist or share server)", handle: async (_command, runtime) => { - const tmpFile = path.join(os.tmpdir(), `${Snowflake.next()}.html`); try { - try { - await runtime.session.exportToHtml(tmpFile); - } catch (err) { - return usage(`Failed to export session: ${errorMessage(err)}`, runtime); - } - const result = await $`gh gist create --public=false ${tmpFile}`.quiet().nothrow(); - if (result.exitCode !== 0) { - return usage( - `Failed to create gist: ${result.stderr.toString("utf-8").trim() || "unknown error"}`, - runtime, - ); - } - const gistUrl = result.stdout.toString("utf-8").trim(); - const gistId = gistUrl.split("/").pop(); - if (!gistId) return usage("Failed to parse gist ID from gh output", runtime); - await runtime.output(`Share URL: https://gistpreview.github.io/?${gistId}\nGist: ${gistUrl}`); + const result = await shareSession(runtime.sessionManager, { + serverUrl: runtime.settings.get("share.serverUrl"), + state: runtime.session.state, + obfuscator: runtime.settings.get("share.redactSecrets") ? runtime.session.obfuscator : undefined, + }); + const lines = [`Share URL: ${result.url}`]; + if (result.gistUrl) lines.push(`Gist: ${result.gistUrl}`); + if (result.truncated) lines.push("Note: large content was trimmed to fit the share size limit."); + await runtime.output(lines.join("\n")); return commandConsumed(); - } catch { - return usage("GitHub CLI (gh) is required for /share. Install it from https://cli.github.com/.", runtime); - } finally { - await fs.rm(tmpFile, { force: true }).catch(() => {}); + } catch (err) { + return usage(`Failed to share session: ${errorMessage(err)}`, runtime); } }, handleTui: async (_command, runtime) => { diff --git a/packages/coding-agent/test/export-subsessions.test.ts b/packages/coding-agent/test/export-subsessions.test.ts new file mode 100644 index 000000000..88ae34fd7 --- /dev/null +++ b/packages/coding-agent/test/export-subsessions.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { collectSubSessions } from "../src/export/html"; + +/** + * Contract: a session at `/.jsonl` embeds subagent transcripts from + * `//.jsonl` (recursively) under slash-joined keys, with + * parent links and last-entry leaf ids. Corrupt/empty/backup files are skipped. + */ + +function sessionJsonl(id: string, entryIds: string[]): string { + const lines = [ + JSON.stringify({ type: "session", version: 3, id, timestamp: "2026-06-12T00:00:00.000Z", cwd: "/tmp" }), + ]; + let parent: string | null = null; + for (const entryId of entryIds) { + lines.push( + JSON.stringify({ + type: "model_change", + id: entryId, + parentId: parent, + timestamp: "2026-06-12T00:00:01.000Z", + model: "test/model", + }), + ); + parent = entryId; + } + return `${lines.join("\n")}\n`; +} + +describe("collectSubSessions", () => { + let root: string; + let mainFile: string; + + beforeEach(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), "omp-subsessions-")); + mainFile = path.join(root, "main.jsonl"); + await Bun.write(mainFile, sessionJsonl("main", ["m1"])); + }); + + afterEach(async () => { + await fs.rm(root, { recursive: true, force: true }); + }); + + test("collects nested subagent sessions with parent links and leaf ids", async () => { + await Bun.write(path.join(root, "main/Alpha.jsonl"), sessionJsonl("alpha", ["a1", "a2"])); + await Bun.write(path.join(root, "main/Alpha/Child.jsonl"), sessionJsonl("child", ["c1"])); + await Bun.write(path.join(root, "main/Beta.jsonl"), sessionJsonl("beta", ["b1"])); + + const subs = await collectSubSessions(mainFile); + + expect(Object.keys(subs).sort()).toEqual(["Alpha", "Alpha/Child", "Beta"]); + expect(subs.Alpha).toMatchObject({ agentId: "Alpha", parent: null, leafId: "a2" }); + expect(subs.Alpha.entries.map(e => e.id)).toEqual(["a1", "a2"]); + expect(subs.Alpha.header?.id).toBe("alpha"); + expect(subs["Alpha/Child"]).toMatchObject({ agentId: "Child", parent: "Alpha", leafId: "c1" }); + expect(subs.Beta).toMatchObject({ agentId: "Beta", parent: null, leafId: "b1" }); + }); + + test("skips corrupt, empty, backup, and non-jsonl files", async () => { + await Bun.write(path.join(root, "main/Good.jsonl"), sessionJsonl("good", ["g1"])); + await Bun.write(path.join(root, "main/corrupt.jsonl"), "{not json\n"); + await Bun.write(path.join(root, "main/empty.jsonl"), ""); + await Bun.write(path.join(root, "main/Good.jsonl.123.bak"), sessionJsonl("bak", ["x1"])); + await Bun.write(path.join(root, "main/notes.md"), "# notes\n"); + + const subs = await collectSubSessions(mainFile); + + expect(Object.keys(subs)).toEqual(["Good"]); + }); + + test("returns empty record when no subagent dir exists", async () => { + expect(await collectSubSessions(mainFile)).toEqual({}); + expect(await collectSubSessions(path.join(root, "not-a-session"))).toEqual({}); + }); +}); diff --git a/packages/coding-agent/test/share.test.ts b/packages/coding-agent/test/share.test.ts new file mode 100644 index 000000000..de5a7b16b --- /dev/null +++ b/packages/coding-agent/test/share.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, test } from "bun:test"; +import type { SessionData } from "../src/export/html"; +import { buildShareSnapshot, normalizeShareServerUrl, SERVER_MAX_SEALED_BYTES, sealToFit } from "../src/export/share"; +import { SecretObfuscator } from "../src/secrets/obfuscator"; +import type { SessionEntry, SessionManager } from "../src/session/session-manager"; + +const IV_LENGTH = 12; + +async function makeKey(): Promise { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return crypto.subtle.importKey("raw", bytes, "AES-GCM", false, ["encrypt", "decrypt"]); +} + +/** Mirror of share-loader.js: AES-GCM open + gunzip + parse. */ +async function open(key: CryptoKey, sealed: Uint8Array): Promise { + const plain = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: sealed.subarray(0, IV_LENGTH) }, + key, + sealed.subarray(IV_LENGTH), + ); + return JSON.parse(new TextDecoder().decode(Bun.gunzipSync(new Uint8Array(plain)))); +} + +function messageEntry(id: string, parentId: string | null, text: string): SessionEntry { + return { + type: "message", + id, + parentId, + timestamp: "2026-06-12T00:00:00.000Z", + message: { role: "user", content: [{ type: "text", text }] }, + } as unknown as SessionEntry; +} + +function sessionData(entries: SessionEntry[], leafId: string): SessionData { + return { + header: { type: "session", version: 3, id: "t", timestamp: "2026-06-12T00:00:00.000Z", cwd: "/tmp" }, + entries, + leafId, + }; +} + +/** Incompressible filler so gzip cannot absorb the payload. */ +function randomHex(words: number): string { + return Array.from(crypto.getRandomValues(new Uint32Array(words)), v => v.toString(16)).join(""); +} + +describe("sealToFit", () => { + test("round-trips losslessly when under budget", async () => { + const key = await makeKey(); + const data = sessionData([messageEntry("e1", null, "hello"), messageEntry("e2", "e1", "world")], "e2"); + + const { sealed, truncated } = await sealToFit(key, data, SERVER_MAX_SEALED_BYTES); + + expect(truncated).toBe(false); + expect(await open(key, sealed)).toEqual(data); + }); + + test("trims oversized text into budget without dropping entries", async () => { + const key = await makeKey(); + const data = sessionData( + [messageEntry("e1", null, "keep me"), messageEntry("e2", "e1", randomHex(1_500_000))], + "e2", + ); + + const { sealed, truncated } = await sealToFit(key, data, SERVER_MAX_SEALED_BYTES); + + expect(truncated).toBe(true); + expect(sealed.byteLength).toBeLessThanOrEqual(SERVER_MAX_SEALED_BYTES); + const opened = await open(key, sealed); + expect(opened.entries).toHaveLength(2); + expect(opened.leafId).toBe("e2"); + expect(JSON.stringify(opened)).toContain("keep me"); + expect(JSON.stringify(opened)).toContain("…[truncated for share]"); + }); + + test("replaces large inline images with placeholders before trimming text", async () => { + const key = await makeKey(); + const imageEntry = { + type: "message", + id: "img", + parentId: null, + timestamp: "2026-06-12T00:00:00.000Z", + message: { + role: "user", + content: [ + { type: "text", text: "see screenshot" }, + { type: "image", data: randomHex(800_000), mimeType: "image/png" }, + ], + }, + } as unknown as SessionEntry; + const data = sessionData([imageEntry], "img"); + + const { sealed, truncated } = await sealToFit(key, data, SERVER_MAX_SEALED_BYTES); + + expect(truncated).toBe(true); + const flat = JSON.stringify(await open(key, sealed)); + expect(flat).toContain("[image omitted from share]"); + expect(flat).toContain("see screenshot"); + }); +}); + +describe("buildShareSnapshot", () => { + test("redacts secrets through the obfuscator and leaves the original untouched", () => { + const entries = [messageEntry("e1", null, "the token is hunter2-XYZZY, keep safe")]; + const sm = { + getHeader: () => sessionData([], "x").header, + getEntries: () => entries, + getLeafId: () => "e1", + } as unknown as SessionManager; + const obfuscator = new SecretObfuscator([{ type: "plain", content: "hunter2-XYZZY" }]); + + const snapshot = buildShareSnapshot(sm, { obfuscator }); + + expect(JSON.stringify(snapshot)).not.toContain("hunter2-XYZZY"); + expect(JSON.stringify(snapshot)).toContain("the token is"); + // Source entries must keep the real value; redaction is share-only. + expect(JSON.stringify(entries)).toContain("hunter2-XYZZY"); + + const plain = buildShareSnapshot(sm, {}); + expect(JSON.stringify(plain)).toContain("hunter2-XYZZY"); + }); +}); + +describe("normalizeShareServerUrl", () => { + test("strips trailing slashes and falls back to the default", () => { + expect(normalizeShareServerUrl("https://my.omp.sh/s/")).toBe("https://my.omp.sh/s"); + expect(normalizeShareServerUrl("https://example.com/s///")).toBe("https://example.com/s"); + expect(normalizeShareServerUrl(undefined)).toBe("https://my.omp.sh/s"); + expect(normalizeShareServerUrl(" ")).toBe("https://my.omp.sh/s"); + }); +});