@saadnvd1/agent-os 0.3.595 → 0.3.596

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.
@@ -0,0 +1,187 @@
1
+ /**
2
+ * A linked machine's session: its chat and files are there, so its own
3
+ * AgentOS lists and serves them, with its own roots and secret filter. What
4
+ * comes back is checked field by field and re-served with this machine's
5
+ * headers, never passed through as it came.
6
+ */
7
+
8
+ import { ARTIFACT_HEADERS } from "../artifacts/serve";
9
+ import { IMAGE_TYPES } from "../artifacts/image-paths";
10
+ import { IMAGE_CSP } from "../artifacts/serve";
11
+ import { cleanRemoteText, hostApi, type HostLink } from "../hosts/remote-api";
12
+ import type {
13
+ ArtifactKind,
14
+ ArtifactPreview,
15
+ ArtifactState,
16
+ SessionArtifact,
17
+ } from "./types";
18
+ import type { Content } from "./serve";
19
+
20
+ const MAX_PEER_ARTIFACTS = 1000;
21
+ export const MAX_PEER_BYTES = 20 * 1024 * 1024;
22
+
23
+ const KINDS = new Set<ArtifactKind>([
24
+ "plan",
25
+ "file",
26
+ "page",
27
+ "image",
28
+ "log",
29
+ "pr",
30
+ "run",
31
+ "session-log",
32
+ ]);
33
+ const STATES = new Set<ArtifactState>([
34
+ "live",
35
+ "kept",
36
+ "in-pr",
37
+ "gone",
38
+ "outside",
39
+ ]);
40
+ const PREVIEWS = new Set<ArtifactPreview>([
41
+ "markdown",
42
+ "html",
43
+ "image",
44
+ "text",
45
+ "none",
46
+ ]);
47
+ const ID = /^[0-9a-f]{16}$/;
48
+
49
+ const num = (v: unknown) =>
50
+ typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : undefined;
51
+
52
+ export function cleanPeerArtifact(raw: unknown): SessionArtifact | null {
53
+ const a = raw as Record<string, unknown> | null;
54
+ if (!a || typeof a !== "object") return null;
55
+ if (typeof a.id !== "string" || !ID.test(a.id)) return null;
56
+ if (!KINDS.has(a.kind as ArtifactKind)) return null;
57
+ if (!STATES.has(a.state as ArtifactState)) return null;
58
+ if (!PREVIEWS.has(a.preview as ArtifactPreview)) return null;
59
+ const url =
60
+ typeof a.url === "string" && /^https:\/\//.test(a.url)
61
+ ? cleanRemoteText(a.url)
62
+ : undefined;
63
+ return {
64
+ id: a.id,
65
+ kind: a.kind as ArtifactKind,
66
+ name: cleanRemoteText(a.name) || "Untitled",
67
+ ...(typeof a.path === "string" && { path: cleanRemoteText(a.path) }),
68
+ at: num(a.at) ?? 0,
69
+ ...(num(a.size) !== undefined && { size: num(a.size) }),
70
+ ...(num(a.edits) && { edits: num(a.edits) }),
71
+ state: a.state as ArtifactState,
72
+ preview: a.preview as ArtifactPreview,
73
+ ...((a.by === "you" || a.by === "shell") && { by: a.by }),
74
+ ...(typeof a.detail === "string" && { detail: cleanRemoteText(a.detail) }),
75
+ ...(typeof a.folder === "string" && { folder: cleanRemoteText(a.folder) }),
76
+ ...(url && { url }),
77
+ };
78
+ }
79
+
80
+ export async function peerArtifacts(
81
+ link: HostLink,
82
+ sessionId: string
83
+ ): Promise<SessionArtifact[]> {
84
+ const { artifacts } = await hostApi<{ artifacts?: unknown }>(
85
+ link,
86
+ `/api/sessions/${encodeURIComponent(sessionId)}/artifacts`
87
+ );
88
+ if (!Array.isArray(artifacts)) return [];
89
+ return artifacts
90
+ .slice(0, MAX_PEER_ARTIFACTS)
91
+ .map(cleanPeerArtifact)
92
+ .filter((a): a is SessionArtifact => !!a);
93
+ }
94
+
95
+ async function readBytes(
96
+ res: Response,
97
+ max: number
98
+ ): Promise<Uint8Array<ArrayBuffer>> {
99
+ if (Number(res.headers.get("content-length")) > max)
100
+ throw new Error("too large");
101
+ const reader = res.body?.getReader();
102
+ if (!reader) return new Uint8Array();
103
+ const chunks: Uint8Array[] = [];
104
+ let total = 0;
105
+ for (;;) {
106
+ const { done, value } = await reader.read();
107
+ if (done) break;
108
+ total += value.byteLength;
109
+ if (total > max) {
110
+ await reader.cancel().catch(() => undefined);
111
+ throw new Error("too large");
112
+ }
113
+ chunks.push(value);
114
+ }
115
+ return new Uint8Array(Buffer.concat(chunks));
116
+ }
117
+
118
+ const IMAGE_MEDIA = new Set(Object.values(IMAGE_TYPES));
119
+ const TEXT_HEADERS = (type: string) => ({
120
+ "Content-Type": `${type}; charset=utf-8`,
121
+ "Content-Security-Policy": "default-src 'none'; sandbox",
122
+ "X-Content-Type-Options": "nosniff",
123
+ "Cache-Control": "private, no-cache",
124
+ });
125
+
126
+ // Our own headers for what the peer says it is; anything else is refused.
127
+ export function peerHeaders(
128
+ contentType: string
129
+ ): Record<string, string> | null {
130
+ const type = contentType.split(";")[0].trim().toLowerCase();
131
+ if (type === "text/html") return ARTIFACT_HEADERS;
132
+ if (type === "text/plain" || type === "text/markdown")
133
+ return TEXT_HEADERS(type);
134
+ if (IMAGE_MEDIA.has(type))
135
+ return {
136
+ "Content-Type": type,
137
+ "Content-Security-Policy": IMAGE_CSP,
138
+ "X-Content-Type-Options": "nosniff",
139
+ "Cache-Control": "private, no-cache",
140
+ };
141
+ return null;
142
+ }
143
+
144
+ export async function peerContent(
145
+ link: HostLink,
146
+ sessionId: string,
147
+ artifactId: string
148
+ ): Promise<Content> {
149
+ if (!ID.test(artifactId))
150
+ return { ok: false, status: 404, error: "Not found" };
151
+ let res: Response;
152
+ try {
153
+ res = await fetch(
154
+ `${link.url}/api/sessions/${encodeURIComponent(sessionId)}/artifacts/${artifactId}`,
155
+ {
156
+ headers: { Authorization: `Bearer ${link.token}` },
157
+ signal: AbortSignal.timeout(20_000),
158
+ redirect: "error",
159
+ }
160
+ );
161
+ } catch {
162
+ return {
163
+ ok: false,
164
+ status: 502,
165
+ error: `Can't reach AgentOS on ${link.hostName}`,
166
+ };
167
+ }
168
+ if (!res.ok) {
169
+ await res.body?.cancel().catch(() => undefined);
170
+ return {
171
+ ok: false,
172
+ status: res.status === 404 || res.status === 403 ? res.status : 502,
173
+ error: `${link.hostName}: HTTP ${res.status}`,
174
+ };
175
+ }
176
+ const headers = peerHeaders(res.headers.get("content-type") ?? "");
177
+ if (!headers) {
178
+ await res.body?.cancel().catch(() => undefined);
179
+ return { ok: false, status: 502, error: "Unexpected content" };
180
+ }
181
+ try {
182
+ const body = await readBytes(res, MAX_PEER_BYTES);
183
+ return { ok: true, body, headers };
184
+ } catch {
185
+ return { ok: false, status: 502, error: "Too large" };
186
+ }
187
+ }
@@ -0,0 +1,265 @@
1
+ /**
2
+ * What opening an artifact returns. Every file is checked again here (real
3
+ * path, roots, secrets, size), whatever the list said; HTML is served as
4
+ * agent output in the same sandbox as a rendered page.
5
+ */
6
+
7
+ import fs from "fs";
8
+ import os from "os";
9
+ import { db, type Session } from "../db";
10
+ import { getItem } from "../chat/store";
11
+ import { findOutputFile } from "../chat/task-output";
12
+ import {
13
+ getArtifact,
14
+ readArtifactHtml,
15
+ MAX_HTML_BYTES,
16
+ } from "../artifacts/store";
17
+ import { MAX_IMAGE_BYTES } from "../artifacts/images";
18
+ import { IMAGE_TYPES } from "../artifacts/image-paths";
19
+ import {
20
+ ARTIFACT_HEADERS,
21
+ IMAGE_CSP,
22
+ prepareArtifact,
23
+ } from "../artifacts/serve";
24
+ import { demoMode } from "../security/demo";
25
+ import { run } from "../tasks/gh";
26
+ import { repoOfPR } from "../tasks/pr-activity";
27
+ import { resolveAllowed } from "./access";
28
+ import { previewFor, type Entry } from "./derive";
29
+ import { rootsOf } from "./collect";
30
+
31
+ export const MAX_TEXT_BYTES = 256 * 1024;
32
+ export const LOG_TAIL_BYTES = 64 * 1024;
33
+
34
+ export type Content =
35
+ | {
36
+ ok: true;
37
+ body: Uint8Array<ArrayBuffer> | string;
38
+ headers: Record<string, string>;
39
+ }
40
+ | { ok: false; status: number; error: string };
41
+
42
+ const TEXT_CSP = "default-src 'none'; sandbox";
43
+
44
+ const textHeaders = (type: string, truncated = false) => ({
45
+ "Content-Type": `${type}; charset=utf-8`,
46
+ "Content-Security-Policy": TEXT_CSP,
47
+ "X-Content-Type-Options": "nosniff",
48
+ "Cache-Control": "private, no-cache",
49
+ ...(truncated && { "X-Artifact-Truncated": "1" }),
50
+ });
51
+
52
+ const imageHeaders = (type: string) => ({
53
+ "Content-Type": type,
54
+ "Content-Security-Policy": IMAGE_CSP,
55
+ "X-Content-Type-Options": "nosniff",
56
+ "Cache-Control": "private, no-cache",
57
+ });
58
+
59
+ const IMAGE_MEDIA = new Set(Object.values(IMAGE_TYPES));
60
+ const fail = (status: number, error: string): Content => ({
61
+ ok: false,
62
+ status,
63
+ error,
64
+ });
65
+ const markdown = (text: string): Content => ({
66
+ ok: true,
67
+ body: text,
68
+ headers: textHeaders("text/markdown"),
69
+ });
70
+
71
+ // Bytes from a file: its head, or its tail for a log.
72
+ function readSlice(file: string, size: number, max: number, tail: boolean) {
73
+ const len = Math.min(size, max);
74
+ const buf = Buffer.alloc(len);
75
+ const fd = fs.openSync(file, "r");
76
+ try {
77
+ fs.readSync(fd, buf, 0, len, tail ? size - len : 0);
78
+ } finally {
79
+ fs.closeSync(fd);
80
+ }
81
+ return buf;
82
+ }
83
+
84
+ const binary = (buf: Buffer) => buf.subarray(0, 8000).includes(0);
85
+
86
+ function serveFile(file: string, roots: string[], asLog = false): Content {
87
+ const hit = resolveAllowed(file, roots);
88
+ if (!hit.ok)
89
+ return fail(hit.status, hit.status === 403 ? "Not served" : "Not found");
90
+ const kind = asLog ? "text" : previewFor(hit.real);
91
+ if (kind === "image") {
92
+ const ext = hit.real.split(".").pop()!.toLowerCase();
93
+ if (hit.size > MAX_IMAGE_BYTES) return fail(413, "Too large");
94
+ return {
95
+ ok: true,
96
+ body: new Uint8Array(fs.readFileSync(hit.real)),
97
+ headers: imageHeaders(IMAGE_TYPES[ext]),
98
+ };
99
+ }
100
+ if (kind === "html") {
101
+ if (hit.size > MAX_HTML_BYTES) return fail(413, "Too large");
102
+ return {
103
+ ok: true,
104
+ body: prepareArtifact(fs.readFileSync(hit.real, "utf8")),
105
+ headers: ARTIFACT_HEADERS,
106
+ };
107
+ }
108
+ const max = asLog ? LOG_TAIL_BYTES : MAX_TEXT_BYTES;
109
+ const buf = readSlice(hit.real, hit.size, max, asLog);
110
+ if (binary(buf)) return fail(415, "Not a text file");
111
+ let text = buf.toString("utf8");
112
+ // A tail starts mid-line; drop the partial one.
113
+ if (asLog && hit.size > max) text = text.slice(text.indexOf("\n") + 1);
114
+ return {
115
+ ok: true,
116
+ body: text,
117
+ headers: textHeaders(
118
+ kind === "markdown" ? "text/markdown" : "text/plain",
119
+ hit.size > max
120
+ ),
121
+ };
122
+ }
123
+
124
+ interface Commit {
125
+ oid?: string;
126
+ messageHeadline?: string;
127
+ }
128
+
129
+ async function prMarkdown(session: Session): Promise<string> {
130
+ const lines = [
131
+ `# ${session.pr_number ? `PR #${session.pr_number}` : "Pull request"}`,
132
+ "",
133
+ `Status: **${session.pr_status ?? "open"}**`,
134
+ ];
135
+ if (session.pr_url) lines.push("", session.pr_url);
136
+ const repo = session.pr_url ? repoOfPR(session.pr_url) : null;
137
+ if (!repo || !session.pr_number || demoMode()) return lines.join("\n");
138
+ try {
139
+ const out = await run(
140
+ "gh",
141
+ [
142
+ "pr",
143
+ "view",
144
+ String(session.pr_number),
145
+ "--repo",
146
+ `${repo.owner}/${repo.name}`,
147
+ "--json",
148
+ "commits",
149
+ ],
150
+ os.tmpdir(),
151
+ 15_000
152
+ );
153
+ const commits = (JSON.parse(out).commits ?? []) as Commit[];
154
+ lines.push("", `## Commits (${commits.length})`, "");
155
+ for (const c of commits)
156
+ lines.push(
157
+ `- \`${String(c.oid ?? "").slice(0, 7)}\` ${String(c.messageHeadline ?? "").replace(/\n/g, " ")}`
158
+ );
159
+ } catch {
160
+ lines.push("", "_Couldn't load its commits from GitHub._");
161
+ }
162
+ return lines.join("\n");
163
+ }
164
+
165
+ function runMarkdown(runId: number, sessionId: string): string | null {
166
+ const row = db
167
+ .prepare(
168
+ `SELECT r.status, r.summary, r.outputs, r.error, r.duration_ms, r.pr_url,
169
+ x.name AS recipe_name
170
+ FROM recipe_runs r LEFT JOIN recipes x ON x.id = r.recipe_id
171
+ WHERE r.id = ? AND r.session_id = ?`
172
+ )
173
+ .get(runId, sessionId) as
174
+ | {
175
+ status: string;
176
+ summary: string | null;
177
+ outputs: string | null;
178
+ error: string | null;
179
+ duration_ms: number | null;
180
+ pr_url: string | null;
181
+ recipe_name: string | null;
182
+ }
183
+ | undefined;
184
+ if (!row) return null;
185
+ const lines = [
186
+ `# ${row.recipe_name ? `Recipe run: ${row.recipe_name}` : "Recipe run"}`,
187
+ "",
188
+ `Status: **${row.status}**${row.duration_ms ? ` · ${Math.round(row.duration_ms / 1000)}s` : ""}`,
189
+ ];
190
+ if (row.pr_url) lines.push("", row.pr_url);
191
+ if (row.summary) lines.push("", "## Summary", "", row.summary);
192
+ if (row.outputs) {
193
+ let pretty = row.outputs;
194
+ try {
195
+ pretty = JSON.stringify(JSON.parse(row.outputs), null, 2);
196
+ } catch {
197
+ // Shown as stored.
198
+ }
199
+ lines.push("", "## Outputs", "", "```json", pretty, "```");
200
+ }
201
+ if (row.error) lines.push("", "## Error", "", row.error);
202
+ return lines.join("\n");
203
+ }
204
+
205
+ export async function artifactContent(
206
+ session: Session,
207
+ entry: Entry
208
+ ): Promise<Content> {
209
+ const roots = rootsOf(session);
210
+ const { source } = entry;
211
+ const state = entry.artifact.state;
212
+ if (state === "outside") return fail(403, "Outside what AgentOS serves");
213
+ switch (source.type) {
214
+ case "page": {
215
+ const page = getArtifact(source.artifactId);
216
+ if (!page || page.session_id !== session.id)
217
+ return fail(404, "Not found");
218
+ const html = await readArtifactHtml(page);
219
+ if (html === null) return fail(404, "Not found");
220
+ return {
221
+ ok: true,
222
+ body: prepareArtifact(html),
223
+ headers: ARTIFACT_HEADERS,
224
+ };
225
+ }
226
+ case "file":
227
+ return entry.read ? serveFile(entry.read, roots) : fail(404, "Gone");
228
+ case "plan": {
229
+ if (entry.read) return serveFile(entry.read, roots);
230
+ const item = source.chatItemId
231
+ ? getItem(session.id, source.chatItemId)
232
+ : undefined;
233
+ return item?.kind === "plan" ? markdown(item.plan) : fail(404, "Gone");
234
+ }
235
+ case "log": {
236
+ const file = entry.read ?? findOutputFile(source.taskId);
237
+ return file ? serveFile(file, roots, true) : fail(404, "No output yet");
238
+ }
239
+ case "attached": {
240
+ const item = getItem(session.id, source.itemId);
241
+ const image = item?.kind === "user" ? item.images?.[source.index] : null;
242
+ if (!image || !IMAGE_MEDIA.has(image.mediaType))
243
+ return fail(404, "Not found");
244
+ const data = Buffer.from(image.data, "base64");
245
+ if (data.length > MAX_IMAGE_BYTES) return fail(413, "Too large");
246
+ return {
247
+ ok: true,
248
+ body: new Uint8Array(data),
249
+ headers: imageHeaders(image.mediaType),
250
+ };
251
+ }
252
+ case "pr":
253
+ return markdown(await prMarkdown(session));
254
+ case "run": {
255
+ const text = runMarkdown(source.runId, session.id);
256
+ return text === null ? fail(404, "Not found") : markdown(text);
257
+ }
258
+ case "session-log": {
259
+ const row = db
260
+ .prepare(`SELECT markdown FROM session_logs WHERE session_id = ?`)
261
+ .get(session.id) as { markdown: string } | undefined;
262
+ return row ? markdown(row.markdown) : fail(404, "Not found");
263
+ }
264
+ }
265
+ }
@@ -0,0 +1,43 @@
1
+ // What a session produced, as the Artifacts view lists it. Client-safe.
2
+
3
+ export type ArtifactKind =
4
+ | "plan"
5
+ | "file"
6
+ | "page"
7
+ | "image"
8
+ | "log"
9
+ | "pr"
10
+ | "run"
11
+ | "session-log";
12
+
13
+ // live: there to open. kept: its worktree went, a copy was kept. in-pr:
14
+ // committed, so the PR has it. gone: nothing left to open. outside: there,
15
+ // but outside what the app serves.
16
+ export type ArtifactState = "live" | "kept" | "in-pr" | "gone" | "outside";
17
+
18
+ export type ArtifactPreview = "markdown" | "html" | "image" | "text" | "none";
19
+
20
+ export interface SessionArtifact {
21
+ id: string;
22
+ kind: ArtifactKind;
23
+ name: string;
24
+ // Where it is, ~ for home; files, images, logs and plan files only.
25
+ path?: string;
26
+ // When it last changed in the chat (ms).
27
+ at: number;
28
+ size?: number;
29
+ edits?: number;
30
+ state: ArtifactState;
31
+ preview: ArtifactPreview;
32
+ // You attached it, or a shell command (not an edit tool) made it.
33
+ by?: "you" | "shell";
34
+ // A short line under the name: a PR's status, a job's state.
35
+ detail?: string;
36
+ // Its folder inside the worktree, "" at the top.
37
+ folder?: string;
38
+ // A link out (the PR on GitHub).
39
+ url?: string;
40
+ }
41
+
42
+ export const openable = (a: Pick<SessionArtifact, "state" | "preview">) =>
43
+ (a.state === "live" || a.state === "kept") && a.preview !== "none";
@@ -36,6 +36,7 @@ import { dropSessionDatabase } from "../project-config/database";
36
36
  import { captureMergedTask } from "../session-logs";
37
37
  import { sessionEnded } from "../experiments/hooks";
38
38
  import { snapshotWorktree } from "../resurrect/snapshot";
39
+ import { keepSessionFiles } from "../session-artifacts/collect";
39
40
 
40
41
  // Never deleted, whatever a setting says.
41
42
  export const PROTECTED_BRANCHES = new Set(["main", "master", "HEAD"]);
@@ -60,6 +61,7 @@ async function cleanup(
60
61
  if (fate?.action === "kept")
61
62
  console.log(`[tasks] kept ${fate.path} after merge: ${fate.why}`);
62
63
  } else if (session.worktree_path) {
64
+ await keepSessionFiles(session.id);
63
65
  await deleteWorktree(session.worktree_path, repo, true).catch(() => {});
64
66
  }
65
67
  // A stacked child's PR targets this branch: deleting it on origin would
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.595",
3
+ "version": "0.3.596",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"