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:
@@ -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 (`<session>/<AgentId>.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/<id>#<key>`: 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://<relay-host>/#<link>`, 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 `<omp-tool-view>` 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/<version-key>` 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)).
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Build the standalone share-viewer page the omp relay serves at `GET /s/<id>`.
|
||||
*
|
||||
* 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 `#<key>` 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 <output.html>");
|
||||
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("<theme-vars/>", () => `<style>:root { ${themeVars} }</style>`)
|
||||
.replace("<title>Session Export</title>", () => "<title>omp session</title>")
|
||||
.replace("{{SESSION_DATA}}</script>", () => `</script>\n <script>${loaderJs}</script>`);
|
||||
|
||||
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)`);
|
||||
@@ -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 <base>/<id>#<key>)",
|
||||
},
|
||||
},
|
||||
|
||||
"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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
})();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<void> {
|
||||
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"}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<SlashCommandSpec> = [
|
||||
},
|
||||
{
|
||||
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) => {
|
||||
|
||||
@@ -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 `<dir>/<name>.jsonl` embeds subagent transcripts from
|
||||
* `<dir>/<name>/<AgentId>.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({});
|
||||
});
|
||||
});
|
||||
@@ -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<CryptoKey> {
|
||||
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<ArrayBuffer>): Promise<SessionData> {
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user