@saadnvd1/agent-os 0.3.558 → 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.
Files changed (51) hide show
  1. package/README.md +1 -0
  2. package/app/api/sessions/route.ts +3 -3
  3. package/app/api/shares/[id]/route.ts +23 -0
  4. package/app/api/shares/preview/route.ts +30 -0
  5. package/app/api/shares/route.test.ts +455 -0
  6. package/app/api/shares/route.ts +101 -0
  7. package/app/page.tsx +5 -0
  8. package/components/Chat/Artifact.tsx +17 -1
  9. package/components/Pane/MobileTabBar.tsx +19 -0
  10. package/components/Pane/PaneMenu.tsx +16 -0
  11. package/components/SessionList/SessionRowMenu.tsx +16 -0
  12. package/components/Settings/SettingsDialog.test.ts +3 -0
  13. package/components/Settings/SettingsDialog.tsx +9 -0
  14. package/components/Settings/commands.ts +9 -0
  15. package/components/Settings/entry-points.test.ts +1 -0
  16. package/components/Share/ShareDialog.tsx +336 -0
  17. package/components/Share/format.test.ts +20 -0
  18. package/components/Share/format.ts +33 -0
  19. package/components/Shares/SharedLinksPanel.tsx +147 -0
  20. package/data/shares/index.ts +2 -0
  21. package/data/shares/keys.ts +4 -0
  22. package/data/shares/queries.ts +111 -0
  23. package/lib/chat/drivers/claude.ts +5 -4
  24. package/lib/connect/control-api.ts +84 -0
  25. package/lib/db/migrations.ts +50 -0
  26. package/lib/db/queries.ts +4 -3
  27. package/lib/models/plan-step.ts +107 -15
  28. package/lib/orchestrator/conditions.ts +12 -4
  29. package/lib/orchestrator/events.ts +23 -0
  30. package/lib/orchestrator/reconcile.ts +201 -0
  31. package/lib/orchestrator/untrusted.ts +12 -2
  32. package/lib/plans/features.ts +69 -13
  33. package/lib/plans/pieces.ts +70 -15
  34. package/lib/plans/release.ts +46 -4
  35. package/lib/recipes/settle.ts +3 -0
  36. package/lib/security/demo.ts +1 -0
  37. package/lib/session-audit/normalize.ts +1 -1
  38. package/lib/settings-url.ts +1 -0
  39. package/lib/share/connect.ts +86 -0
  40. package/lib/share/context.ts +50 -0
  41. package/lib/share/redact.ts +190 -0
  42. package/lib/share/render.tsx +161 -0
  43. package/lib/share/route.ts +33 -0
  44. package/lib/share/service.ts +159 -0
  45. package/lib/share/snapshot.ts +261 -0
  46. package/lib/share/static-markup.ts +16 -0
  47. package/lib/share/store.ts +39 -0
  48. package/lib/share/styles.ts +83 -0
  49. package/lib/sidebar/shelves.ts +5 -6
  50. package/package.json +1 -1
  51. package/stores/shareUi.ts +17 -0
@@ -8,7 +8,7 @@ import { loadProjectConfig } from "../project-config";
8
8
  import { createTask } from "../tasks";
9
9
  import { startOrQueue } from "../tasks/queue";
10
10
  import { braked, BrakeRefused } from "../orchestrator/brakes";
11
- import { queueEvent } from "../orchestrator/events";
11
+ import { queueEvent, queueEventOnce } from "../orchestrator/events";
12
12
  import { amendBrief } from "../orchestrator/amendments";
13
13
  import { usageRows } from "../session-logs/gather";
14
14
  import { pieceBrief } from "./pieces";
@@ -190,7 +190,8 @@ async function releaseOne(
190
190
  )
191
191
  .run((piece.session_id ??= randomUUID()), piece.id).changes;
192
192
  if (!claimed) return;
193
- const prompt = `[Feature "${feature.name}", piece ${piece.position} of ${all.length}, started by AgentOS from an approved plan]\n${pieceBrief(all.length, { position: piece.position, name: piece.name, slice: piece.slice, owns: piece.owns ? piece.owns.split(", ") : [] }, planPathOf(feature.id), beforeLines(all, piece.position))}`;
193
+ const project = getProject(piece.project_id);
194
+ const prompt = `[Feature "${feature.name}", piece ${piece.position} of ${all.length}, started by AgentOS from an approved plan]\n${pieceBrief(all.length, { position: piece.position, name: piece.name, slice: piece.slice, owns: piece.owns ? piece.owns.split(", ") : [] }, planPathOf(feature.id), beforeLines(all, piece.position), project?.name)}`;
194
195
  try {
195
196
  const id = await deps.start(feature, piece, prompt, piece.session_id);
196
197
  if (id !== piece.session_id)
@@ -246,16 +247,56 @@ function close(feature: Feature, all: FeaturePiece[], deps: ReleaseDeps): void {
246
247
  )
247
248
  .run(cost, ended > started ? ended - started : null, feature.id).changes;
248
249
  if (!claimed) return;
250
+ // A plan that listed more than it built is told, not closed quietly.
251
+ const listed = feature.listed ?? live.length;
252
+ const left = [
253
+ feature.folded ? `folded: ${feature.folded.split("\n").join("; ")}` : "",
254
+ all.some((p) => p.status === "dropped")
255
+ ? `dropped: ${all
256
+ .filter((p) => p.status === "dropped")
257
+ .map((p) => p.name)
258
+ .join(", ")}`
259
+ : "",
260
+ ].filter(Boolean);
261
+ const short =
262
+ listed > live.length || left.length
263
+ ? ` Its plan listed ${listed}; check the rest landed (${left.join("; ") || "not built as pieces"}).`
264
+ : "";
249
265
  say(
250
266
  feature,
251
267
  `done-${feature.id}`,
252
- `Feature "${feature.name}" is merged: ${live.length} ${live.length === 1 ? "PR" : "PRs"}.`
268
+ `Feature "${feature.name}" is merged: ${live.length} ${live.length === 1 ? "PR" : "PRs"}.${short}`
253
269
  );
254
270
  deps.pushFeatureMerged(feature);
255
271
  }
256
272
 
273
+ // A dependency merged and the piece after it still hasn't started: the sweep
274
+ // that should have started it is stuck, or the start keeps being held.
275
+ export const STUCK_PIECE_MS = 30 * 60 * 1000;
276
+
277
+ function stuckPieces(feature: Feature, all: FeaturePiece[], now: number): void {
278
+ for (const piece of all) {
279
+ if (piece.status !== "waiting" || !piece.after_position) continue;
280
+ const dep = all.find((p) => p.position === piece.after_position);
281
+ const merged = dep ? sessionOf(dep.session_id)?.merged_at : null;
282
+ if (!dep || dep.status !== "merged" || !merged) continue;
283
+ const since = Date.parse(`${merged}Z`);
284
+ const wait =
285
+ piece.after_kind === "deployed" ? STUCK_PIECE_MS * 2 : STUCK_PIECE_MS;
286
+ if (now - since < wait) continue;
287
+ queueEventOnce(
288
+ feature.workspace_id,
289
+ `feature:stuck-${piece.id}-${since}`,
290
+ feature.plan_session_id,
291
+ `Feature "${feature.name}": piece ${piece.position} ${piece.name} is still waiting ${Math.floor((now - since) / 60000)}m after ${dep.position} ${dep.name} merged. Release it anyway or drop it.`,
292
+ now
293
+ );
294
+ }
295
+ }
296
+
257
297
  export async function releasePieces(
258
- deps: ReleaseDeps = realDeps
298
+ deps: ReleaseDeps = realDeps,
299
+ now = Date.now()
259
300
  ): Promise<void> {
260
301
  const features = db
261
302
  .prepare(`SELECT * FROM features WHERE status = 'building'`)
@@ -267,6 +308,7 @@ export async function releasePieces(
267
308
  await releaseOne(feature, piecesOf(feature.id), piece, deps).catch((e) =>
268
309
  console.error(`[features] piece ${piece.id}:`, e)
269
310
  );
311
+ stuckPieces(feature, piecesOf(feature.id), now);
270
312
  close(feature, piecesOf(feature.id), deps);
271
313
  }
272
314
  }
@@ -459,6 +459,9 @@ export function startSettling(): void {
459
459
  busy = true;
460
460
  void settleRuns()
461
461
  .then(() => releasePieces())
462
+ .then(async () =>
463
+ (await import("../orchestrator/reconcile")).reconcileSweep()
464
+ )
462
465
  .catch((e) => console.error("[recipes] settle:", e))
463
466
  .finally(() => {
464
467
  busy = false;
@@ -65,6 +65,7 @@ export const STATIC_SIBLINGS = [
65
65
  "me",
66
66
  "items",
67
67
  "network",
68
+ "preview",
68
69
  "queue",
69
70
  "status",
70
71
  ];
@@ -9,7 +9,7 @@ import { redact } from "../orchestrator/untrusted";
9
9
  /** Tokens a text costs, by the usual 4 characters a token. An estimate. */
10
10
  export const estTokens = (chars: number) => Math.ceil(chars / 4);
11
11
 
12
- const HOME = /\/(?:Users|home)\/[^/\s"']+/g;
12
+ export const HOME = /\/(?:Users|home)\/[^/\s"']+/g;
13
13
  const WORKTREE = /~\/\.agent-os\/worktrees\/[^/\s"']+/g;
14
14
  const ORCH_DIR = /~\/\.agent-os\/orchestrators\/[^/\s"']+/g;
15
15
  const TMP = /\/(?:private\/)?(?:var\/folders|tmp)\/[^\s"']*/g;
@@ -10,6 +10,7 @@ export const SETTINGS_SECTIONS = [
10
10
  "workspaces",
11
11
  "models",
12
12
  "devices",
13
+ "shares",
13
14
  "notifications",
14
15
  "recipes",
15
16
  "schedules",
@@ -0,0 +1,86 @@
1
+ /** Connect's share API, answered in terms the dialog can act on. */
2
+
3
+ import { loadConnect } from "@/lib/connect/config";
4
+ import {
5
+ ApiError,
6
+ clean,
7
+ listShares,
8
+ revokeShare,
9
+ uploadShare,
10
+ type RemoteShare,
11
+ type ShareExpiry,
12
+ } from "@/lib/connect/control-api";
13
+
14
+ export const NOT_LINKED = "Link this machine to Connect to share a link.";
15
+
16
+ export class ShareError extends Error {
17
+ constructor(
18
+ message: string,
19
+ readonly status: number
20
+ ) {
21
+ super(message);
22
+ }
23
+ }
24
+
25
+ export const linked = () => loadConnect() !== null;
26
+
27
+ function files() {
28
+ const f = loadConnect();
29
+ if (!f) throw new ShareError(NOT_LINKED, 409);
30
+ return f;
31
+ }
32
+
33
+ /** A failed call as a message that says what to do next. */
34
+ function explain(err: unknown): never {
35
+ if (err instanceof ShareError) throw err;
36
+ if (err instanceof ApiError) {
37
+ if (err.status === 413)
38
+ throw new ShareError(
39
+ `Connect refused it as too big or over your quota: ${clean(err.message, 120)}`,
40
+ 413
41
+ );
42
+ if (err.status === 429)
43
+ throw new ShareError(
44
+ "Too many shares just now. Try again in a few minutes.",
45
+ 429
46
+ );
47
+ if (err.status === 401 || err.status === 403)
48
+ throw new ShareError(
49
+ "Connect doesn't accept this machine. Link it again in Settings → Devices & access.",
50
+ 502
51
+ );
52
+ if (err.status === 404) throw new ShareError("Not found on Connect.", 404);
53
+ throw new ShareError(clean(err.message, 120), 502);
54
+ }
55
+ throw new ShareError("Couldn't reach Connect. Try again in a moment.", 502);
56
+ }
57
+
58
+ export async function createRemote(share: {
59
+ kind: "chat" | "artifact";
60
+ title: string;
61
+ html: string;
62
+ expiresIn: ShareExpiry;
63
+ }) {
64
+ const { config, machineKey } = files();
65
+ try {
66
+ return await uploadShare(config, machineKey, share);
67
+ } catch (err) {
68
+ explain(err);
69
+ }
70
+ }
71
+
72
+ export async function listRemote(): Promise<RemoteShare[]> {
73
+ const { config, machineKey } = files();
74
+ return listShares(config, machineKey);
75
+ }
76
+
77
+ /** Revokes on Connect; a share Connect no longer has counts as revoked. */
78
+ export async function revokeRemote(id: string): Promise<void> {
79
+ const { config, machineKey } = files();
80
+ try {
81
+ await revokeShare(config, machineKey, id);
82
+ } catch (err) {
83
+ if (err instanceof ApiError && err.status === 404) return;
84
+ explain(err);
85
+ }
86
+ }
@@ -0,0 +1,50 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import type { ShareContext } from "./redact";
5
+
6
+ // Environment variables that hold a credential, by name.
7
+ const SECRET_NAME = /(KEY|TOKEN|SECRET|PASSWORD|PASS|PWD|CREDENTIALS?|AUTH)/;
8
+ const NOT_SECRET_NAME = /(SOCK|_FILE|_PATH|_DIR)$/;
9
+ const USERINFO_URL = /^[a-z][a-z0-9+.-]*:\/\/[^@\s/]+@/i;
10
+
11
+ export function envSecrets(env: NodeJS.ProcessEnv = process.env): string[] {
12
+ const out = new Set<string>();
13
+ for (const [name, value] of Object.entries(env)) {
14
+ if (!value || value.length < 8) continue;
15
+ const named = SECRET_NAME.test(name) && !NOT_SECRET_NAME.test(name);
16
+ if (named || (name.endsWith("_URL") && USERINFO_URL.test(value)))
17
+ out.add(value);
18
+ }
19
+ return [...out];
20
+ }
21
+
22
+ /** Host and HostName entries of an ssh config, wildcards left out. */
23
+ export function sshHosts(config: string): string[] {
24
+ const out = new Set<string>();
25
+ for (const line of config.split("\n")) {
26
+ const m = line.match(/^\s*(?:Host|HostName)\s+(.+?)\s*$/i);
27
+ if (!m) continue;
28
+ for (const h of m[1].split(/\s+/))
29
+ if (!/[*?!]/.test(h) && !/^\d+\.\d+\.\d+\.\d+$/.test(h)) out.add(h);
30
+ }
31
+ return [...out];
32
+ }
33
+
34
+ /** This machine's details, read once per request. Every read fails soft. */
35
+ export function shareContext(): ShareContext {
36
+ const home = os.homedir();
37
+ let user = "";
38
+ try {
39
+ user = os.userInfo().username;
40
+ } catch {
41
+ user = process.env.USER ?? "";
42
+ }
43
+ const hostname = os.hostname();
44
+ let ssh = "";
45
+ try {
46
+ ssh = fs.readFileSync(path.join(home, ".ssh", "config"), "utf8");
47
+ } catch {}
48
+ const hosts = [hostname, hostname.split(".")[0], ...sshHosts(ssh)];
49
+ return { home, user, secrets: envSecrets(), hosts };
50
+ }
@@ -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, "&amp;")
23
+ .replace(/</g, "&lt;")
24
+ .replace(/>/g, "&gt;")
25
+ .replace(/"/g, "&quot;");
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
+ }