@saadnvd1/agent-os 0.3.560 → 0.3.561
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/app/api/shares/[id]/route.ts +23 -0
- package/app/api/shares/preview/route.ts +30 -0
- package/app/api/shares/route.test.ts +455 -0
- package/app/api/shares/route.ts +101 -0
- package/app/page.tsx +5 -0
- package/components/Chat/Artifact.tsx +17 -1
- package/components/Pane/MobileTabBar.tsx +19 -0
- package/components/Pane/PaneMenu.tsx +16 -0
- package/components/SessionList/SessionRowMenu.tsx +16 -0
- package/components/Settings/SettingsDialog.test.ts +3 -0
- package/components/Settings/SettingsDialog.tsx +9 -0
- package/components/Settings/commands.ts +9 -0
- package/components/Settings/entry-points.test.ts +1 -0
- package/components/Share/ShareDialog.tsx +336 -0
- package/components/Share/format.test.ts +20 -0
- package/components/Share/format.ts +33 -0
- package/components/Shares/SharedLinksPanel.tsx +147 -0
- package/data/shares/index.ts +2 -0
- package/data/shares/keys.ts +4 -0
- package/data/shares/queries.ts +111 -0
- package/lib/chat/drivers/claude.ts +5 -4
- package/lib/connect/control-api.ts +84 -0
- package/lib/db/migrations.ts +23 -0
- package/lib/orchestrator/untrusted.ts +12 -2
- package/lib/security/demo.ts +1 -0
- package/lib/session-audit/normalize.ts +1 -1
- package/lib/settings-url.ts +1 -0
- package/lib/share/connect.ts +86 -0
- package/lib/share/context.ts +50 -0
- package/lib/share/redact.ts +190 -0
- package/lib/share/render.tsx +161 -0
- package/lib/share/route.ts +33 -0
- package/lib/share/service.ts +159 -0
- package/lib/share/snapshot.ts +261 -0
- package/lib/share/static-markup.ts +16 -0
- package/lib/share/store.ts +39 -0
- package/lib/share/styles.ts +83 -0
- package/package.json +1 -1
- package/stores/shareUi.ts +17 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a shared snapshot must not carry: credentials, the machine's paths,
|
|
3
|
+
* addresses and names, and email addresses. Pure: everything about this
|
|
4
|
+
* machine comes in through the context (lib/share/context), so the rules can
|
|
5
|
+
* be tested with invented values.
|
|
6
|
+
*
|
|
7
|
+
* Public hostnames (github.com) are left alone on purpose: masking every
|
|
8
|
+
* domain would destroy ordinary content. Only names that identify the
|
|
9
|
+
* reader's own machines and network are masked.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
PRIVATE_KEY_BLOCK,
|
|
14
|
+
SECRET_ASSIGNMENT,
|
|
15
|
+
SECRET_PARAM,
|
|
16
|
+
SECRET_TOKENS,
|
|
17
|
+
URL_USERINFO,
|
|
18
|
+
BASE64_RUN,
|
|
19
|
+
looksRandom,
|
|
20
|
+
} from "@/lib/orchestrator/untrusted";
|
|
21
|
+
import { HOME } from "@/lib/session-audit/normalize";
|
|
22
|
+
|
|
23
|
+
export type Kind = "secret" | "path" | "ip" | "host" | "email";
|
|
24
|
+
export type Hits = Record<Kind, number>;
|
|
25
|
+
export const noHits = (): Hits => ({
|
|
26
|
+
secret: 0,
|
|
27
|
+
path: 0,
|
|
28
|
+
ip: 0,
|
|
29
|
+
host: 0,
|
|
30
|
+
email: 0,
|
|
31
|
+
});
|
|
32
|
+
export const addHits = (a: Hits, b: Hits): Hits => ({
|
|
33
|
+
secret: a.secret + b.secret,
|
|
34
|
+
path: a.path + b.path,
|
|
35
|
+
ip: a.ip + b.ip,
|
|
36
|
+
host: a.host + b.host,
|
|
37
|
+
email: a.email + b.email,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export interface ShareContext {
|
|
41
|
+
/** The user's home directory. */
|
|
42
|
+
home: string;
|
|
43
|
+
/** The OS account name. */
|
|
44
|
+
user: string;
|
|
45
|
+
/** Values of secret-looking environment variables, masked wherever they appear. */
|
|
46
|
+
secrets: string[];
|
|
47
|
+
/** This machine's names, and the hosts its ssh config knows. */
|
|
48
|
+
hosts: string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const NO_CONTEXT: ShareContext = {
|
|
52
|
+
home: "",
|
|
53
|
+
user: "",
|
|
54
|
+
secrets: [],
|
|
55
|
+
hosts: [],
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const SECRET = "[secret]";
|
|
59
|
+
const BEARER = /(\bbearer\s+)(?!\[secret\])[A-Za-z0-9._~+/=-]{8,}/gi;
|
|
60
|
+
const INLINE_ASSIGNMENT =
|
|
61
|
+
/(\b[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASS|PWD|CREDENTIALS?|AUTH)[A-Z0-9_]*=)(?!\[secret\])[^\s'"]+/g;
|
|
62
|
+
const LONG_HEX = /\b[a-fA-F0-9]{40,}\b/g;
|
|
63
|
+
|
|
64
|
+
const IPV4 =
|
|
65
|
+
/(?<![\w.])(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)(?![\w.]*\w)/g;
|
|
66
|
+
const KEEP_IPV4 = new Set(["127.0.0.1", "0.0.0.0"]);
|
|
67
|
+
const G = "[0-9a-f]{1,4}";
|
|
68
|
+
// Eight groups, or a "::" compression; a lone "12:30:45" is a time, not this.
|
|
69
|
+
const IPV6 = new RegExp(
|
|
70
|
+
`(?<![\\w:])(?:(?:${G}:){7}${G}|(?:${G}:){1,7}:(?:${G}(?::${G}){0,6})?|::(?:${G}(?::${G}){0,6})?)(?![\\w:])`,
|
|
71
|
+
"gi"
|
|
72
|
+
);
|
|
73
|
+
const KEEP_IPV6 = new Set(["::1", "::"]);
|
|
74
|
+
|
|
75
|
+
const LOCAL_HOST =
|
|
76
|
+
/\b(?:[a-z0-9][a-z0-9-]*\.)+(?:ts\.net|local|lan|internal|home\.arpa)\b/gi;
|
|
77
|
+
const CONNECT_HOST = /\bm-[0-9a-f]{16}\.runagentos\.com\b/gi;
|
|
78
|
+
const EMAIL = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+\b/g;
|
|
79
|
+
|
|
80
|
+
const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
81
|
+
|
|
82
|
+
/** A replacement that counts what it replaced. */
|
|
83
|
+
function sub(
|
|
84
|
+
text: string,
|
|
85
|
+
re: RegExp,
|
|
86
|
+
to: string | ((m: string, ...g: string[]) => string),
|
|
87
|
+
hits: Hits,
|
|
88
|
+
kind: Kind,
|
|
89
|
+
keep?: (m: string) => boolean
|
|
90
|
+
): string {
|
|
91
|
+
return text.replace(re, (m: string, ...g: string[]) => {
|
|
92
|
+
if (keep?.(m)) return m;
|
|
93
|
+
hits[kind]++;
|
|
94
|
+
return typeof to === "function" ? to(m, ...g) : to;
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function redactForShare(
|
|
99
|
+
input: string,
|
|
100
|
+
ctx: ShareContext = NO_CONTEXT
|
|
101
|
+
): { text: string; hits: Hits } {
|
|
102
|
+
const hits = noHits();
|
|
103
|
+
let t = input;
|
|
104
|
+
|
|
105
|
+
// 1. Credentials by shape.
|
|
106
|
+
t = sub(t, PRIVATE_KEY_BLOCK, SECRET, hits, "secret");
|
|
107
|
+
t = sub(
|
|
108
|
+
t,
|
|
109
|
+
SECRET_ASSIGNMENT,
|
|
110
|
+
(_m, k) => `${k}${SECRET}`,
|
|
111
|
+
hits,
|
|
112
|
+
"secret",
|
|
113
|
+
(m) => m.endsWith(SECRET)
|
|
114
|
+
);
|
|
115
|
+
t = sub(t, INLINE_ASSIGNMENT, (_m, k) => `${k}${SECRET}`, hits, "secret");
|
|
116
|
+
t = sub(t, BEARER, (_m, k) => `${k}${SECRET}`, hits, "secret");
|
|
117
|
+
t = sub(
|
|
118
|
+
t,
|
|
119
|
+
URL_USERINFO,
|
|
120
|
+
(_m, s) => `${s}${SECRET}@`,
|
|
121
|
+
hits,
|
|
122
|
+
"secret",
|
|
123
|
+
(m) => m.endsWith(`${SECRET}@`)
|
|
124
|
+
);
|
|
125
|
+
t = sub(
|
|
126
|
+
t,
|
|
127
|
+
SECRET_PARAM,
|
|
128
|
+
(_m, k) => `${k}${SECRET}`,
|
|
129
|
+
hits,
|
|
130
|
+
"secret",
|
|
131
|
+
(m) => m.endsWith(SECRET)
|
|
132
|
+
);
|
|
133
|
+
for (const re of SECRET_TOKENS) t = sub(t, re, SECRET, hits, "secret");
|
|
134
|
+
t = sub(t, LONG_HEX, SECRET, hits, "secret");
|
|
135
|
+
t = sub(t, BASE64_RUN, SECRET, hits, "secret", (m) => !looksRandom(m));
|
|
136
|
+
|
|
137
|
+
// 2. This machine's own secrets, wherever they appear.
|
|
138
|
+
for (const value of ctx.secrets) {
|
|
139
|
+
if (value.length < 8 || !t.includes(value)) continue;
|
|
140
|
+
const parts = t.split(value);
|
|
141
|
+
hits.secret += parts.length - 1;
|
|
142
|
+
t = parts.join(SECRET);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 3. Home paths, and the account name before an @.
|
|
146
|
+
if (ctx.home.length > 1)
|
|
147
|
+
t = sub(
|
|
148
|
+
t,
|
|
149
|
+
new RegExp(escape(ctx.home) + "(?![\\w.-])", "g"),
|
|
150
|
+
"~",
|
|
151
|
+
hits,
|
|
152
|
+
"path"
|
|
153
|
+
);
|
|
154
|
+
t = sub(t, new RegExp(HOME.source, "g"), "~", hits, "path");
|
|
155
|
+
if (ctx.user.length > 2)
|
|
156
|
+
t = sub(
|
|
157
|
+
t,
|
|
158
|
+
new RegExp(`\\b${escape(ctx.user)}(?=@)`, "g"),
|
|
159
|
+
"user",
|
|
160
|
+
hits,
|
|
161
|
+
"path"
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// 4. Addresses.
|
|
165
|
+
t = sub(t, IPV4, "[ip]", hits, "ip", (m) => KEEP_IPV4.has(m));
|
|
166
|
+
t = sub(t, IPV6, "[ip]", hits, "ip", (m) => KEEP_IPV6.has(m));
|
|
167
|
+
|
|
168
|
+
// 5. This machine's names, and private-network suffixes.
|
|
169
|
+
const names = [...new Set(ctx.hosts.filter((h) => h.length > 2))].sort(
|
|
170
|
+
(a, b) => b.length - a.length
|
|
171
|
+
);
|
|
172
|
+
if (names.length)
|
|
173
|
+
t = sub(
|
|
174
|
+
t,
|
|
175
|
+
new RegExp(
|
|
176
|
+
`(?<![\\w.-])(?:${names.map(escape).join("|")})(?![\\w-])`,
|
|
177
|
+
"gi"
|
|
178
|
+
),
|
|
179
|
+
"[host]",
|
|
180
|
+
hits,
|
|
181
|
+
"host"
|
|
182
|
+
);
|
|
183
|
+
t = sub(t, CONNECT_HOST, "[host]", hits, "host");
|
|
184
|
+
t = sub(t, LOCAL_HOST, "[host]", hits, "host");
|
|
185
|
+
|
|
186
|
+
// 6. Email addresses.
|
|
187
|
+
t = sub(t, EMAIL, "[email]", hits, "email");
|
|
188
|
+
|
|
189
|
+
return { text: t, hits };
|
|
190
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A snapshot as one self-contained HTML page: inline CSS, no script, no
|
|
3
|
+
* request on load. Output depends only on (snapshot, sharedAt), so what the
|
|
4
|
+
* preview showed is byte-for-byte what gets uploaded.
|
|
5
|
+
*
|
|
6
|
+
* Markdown goes through react-markdown with raw HTML off (the default: do
|
|
7
|
+
* not add rehype-raw), so nothing a message says becomes markup.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { isValidElement, type ReactNode } from "react";
|
|
11
|
+
import { renderToStaticMarkup } from "./static-markup";
|
|
12
|
+
import ReactMarkdown, { type Components } from "react-markdown";
|
|
13
|
+
import remarkGfm from "remark-gfm";
|
|
14
|
+
import { Prism as Highlighter } from "react-syntax-highlighter";
|
|
15
|
+
import type { Snapshot, SnapshotItem } from "./snapshot";
|
|
16
|
+
import { STYLES } from "./styles";
|
|
17
|
+
|
|
18
|
+
export const MAX_SHARE_BYTES = 5 * 1024 * 1024;
|
|
19
|
+
|
|
20
|
+
const escape = (s: string) =>
|
|
21
|
+
s
|
|
22
|
+
.replace(/&/g, "&")
|
|
23
|
+
.replace(/</g, "<")
|
|
24
|
+
.replace(/>/g, ">")
|
|
25
|
+
.replace(/"/g, """);
|
|
26
|
+
|
|
27
|
+
const METAS = `<meta name="robots" content="noindex,nofollow"><meta name="referrer" content="no-referrer"><meta name="color-scheme" content="light dark">`;
|
|
28
|
+
|
|
29
|
+
function textOf(node: ReactNode): string {
|
|
30
|
+
if (typeof node === "string" || typeof node === "number") return String(node);
|
|
31
|
+
if (Array.isArray(node)) return node.map(textOf).join("");
|
|
32
|
+
if (isValidElement<{ children?: ReactNode }>(node))
|
|
33
|
+
return textOf(node.props.children);
|
|
34
|
+
return "";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const components: Components = {
|
|
38
|
+
pre: ({ children }) => <>{children}</>,
|
|
39
|
+
code({ className, children }) {
|
|
40
|
+
const language = className?.match(/language-([\w+-]+)/)?.[1];
|
|
41
|
+
const text = textOf(children);
|
|
42
|
+
if (!language && !text.includes("\n")) return <code>{children}</code>;
|
|
43
|
+
return (
|
|
44
|
+
<Highlighter
|
|
45
|
+
language={language ?? "text"}
|
|
46
|
+
useInlineStyles={false}
|
|
47
|
+
PreTag="pre"
|
|
48
|
+
CodeTag="code"
|
|
49
|
+
>
|
|
50
|
+
{text.replace(/\n$/, "")}
|
|
51
|
+
</Highlighter>
|
|
52
|
+
);
|
|
53
|
+
},
|
|
54
|
+
a: ({ href, children }) => (
|
|
55
|
+
<a href={href} target="_blank" rel="noopener noreferrer">
|
|
56
|
+
{children}
|
|
57
|
+
</a>
|
|
58
|
+
),
|
|
59
|
+
// A remote image would phone home when the page is opened.
|
|
60
|
+
img: ({ alt }) => <span>[image{alt ? `: ${alt}` : ""}]</span>,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
function Markdown({ text }: { text: string }) {
|
|
64
|
+
return (
|
|
65
|
+
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
|
|
66
|
+
{text}
|
|
67
|
+
</ReactMarkdown>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function Item({ item }: { item: SnapshotItem }) {
|
|
72
|
+
switch (item.type) {
|
|
73
|
+
case "user":
|
|
74
|
+
return (
|
|
75
|
+
<div className="msg user">
|
|
76
|
+
<div className="bubble">{item.text}</div>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
case "assistant":
|
|
80
|
+
return (
|
|
81
|
+
<div className="msg assistant">
|
|
82
|
+
<Markdown text={item.text} />
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
case "plan":
|
|
86
|
+
return (
|
|
87
|
+
<div className="msg plan">
|
|
88
|
+
<div className="kicker">Plan</div>
|
|
89
|
+
<Markdown text={item.text} />
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
case "error":
|
|
93
|
+
return <div className="msg error">{item.text}</div>;
|
|
94
|
+
case "divider":
|
|
95
|
+
return <div className="msg divider">{item.text}</div>;
|
|
96
|
+
case "artifact":
|
|
97
|
+
return (
|
|
98
|
+
<div className="msg artifact">
|
|
99
|
+
<div className="kicker">{item.label}</div>
|
|
100
|
+
<iframe
|
|
101
|
+
sandbox="allow-scripts"
|
|
102
|
+
referrerPolicy="no-referrer"
|
|
103
|
+
loading="lazy"
|
|
104
|
+
title={item.label}
|
|
105
|
+
style={{ height: item.height ?? 480 }}
|
|
106
|
+
srcDoc={withMetas(item.html ?? "")}
|
|
107
|
+
/>
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
case "detail":
|
|
111
|
+
return (
|
|
112
|
+
<details className="msg tool">
|
|
113
|
+
<summary>{item.label}</summary>
|
|
114
|
+
<pre>{item.text}</pre>
|
|
115
|
+
</details>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function renderChatSnapshot(
|
|
121
|
+
snapshot: Snapshot,
|
|
122
|
+
opts: { sharedAt: string }
|
|
123
|
+
): string {
|
|
124
|
+
const body = renderToStaticMarkup(
|
|
125
|
+
<>
|
|
126
|
+
{snapshot.items.map((item) => (
|
|
127
|
+
<Item key={item.id} item={item} />
|
|
128
|
+
))}
|
|
129
|
+
</>
|
|
130
|
+
);
|
|
131
|
+
const date = opts.sharedAt.slice(0, 10);
|
|
132
|
+
const note = snapshot.toolsHidden
|
|
133
|
+
? " · Read-only snapshot; tool calls hidden"
|
|
134
|
+
: " · Read-only snapshot";
|
|
135
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">${METAS}<title>${escape(snapshot.title)}</title><style>${STYLES}</style></head><body><div class="wrap"><header><h1 class="title">${escape(snapshot.title)}</h1><p class="meta">Shared from AgentOS · ${escape(date)}${note}</p></header><main>${body}</main><footer>Made with <a href="https://github.com/saadnvd1/agent-os" target="_blank" rel="noopener noreferrer">AgentOS</a></footer></div></body></html>`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** An artifact's page with the privacy metas added to its head. */
|
|
139
|
+
export function withMetas(html: string): string {
|
|
140
|
+
if (/<head[\s>]/i.test(html))
|
|
141
|
+
return html.replace(/<head(\s[^>]*)?>/i, (m) => m + METAS);
|
|
142
|
+
if (/<html[\s>]/i.test(html))
|
|
143
|
+
return html.replace(
|
|
144
|
+
/<html(\s[^>]*)?>/i,
|
|
145
|
+
(m) => `${m}<head>${METAS}</head>`
|
|
146
|
+
);
|
|
147
|
+
return `<!doctype html><html><head><meta charset="utf-8">${METAS}</head><body>${html}</body></html>`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function renderArtifactSnapshot(html: string, title: string): string {
|
|
151
|
+
const page = withMetas(html);
|
|
152
|
+
return /<title[\s>]/i.test(page)
|
|
153
|
+
? page
|
|
154
|
+
: page.replace(
|
|
155
|
+
/<head(\s[^>]*)?>/i,
|
|
156
|
+
(m) => `${m}<title>${escape(title)}</title>`
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export const tooBig = (html: string) =>
|
|
161
|
+
Buffer.byteLength(html, "utf8") > MAX_SHARE_BYTES;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { demoMode } from "@/lib/security/demo";
|
|
3
|
+
import { readCapped } from "@/lib/security/read-capped";
|
|
4
|
+
import { BadShare } from "./service";
|
|
5
|
+
import { ShareError } from "./connect";
|
|
6
|
+
|
|
7
|
+
const MAX_BODY = 64 * 1024;
|
|
8
|
+
|
|
9
|
+
export const fail = (error: string, status: number) =>
|
|
10
|
+
NextResponse.json({ error }, { status });
|
|
11
|
+
|
|
12
|
+
export const refuseDemo = () =>
|
|
13
|
+
demoMode() ? fail("Not in the demo.", 403) : null;
|
|
14
|
+
|
|
15
|
+
/** The JSON body, capped, or a response saying why not. */
|
|
16
|
+
export async function readJson(
|
|
17
|
+
request: Request
|
|
18
|
+
): Promise<{ body: unknown } | { refused: NextResponse }> {
|
|
19
|
+
const text = await readCapped(request, MAX_BODY);
|
|
20
|
+
if (text === null) return { refused: fail("Request too large.", 413) };
|
|
21
|
+
try {
|
|
22
|
+
return { body: text ? JSON.parse(text) : {} };
|
|
23
|
+
} catch {
|
|
24
|
+
return { refused: fail("Invalid JSON.", 400) };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function toResponse(err: unknown): NextResponse {
|
|
29
|
+
if (err instanceof BadShare || err instanceof ShareError)
|
|
30
|
+
return fail(err.message, err.status);
|
|
31
|
+
console.error(`share: ${(err as Error)?.message ?? err}`);
|
|
32
|
+
return fail("Couldn't share that.", 500);
|
|
33
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The share flow, shared by the preview and create routes: the same inputs
|
|
3
|
+
* always render the same page, so create can prove it uploads exactly what
|
|
4
|
+
* the preview showed (by hash) without trusting any markup from the client.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createHash } from "crypto";
|
|
8
|
+
import type { ShareExpiry } from "@/lib/connect/control-api";
|
|
9
|
+
import { getSession } from "@/lib/chat/registry";
|
|
10
|
+
import { getArtifact } from "@/lib/artifacts/store";
|
|
11
|
+
import { shareContext } from "./context";
|
|
12
|
+
import type { Hits } from "./redact";
|
|
13
|
+
import {
|
|
14
|
+
MAX_SHARE_BYTES,
|
|
15
|
+
renderArtifactSnapshot,
|
|
16
|
+
renderChatSnapshot,
|
|
17
|
+
} from "./render";
|
|
18
|
+
import { buildArtifactSnapshot, buildChatSnapshot } from "./snapshot";
|
|
19
|
+
|
|
20
|
+
export interface ShareRequest {
|
|
21
|
+
kind: "chat" | "artifact";
|
|
22
|
+
sessionId?: string;
|
|
23
|
+
artifactId?: string;
|
|
24
|
+
includeTools: boolean;
|
|
25
|
+
/** Messages the reader left out in the preview. */
|
|
26
|
+
dropIds?: string[];
|
|
27
|
+
sharedAt: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ItemRow {
|
|
31
|
+
id: string;
|
|
32
|
+
type: string;
|
|
33
|
+
tools: boolean;
|
|
34
|
+
excerpt: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface Page {
|
|
38
|
+
html: string;
|
|
39
|
+
title: string;
|
|
40
|
+
bytes: number;
|
|
41
|
+
hash: string;
|
|
42
|
+
hits: Hits;
|
|
43
|
+
/** Every item that could be shared, for the dialog's list. */
|
|
44
|
+
items: ItemRow[];
|
|
45
|
+
tooBig: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class BadShare extends Error {
|
|
49
|
+
constructor(
|
|
50
|
+
message: string,
|
|
51
|
+
readonly status: number
|
|
52
|
+
) {
|
|
53
|
+
super(message);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const EXPIRIES: ShareExpiry[] = ["1d", "7d", "30d", "never"];
|
|
58
|
+
const ID = /^[\w-]{1,80}$/;
|
|
59
|
+
|
|
60
|
+
/** The request body as a ShareRequest, or a BadShare saying what's wrong. */
|
|
61
|
+
export function parseRequest(raw: unknown, sharedAt?: unknown): ShareRequest {
|
|
62
|
+
const b = (raw ?? {}) as Record<string, unknown>;
|
|
63
|
+
const kind = b.kind;
|
|
64
|
+
if (kind !== "chat" && kind !== "artifact")
|
|
65
|
+
throw new BadShare("kind must be chat or artifact", 400);
|
|
66
|
+
const sessionId = typeof b.sessionId === "string" ? b.sessionId : undefined;
|
|
67
|
+
const artifactId =
|
|
68
|
+
typeof b.artifactId === "string" ? b.artifactId : undefined;
|
|
69
|
+
if (
|
|
70
|
+
kind === "chat"
|
|
71
|
+
? !sessionId || !ID.test(sessionId)
|
|
72
|
+
: !artifactId || !ID.test(artifactId)
|
|
73
|
+
)
|
|
74
|
+
throw new BadShare(
|
|
75
|
+
`${kind === "chat" ? "sessionId" : "artifactId"} is required`,
|
|
76
|
+
400
|
|
77
|
+
);
|
|
78
|
+
let dropIds: string[] | undefined;
|
|
79
|
+
if (b.dropIds !== undefined) {
|
|
80
|
+
if (
|
|
81
|
+
!Array.isArray(b.dropIds) ||
|
|
82
|
+
b.dropIds.some((i) => typeof i !== "string")
|
|
83
|
+
)
|
|
84
|
+
throw new BadShare("dropIds must be a list of ids", 400);
|
|
85
|
+
dropIds = b.dropIds as string[];
|
|
86
|
+
}
|
|
87
|
+
const at = typeof sharedAt === "string" ? sharedAt : new Date().toISOString();
|
|
88
|
+
if (Number.isNaN(Date.parse(at)))
|
|
89
|
+
throw new BadShare("sharedAt is not a date", 400);
|
|
90
|
+
return {
|
|
91
|
+
kind,
|
|
92
|
+
sessionId,
|
|
93
|
+
artifactId,
|
|
94
|
+
includeTools: b.includeTools === true,
|
|
95
|
+
dropIds,
|
|
96
|
+
sharedAt: at,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const sha256 = (s: string) => createHash("sha256").update(s).digest("hex");
|
|
101
|
+
|
|
102
|
+
export async function buildPage(req: ShareRequest): Promise<Page> {
|
|
103
|
+
const ctx = shareContext();
|
|
104
|
+
let html: string;
|
|
105
|
+
let title: string;
|
|
106
|
+
let hits: Hits;
|
|
107
|
+
let items: ItemRow[] = [];
|
|
108
|
+
|
|
109
|
+
if (req.kind === "chat") {
|
|
110
|
+
let session;
|
|
111
|
+
try {
|
|
112
|
+
session = getSession(req.sessionId!);
|
|
113
|
+
} catch {
|
|
114
|
+
throw new BadShare("No such chat", 404);
|
|
115
|
+
}
|
|
116
|
+
if (session.view !== "chat")
|
|
117
|
+
throw new BadShare("Only chats can be shared", 400);
|
|
118
|
+
const all = await buildChatSnapshot(req.sessionId!, ctx, {
|
|
119
|
+
includeTools: req.includeTools,
|
|
120
|
+
});
|
|
121
|
+
items = all.items.map(({ id, type, tools, excerpt }) => ({
|
|
122
|
+
id,
|
|
123
|
+
type,
|
|
124
|
+
tools,
|
|
125
|
+
excerpt,
|
|
126
|
+
}));
|
|
127
|
+
const snap = req.dropIds?.length
|
|
128
|
+
? await buildChatSnapshot(req.sessionId!, ctx, {
|
|
129
|
+
includeTools: req.includeTools,
|
|
130
|
+
dropIds: req.dropIds,
|
|
131
|
+
})
|
|
132
|
+
: all;
|
|
133
|
+
if (!snap.items.length)
|
|
134
|
+
throw new BadShare("Nothing to share: every message is left out", 400);
|
|
135
|
+
html = renderChatSnapshot(snap, { sharedAt: req.sharedAt });
|
|
136
|
+
title = snap.title;
|
|
137
|
+
hits = snap.hits;
|
|
138
|
+
} else {
|
|
139
|
+
if (!getArtifact(req.artifactId!)) throw new BadShare("No such page", 404);
|
|
140
|
+
const snap = await buildArtifactSnapshot(req.artifactId!, ctx);
|
|
141
|
+
if (!snap) throw new BadShare("That page is gone", 404);
|
|
142
|
+
html = renderArtifactSnapshot(snap.html, snap.title);
|
|
143
|
+
title = snap.title;
|
|
144
|
+
hits = snap.hits;
|
|
145
|
+
}
|
|
146
|
+
const bytes = Buffer.byteLength(html, "utf8");
|
|
147
|
+
return {
|
|
148
|
+
html,
|
|
149
|
+
title,
|
|
150
|
+
bytes,
|
|
151
|
+
hash: sha256(html),
|
|
152
|
+
hits,
|
|
153
|
+
items,
|
|
154
|
+
tooBig: bytes > MAX_SHARE_BYTES,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export const TOO_BIG =
|
|
159
|
+
"Too big to share; drop some messages or turn tool calls off.";
|