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`.
This commit is contained in:
can1357
2026-06-12 15:00:41 +02:00
parent 9d159ced17
commit 162c9ca422
12 changed files with 805 additions and 134 deletions
@@ -17,7 +17,7 @@ export interface CustomShareResult {
export type CustomShareFn = (htmlPath: string) => Promise<CustomShareResult | string | undefined>;
interface LoadedCustomShare {
export interface LoadedCustomShare {
path: string;
fn: CustomShareFn;
}
+86 -10
View File
@@ -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<string> {
/** Generate CSS custom properties for theme. Exported for the share-viewer build script. */
export async function generateThemeVars(themeName?: string): Promise<string> {
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<string> {
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<string, SubSession>;
}
/** 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 `<dir>/<name>.jsonl` keeps its subagent sessions at `<dir>/<name>/<AgentId>.jsonl`;
* each subagent's own children nest the same way under `<dir>/<name>/<AgentId>/`. 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<Record<string, SubSession>> {
const result: Record<string, SubSession> = {};
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<string, SubSession>,
): Promise<void> {
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`;
@@ -0,0 +1,102 @@
(function() {
'use strict';
// ============================================================
// SHARE VIEWER BOOTSTRAP
// ============================================================
//
// Served by the omp relay at /s/<id>; 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/<id>/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/<id>.');
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;
})();
+268
View File
@@ -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 <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;
}