Files
oh-my-pi/packages/coding-agent/src/export/share.ts
T
can1357 162c9ca422 feat(coding-agent): sealed /share behind encrypted links and embedded subagent transcripts in exports
- Added `src/export/share.ts`: `/share` now snapshots the session JSON, gzips and seals it with a fresh AES-256-GCM key, and pushes the blob to a secret gist or the share server (1 MB cap with image/string/entry truncation via `sealToFit`); links are `<serverUrl>/<id>#<key>` with the key only in the fragment.
- Added `share-loader.js` and `scripts/generate-share-viewer.ts` building the static viewer the relay serves at `GET /s/<id>`: it fetches the sealed blob, decrypts in-browser, and hands the JSON to the export template via `window.__OMP_SESSION_DATA__`.
- Reworked the `/share` command in `command-controller.ts`/`builtin-registry.ts` off the plaintext-gist HTML upload, exported `LoadedCustomShare`, and exposed the session `SecretObfuscator` getter on `AgentSession` for redaction.
- Added `share.serverUrl` and `share.redactSecrets` settings backed by `DEFAULT_SHARE_URL` from pi-wire.
- HTML exports now embed subagent transcripts: `collectSubSessions` walks `<session>/<AgentId>.jsonl` recursively into `SessionData.subSessions`, with `includeSubSessions` opt-out and the exported `buildSessionData` reused by share snapshots.
- Added `share.test.ts` (snapshot/seal/server-url contracts) and `export-subsessions.test.ts`.
2026-06-12 15:00:41 +02:00

269 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 <serverUrl>` → `{"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 `<serverUrl>/<id>#<base64url key>`. 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 <img> 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: `<serverUrl>/<id>#<key>`. */
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<ShareSessionResult> {
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 `<base>/<id>` 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<ArrayBuffer>;
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<SealedSession> {
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<Uint8Array<ArrayBuffer>> {
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<string, unknown> {
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<string> {
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;
}