@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.
- package/README.md +1 -0
- package/app/api/sessions/route.ts +3 -3
- 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 +50 -0
- package/lib/db/queries.ts +4 -3
- package/lib/models/plan-step.ts +107 -15
- package/lib/orchestrator/conditions.ts +12 -4
- package/lib/orchestrator/events.ts +23 -0
- package/lib/orchestrator/reconcile.ts +201 -0
- package/lib/orchestrator/untrusted.ts +12 -2
- package/lib/plans/features.ts +69 -13
- package/lib/plans/pieces.ts +70 -15
- package/lib/plans/release.ts +46 -4
- package/lib/recipes/settle.ts +3 -0
- 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/lib/sidebar/shelves.ts +5 -6
- package/package.json +1 -1
- package/stores/shareUi.ts +17 -0
package/README.md
CHANGED
|
@@ -20,6 +20,7 @@ and check on all of it from your phone.
|
|
|
20
20
|
- **Built for your phone.** The whole app works on a phone; pair it with a QR code. [More](docs/guides/phone.md)
|
|
21
21
|
- **Agents that talk to each other.** Sessions message each other with `aos`, and you see every conversation in Messages. [More](docs/guides/agent-network.md)
|
|
22
22
|
- **Chat or terminal, same conversation.** Streaming replies, steps and diffs, plan mode, slash commands, `@file`, undo. [More](docs/guides/chat.md)
|
|
23
|
+
- **Share a chat or a page.** A read-only snapshot link with secrets, home paths and machine names masked, and a preview before anything leaves your machine. [More](docs/guides/sharing.md)
|
|
23
24
|
- **Project config.** `agentos.json` gives each session its own ports, environment, files and a private Postgres copy. [More](docs/reference/project-config.md)
|
|
24
25
|
- **Schedules and recipes.** Start work on a timer, or save a job that comes up again. [More](docs/guides/schedules.md)
|
|
25
26
|
- **Plans in pieces and stacks.** A plan splits into several PRs only for a real reason; a board can run as stacked PRs. [More](docs/guides/tasks.md#plans-in-pieces)
|
|
@@ -10,9 +10,9 @@ import { isProjectRef } from "@/lib/tasks/project-ref";
|
|
|
10
10
|
export async function GET() {
|
|
11
11
|
try {
|
|
12
12
|
const db = getDb();
|
|
13
|
-
// Merged
|
|
14
|
-
// the machine it moved to
|
|
15
|
-
// in the Archived view:
|
|
13
|
+
// Merged tasks are listed (the sidebar files them under Done). Dropped
|
|
14
|
+
// tasks live in the Tasks panel, a moved one on the machine it moved to,
|
|
15
|
+
// and archived sessions in the Archived view: none is in the sidebar.
|
|
16
16
|
const sessions = queries.getSidebarSessions(db).all() as Session[];
|
|
17
17
|
const groups = queries.getAllGroups(db).all() as Group[];
|
|
18
18
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { NOT_LINKED, linked, revokeRemote } from "@/lib/share/connect";
|
|
3
|
+
import { fail, refuseDemo, toResponse } from "@/lib/share/route";
|
|
4
|
+
import { getSharedLink, markRevoked } from "@/lib/share/store";
|
|
5
|
+
|
|
6
|
+
// DELETE /api/shares/[id] - revoke a link: Connect deletes the page at once.
|
|
7
|
+
export async function DELETE(
|
|
8
|
+
_request: Request,
|
|
9
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
10
|
+
) {
|
|
11
|
+
const refused = refuseDemo();
|
|
12
|
+
if (refused) return refused;
|
|
13
|
+
const { id } = await params;
|
|
14
|
+
if (!getSharedLink(id)) return fail("No such shared link.", 404);
|
|
15
|
+
if (!linked()) return fail(NOT_LINKED, 409);
|
|
16
|
+
try {
|
|
17
|
+
await revokeRemote(id);
|
|
18
|
+
markRevoked(id);
|
|
19
|
+
return NextResponse.json({ ok: true });
|
|
20
|
+
} catch (err) {
|
|
21
|
+
return toResponse(err);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { linked } from "@/lib/share/connect";
|
|
3
|
+
import { readJson, refuseDemo, toResponse } from "@/lib/share/route";
|
|
4
|
+
import { buildPage, parseRequest } from "@/lib/share/service";
|
|
5
|
+
|
|
6
|
+
// POST /api/shares/preview - exactly what a share would upload, redacted.
|
|
7
|
+
// Local work only: nothing goes to Connect until a share is created.
|
|
8
|
+
export async function POST(request: Request) {
|
|
9
|
+
const refused = refuseDemo();
|
|
10
|
+
if (refused) return refused;
|
|
11
|
+
const read = await readJson(request);
|
|
12
|
+
if ("refused" in read) return read.refused;
|
|
13
|
+
try {
|
|
14
|
+
const req = parseRequest(read.body);
|
|
15
|
+
const page = await buildPage(req);
|
|
16
|
+
return NextResponse.json({
|
|
17
|
+
html: page.html,
|
|
18
|
+
title: page.title,
|
|
19
|
+
bytes: page.bytes,
|
|
20
|
+
hash: page.hash,
|
|
21
|
+
sharedAt: req.sharedAt,
|
|
22
|
+
items: page.items,
|
|
23
|
+
hits: page.hits,
|
|
24
|
+
tooBig: page.tooBig,
|
|
25
|
+
connectLinked: linked(),
|
|
26
|
+
});
|
|
27
|
+
} catch (err) {
|
|
28
|
+
return toResponse(err);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import http from "http";
|
|
4
|
+
import os from "os";
|
|
5
|
+
import path from "path";
|
|
6
|
+
import type { AddressInfo } from "net";
|
|
7
|
+
import { randomUUID } from "crypto";
|
|
8
|
+
import { afterAll, beforeEach, describe, expect, it } from "vitest";
|
|
9
|
+
|
|
10
|
+
// A fake Connect that verifies each signed request against the machine's key.
|
|
11
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agentos-share-route-"));
|
|
12
|
+
const keys = crypto.generateKeyPairSync("ed25519");
|
|
13
|
+
const publicPem = keys.publicKey
|
|
14
|
+
.export({ type: "spki", format: "pem" })
|
|
15
|
+
.toString();
|
|
16
|
+
const privatePem = keys.privateKey
|
|
17
|
+
.export({ type: "pkcs8", format: "pem" })
|
|
18
|
+
.toString();
|
|
19
|
+
const MACHINE = "abcd1234";
|
|
20
|
+
const HOST = "m-0123456789abcdef.runagentos.com";
|
|
21
|
+
|
|
22
|
+
interface Seen {
|
|
23
|
+
method: string;
|
|
24
|
+
url: string;
|
|
25
|
+
body: string;
|
|
26
|
+
signed: boolean;
|
|
27
|
+
}
|
|
28
|
+
let seen: Seen[] = [];
|
|
29
|
+
let down = false;
|
|
30
|
+
let remote: Record<string, unknown>[] = [];
|
|
31
|
+
const server = http.createServer((req, res) => {
|
|
32
|
+
let body = "";
|
|
33
|
+
req.on("data", (c) => (body += c));
|
|
34
|
+
req.on("end", () => {
|
|
35
|
+
const h = (k: string) => String(req.headers[k] ?? "");
|
|
36
|
+
const message = [
|
|
37
|
+
"agentos-connect-api",
|
|
38
|
+
req.method,
|
|
39
|
+
req.url,
|
|
40
|
+
h("x-connect-timestamp"),
|
|
41
|
+
h("x-connect-nonce"),
|
|
42
|
+
crypto.createHash("sha256").update(body).digest("hex"),
|
|
43
|
+
].join("\n");
|
|
44
|
+
const signed = crypto.verify(
|
|
45
|
+
null,
|
|
46
|
+
Buffer.from(message),
|
|
47
|
+
publicPem,
|
|
48
|
+
Buffer.from(h("x-connect-signature"), "base64url")
|
|
49
|
+
);
|
|
50
|
+
seen.push({ method: req.method!, url: req.url!, body, signed });
|
|
51
|
+
const send = (code: number, json: unknown) =>
|
|
52
|
+
res
|
|
53
|
+
.writeHead(code, { "Content-Type": "application/json" })
|
|
54
|
+
.end(JSON.stringify(json));
|
|
55
|
+
if (!signed) return send(401, { error: "bad signature" });
|
|
56
|
+
if (down) return send(503, { error: "down" });
|
|
57
|
+
const base = `/api/v1/machines/${MACHINE}/shares`;
|
|
58
|
+
if (req.method === "POST" && req.url === base) {
|
|
59
|
+
const b = JSON.parse(body);
|
|
60
|
+
const s = {
|
|
61
|
+
id: "abcdefghijkl",
|
|
62
|
+
kind: b.kind,
|
|
63
|
+
title: b.title,
|
|
64
|
+
status: "active",
|
|
65
|
+
byte_size: body.length,
|
|
66
|
+
view_count: 0,
|
|
67
|
+
last_viewed_at: null,
|
|
68
|
+
created_at: new Date().toISOString(),
|
|
69
|
+
expires_at:
|
|
70
|
+
b.expires_in === "never"
|
|
71
|
+
? null
|
|
72
|
+
: new Date(Date.now() + 7 * 864e5).toISOString(),
|
|
73
|
+
};
|
|
74
|
+
remote = [{ ...s, view_count: 3 }];
|
|
75
|
+
return send(201, {
|
|
76
|
+
...s,
|
|
77
|
+
url: `https://runagentos.com/s/${"T".repeat(32)}`,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
if (req.method === "GET" && req.url === base)
|
|
81
|
+
return send(200, { shares: remote });
|
|
82
|
+
if (req.method === "DELETE" && req.url === `${base}/abcdefghijkl`) {
|
|
83
|
+
remote = remote.map((s) => ({ ...s, status: "revoked" }));
|
|
84
|
+
return send(200, remote[0]);
|
|
85
|
+
}
|
|
86
|
+
send(404, { error: "no such share" });
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
|
|
90
|
+
process.env.AGENTOS_CONNECT_DIR = dir;
|
|
91
|
+
process.env.CONNECT_API_URL = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
|
92
|
+
process.env.AGENTOS_CONNECT_OPERATOR = "1";
|
|
93
|
+
process.env.AGENTOS_ARTIFACTS_DIR = fs.mkdtempSync(
|
|
94
|
+
path.join(os.tmpdir(), "agentos-share-art-")
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const link = () => {
|
|
98
|
+
fs.writeFileSync(
|
|
99
|
+
path.join(dir, "connect.json"),
|
|
100
|
+
JSON.stringify({
|
|
101
|
+
machineId: MACHINE,
|
|
102
|
+
hostname: HOST,
|
|
103
|
+
apiUrl: process.env.CONNECT_API_URL,
|
|
104
|
+
enabled: true,
|
|
105
|
+
}),
|
|
106
|
+
{ mode: 0o600 }
|
|
107
|
+
);
|
|
108
|
+
fs.writeFileSync(path.join(dir, "machine.key"), privatePem, { mode: 0o600 });
|
|
109
|
+
};
|
|
110
|
+
const unlink = () => {
|
|
111
|
+
for (const f of ["connect.json", "machine.key"])
|
|
112
|
+
fs.rmSync(path.join(dir, f), { force: true });
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const shares = await import("./route");
|
|
116
|
+
const preview = await import("./preview/route");
|
|
117
|
+
const one = await import("./[id]/route");
|
|
118
|
+
const { saveItem } = await import("@/lib/chat/store");
|
|
119
|
+
const { seedSession } = await import("@/lib/orchestrator/testing");
|
|
120
|
+
const { createProject } = await import("@/lib/projects");
|
|
121
|
+
const { createArtifact } = await import("@/lib/artifacts/store");
|
|
122
|
+
const { getDb } = await import("@/lib/db");
|
|
123
|
+
|
|
124
|
+
const secret = ["gh", "p_", "b".repeat(36)].join("");
|
|
125
|
+
const home = os.homedir();
|
|
126
|
+
const tailnet = ["box", "example-net", "ts", "net"].join(".");
|
|
127
|
+
|
|
128
|
+
const post = (url: string, body: unknown) =>
|
|
129
|
+
new Request(`http://localhost${url}`, {
|
|
130
|
+
method: "POST",
|
|
131
|
+
headers: { "content-type": "application/json" },
|
|
132
|
+
body: JSON.stringify(body),
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
function chat() {
|
|
136
|
+
const project = createProject({
|
|
137
|
+
name: `p-${randomUUID().slice(0, 6)}`,
|
|
138
|
+
workingDirectory: "/tmp/p",
|
|
139
|
+
});
|
|
140
|
+
const id = seedSession({
|
|
141
|
+
projectId: project.id,
|
|
142
|
+
name: "Share me",
|
|
143
|
+
view: "chat",
|
|
144
|
+
});
|
|
145
|
+
let t = 0;
|
|
146
|
+
const add = (item: Record<string, unknown>) =>
|
|
147
|
+
saveItem(id, { createdAt: ++t, ...item } as never);
|
|
148
|
+
add({
|
|
149
|
+
id: "u1",
|
|
150
|
+
kind: "user",
|
|
151
|
+
text: `key ${secret} in ${home}/app on ${tailnet}`,
|
|
152
|
+
});
|
|
153
|
+
add({ id: "a1", kind: "assistant", text: "ok" });
|
|
154
|
+
return id;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
beforeEach(() => {
|
|
158
|
+
seen = [];
|
|
159
|
+
down = false;
|
|
160
|
+
remote = [];
|
|
161
|
+
getDb().prepare(`DELETE FROM shared_links`).run();
|
|
162
|
+
link();
|
|
163
|
+
});
|
|
164
|
+
afterAll(() => {
|
|
165
|
+
server.close();
|
|
166
|
+
unlink();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("preview", () => {
|
|
170
|
+
it("shows the redacted page and never calls Connect", async () => {
|
|
171
|
+
const id = chat();
|
|
172
|
+
const res = await preview.POST(
|
|
173
|
+
post("/api/shares/preview", {
|
|
174
|
+
kind: "chat",
|
|
175
|
+
sessionId: id,
|
|
176
|
+
includeTools: false,
|
|
177
|
+
})
|
|
178
|
+
);
|
|
179
|
+
expect(res.status).toBe(200);
|
|
180
|
+
const p = await res.json();
|
|
181
|
+
expect(p.connectLinked).toBe(true);
|
|
182
|
+
expect(p.html).not.toContain(secret);
|
|
183
|
+
expect(p.html).not.toContain(home);
|
|
184
|
+
expect(p.html).not.toContain(tailnet);
|
|
185
|
+
expect(p.html).toContain("[secret]");
|
|
186
|
+
expect(p.hits).toMatchObject({ secret: 1, path: 1, host: 1 });
|
|
187
|
+
expect(p.items.map((i: { id: string }) => i.id)).toEqual(["u1", "a1"]);
|
|
188
|
+
expect(seen).toHaveLength(0);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("refuses the demo, a bad body and an unknown chat", async () => {
|
|
192
|
+
process.env.AGENTOS_DEMO = "1";
|
|
193
|
+
expect((await preview.POST(post("/api/shares/preview", {}))).status).toBe(
|
|
194
|
+
403
|
|
195
|
+
);
|
|
196
|
+
delete process.env.AGENTOS_DEMO;
|
|
197
|
+
expect(
|
|
198
|
+
(await preview.POST(post("/api/shares/preview", { kind: "x" }))).status
|
|
199
|
+
).toBe(400);
|
|
200
|
+
expect(
|
|
201
|
+
(
|
|
202
|
+
await preview.POST(
|
|
203
|
+
post("/api/shares/preview", { kind: "chat", sessionId: "nope" })
|
|
204
|
+
)
|
|
205
|
+
).status
|
|
206
|
+
).toBe(404);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("rejects a body over the cap", async () => {
|
|
210
|
+
const res = await preview.POST(
|
|
211
|
+
post("/api/shares/preview", {
|
|
212
|
+
kind: "chat",
|
|
213
|
+
sessionId: "x".repeat(70_000),
|
|
214
|
+
})
|
|
215
|
+
);
|
|
216
|
+
expect(res.status).toBe(413);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe("create", () => {
|
|
221
|
+
async function previewOf(id: string, extra = {}) {
|
|
222
|
+
const res = await preview.POST(
|
|
223
|
+
post("/api/shares/preview", {
|
|
224
|
+
kind: "chat",
|
|
225
|
+
sessionId: id,
|
|
226
|
+
includeTools: false,
|
|
227
|
+
...extra,
|
|
228
|
+
})
|
|
229
|
+
);
|
|
230
|
+
return res.json();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
it("uploads exactly what was previewed, redacted, signed, and keeps the link", async () => {
|
|
234
|
+
const id = chat();
|
|
235
|
+
const p = await previewOf(id);
|
|
236
|
+
const res = await shares.POST(
|
|
237
|
+
post("/api/shares", {
|
|
238
|
+
kind: "chat",
|
|
239
|
+
sessionId: id,
|
|
240
|
+
includeTools: false,
|
|
241
|
+
sharedAt: p.sharedAt,
|
|
242
|
+
hash: p.hash,
|
|
243
|
+
expiresIn: "7d",
|
|
244
|
+
})
|
|
245
|
+
);
|
|
246
|
+
expect(res.status).toBe(201);
|
|
247
|
+
const made = await res.json();
|
|
248
|
+
expect(made.url).toMatch(/^https:\/\/runagentos\.com\/s\/T{32}$/);
|
|
249
|
+
const upload = seen.find((s) => s.method === "POST")!;
|
|
250
|
+
expect(upload.signed).toBe(true);
|
|
251
|
+
const sent = JSON.parse(upload.body);
|
|
252
|
+
expect(sent).toMatchObject({
|
|
253
|
+
kind: "chat",
|
|
254
|
+
title: "Share me",
|
|
255
|
+
expires_in: "7d",
|
|
256
|
+
html: p.html,
|
|
257
|
+
});
|
|
258
|
+
for (const leak of [secret, home, tailnet])
|
|
259
|
+
expect(upload.body).not.toContain(leak);
|
|
260
|
+
const listed = await (await shares.GET()).json();
|
|
261
|
+
expect(listed.shares[0]).toMatchObject({
|
|
262
|
+
id: made.id,
|
|
263
|
+
url: made.url,
|
|
264
|
+
sessionId: id,
|
|
265
|
+
status: "active",
|
|
266
|
+
views: 3,
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("refuses when the page changed since the preview, uploading nothing", async () => {
|
|
271
|
+
const id = chat();
|
|
272
|
+
const p = await previewOf(id);
|
|
273
|
+
saveItem(id, {
|
|
274
|
+
id: "a2",
|
|
275
|
+
kind: "assistant",
|
|
276
|
+
text: "later",
|
|
277
|
+
createdAt: 99,
|
|
278
|
+
} as never);
|
|
279
|
+
const res = await shares.POST(
|
|
280
|
+
post("/api/shares", {
|
|
281
|
+
kind: "chat",
|
|
282
|
+
sessionId: id,
|
|
283
|
+
includeTools: false,
|
|
284
|
+
sharedAt: p.sharedAt,
|
|
285
|
+
hash: p.hash,
|
|
286
|
+
expiresIn: "7d",
|
|
287
|
+
})
|
|
288
|
+
);
|
|
289
|
+
expect(res.status).toBe(409);
|
|
290
|
+
expect(seen).toHaveLength(0);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("respects the messages dropped in the preview", async () => {
|
|
294
|
+
const id = chat();
|
|
295
|
+
const p = await previewOf(id, { dropIds: ["u1"] });
|
|
296
|
+
expect(p.html).not.toContain("[secret]");
|
|
297
|
+
const res = await shares.POST(
|
|
298
|
+
post("/api/shares", {
|
|
299
|
+
kind: "chat",
|
|
300
|
+
sessionId: id,
|
|
301
|
+
includeTools: false,
|
|
302
|
+
dropIds: ["u1"],
|
|
303
|
+
sharedAt: p.sharedAt,
|
|
304
|
+
hash: p.hash,
|
|
305
|
+
expiresIn: "never",
|
|
306
|
+
})
|
|
307
|
+
);
|
|
308
|
+
expect(res.status).toBe(201);
|
|
309
|
+
expect(JSON.parse(seen[0].body).expires_in).toBe("never");
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("shares an artifact", async () => {
|
|
313
|
+
const id = chat();
|
|
314
|
+
const art = createArtifact(
|
|
315
|
+
id,
|
|
316
|
+
"Chart",
|
|
317
|
+
`<html><head></head><body>${secret}</body></html>`
|
|
318
|
+
);
|
|
319
|
+
const body = { kind: "artifact", artifactId: art.id };
|
|
320
|
+
const p = await (
|
|
321
|
+
await preview.POST(post("/api/shares/preview", body))
|
|
322
|
+
).json();
|
|
323
|
+
const res = await shares.POST(
|
|
324
|
+
post("/api/shares", {
|
|
325
|
+
...body,
|
|
326
|
+
sharedAt: p.sharedAt,
|
|
327
|
+
hash: p.hash,
|
|
328
|
+
expiresIn: "1d",
|
|
329
|
+
})
|
|
330
|
+
);
|
|
331
|
+
expect(res.status).toBe(201);
|
|
332
|
+
const html = JSON.parse(seen[0].body).html as string;
|
|
333
|
+
expect(html).not.toContain(secret);
|
|
334
|
+
expect(html).toContain("noindex");
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("says so when Connect isn't linked, and for a bad expiry or hash", async () => {
|
|
338
|
+
const id = chat();
|
|
339
|
+
const p = await previewOf(id);
|
|
340
|
+
const body = {
|
|
341
|
+
kind: "chat",
|
|
342
|
+
sessionId: id,
|
|
343
|
+
includeTools: false,
|
|
344
|
+
sharedAt: p.sharedAt,
|
|
345
|
+
hash: p.hash,
|
|
346
|
+
};
|
|
347
|
+
expect(
|
|
348
|
+
(
|
|
349
|
+
await shares.POST(
|
|
350
|
+
post("/api/shares", { ...body, expiresIn: "forever" })
|
|
351
|
+
)
|
|
352
|
+
).status
|
|
353
|
+
).toBe(400);
|
|
354
|
+
expect(
|
|
355
|
+
(await shares.POST(post("/api/shares", { ...body, hash: undefined })))
|
|
356
|
+
.status
|
|
357
|
+
).toBe(400);
|
|
358
|
+
unlink();
|
|
359
|
+
const res = await shares.POST(
|
|
360
|
+
post("/api/shares", { ...body, expiresIn: "7d" })
|
|
361
|
+
);
|
|
362
|
+
expect(res.status).toBe(409);
|
|
363
|
+
expect((await res.json()).error).toMatch(/Connect/);
|
|
364
|
+
expect(seen).toHaveLength(0);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("maps a Connect failure to a message, and refuses the demo", async () => {
|
|
368
|
+
const id = chat();
|
|
369
|
+
const p = await previewOf(id);
|
|
370
|
+
down = true;
|
|
371
|
+
const body = {
|
|
372
|
+
kind: "chat",
|
|
373
|
+
sessionId: id,
|
|
374
|
+
includeTools: false,
|
|
375
|
+
sharedAt: p.sharedAt,
|
|
376
|
+
hash: p.hash,
|
|
377
|
+
expiresIn: "7d",
|
|
378
|
+
};
|
|
379
|
+
expect((await shares.POST(post("/api/shares", body))).status).toBe(502);
|
|
380
|
+
process.env.AGENTOS_DEMO = "1";
|
|
381
|
+
expect((await shares.POST(post("/api/shares", body))).status).toBe(403);
|
|
382
|
+
delete process.env.AGENTOS_DEMO;
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
describe("list and revoke", () => {
|
|
387
|
+
async function made() {
|
|
388
|
+
const id = chat();
|
|
389
|
+
const p = await (
|
|
390
|
+
await preview.POST(
|
|
391
|
+
post("/api/shares/preview", { kind: "chat", sessionId: id })
|
|
392
|
+
)
|
|
393
|
+
).json();
|
|
394
|
+
const res = await shares.POST(
|
|
395
|
+
post("/api/shares", {
|
|
396
|
+
kind: "chat",
|
|
397
|
+
sessionId: id,
|
|
398
|
+
sharedAt: p.sharedAt,
|
|
399
|
+
hash: p.hash,
|
|
400
|
+
expiresIn: "7d",
|
|
401
|
+
})
|
|
402
|
+
);
|
|
403
|
+
return (await res.json()).id as string;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
it("falls back to the local rows when Connect is down", async () => {
|
|
407
|
+
await made();
|
|
408
|
+
down = true;
|
|
409
|
+
const listed = await (await shares.GET()).json();
|
|
410
|
+
expect(listed.connect).toBe("unavailable");
|
|
411
|
+
unlink();
|
|
412
|
+
expect((await (await shares.GET()).json()).connect).toBe("unlinked");
|
|
413
|
+
link();
|
|
414
|
+
expect(listed.shares[0]).toMatchObject({ views: null, status: "active" });
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("revokes on Connect and marks the row", async () => {
|
|
418
|
+
const id = await made();
|
|
419
|
+
const res = await one.DELETE(new Request("http://localhost"), {
|
|
420
|
+
params: Promise.resolve({ id }),
|
|
421
|
+
});
|
|
422
|
+
expect(res.status).toBe(200);
|
|
423
|
+
const del = seen.find((s) => s.method === "DELETE")!;
|
|
424
|
+
expect(del.signed).toBe(true);
|
|
425
|
+
const listed = await (await shares.GET()).json();
|
|
426
|
+
expect(listed.shares[0].status).toBe("revoked");
|
|
427
|
+
const row = getDb()
|
|
428
|
+
.prepare(`SELECT revoked_at FROM shared_links WHERE id = ?`)
|
|
429
|
+
.get(id) as { revoked_at: string };
|
|
430
|
+
expect(row.revoked_at).toBeTruthy();
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("404s an unknown link and keeps the row when Connect fails", async () => {
|
|
434
|
+
expect(
|
|
435
|
+
(
|
|
436
|
+
await one.DELETE(new Request("http://localhost"), {
|
|
437
|
+
params: Promise.resolve({ id: "zzzzzzzzzzzz" }),
|
|
438
|
+
})
|
|
439
|
+
).status
|
|
440
|
+
).toBe(404);
|
|
441
|
+
const id = await made();
|
|
442
|
+
down = true;
|
|
443
|
+
expect(
|
|
444
|
+
(
|
|
445
|
+
await one.DELETE(new Request("http://localhost"), {
|
|
446
|
+
params: Promise.resolve({ id }),
|
|
447
|
+
})
|
|
448
|
+
).status
|
|
449
|
+
).toBe(502);
|
|
450
|
+
const row = getDb()
|
|
451
|
+
.prepare(`SELECT revoked_at FROM shared_links WHERE id = ?`)
|
|
452
|
+
.get(id) as { revoked_at: string | null };
|
|
453
|
+
expect(row.revoked_at).toBeNull();
|
|
454
|
+
});
|
|
455
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import {
|
|
3
|
+
listRemote,
|
|
4
|
+
linked,
|
|
5
|
+
createRemote,
|
|
6
|
+
NOT_LINKED,
|
|
7
|
+
} from "@/lib/share/connect";
|
|
8
|
+
import { fail, readJson, refuseDemo, toResponse } from "@/lib/share/route";
|
|
9
|
+
import {
|
|
10
|
+
EXPIRIES,
|
|
11
|
+
TOO_BIG,
|
|
12
|
+
buildPage,
|
|
13
|
+
parseRequest,
|
|
14
|
+
} from "@/lib/share/service";
|
|
15
|
+
import { insertSharedLink, listSharedLinks } from "@/lib/share/store";
|
|
16
|
+
import type { ShareExpiry } from "@/lib/connect/control-api";
|
|
17
|
+
|
|
18
|
+
// POST /api/shares - upload a snapshot to Connect and keep its link.
|
|
19
|
+
// The page is rendered again here from the same inputs as the preview and
|
|
20
|
+
// must hash the same: markup from the client is never uploaded.
|
|
21
|
+
export async function POST(request: Request) {
|
|
22
|
+
const refused = refuseDemo();
|
|
23
|
+
if (refused) return refused;
|
|
24
|
+
const read = await readJson(request);
|
|
25
|
+
if ("refused" in read) return read.refused;
|
|
26
|
+
const body = read.body as Record<string, unknown>;
|
|
27
|
+
try {
|
|
28
|
+
const req = parseRequest(body, body?.sharedAt);
|
|
29
|
+
const expiresIn = (body?.expiresIn ?? "7d") as ShareExpiry;
|
|
30
|
+
if (!EXPIRIES.includes(expiresIn)) return fail("Unknown expiry.", 400);
|
|
31
|
+
if (typeof body?.hash !== "string") return fail("hash is required.", 400);
|
|
32
|
+
if (!linked()) return fail(NOT_LINKED, 409);
|
|
33
|
+
|
|
34
|
+
const page = await buildPage(req);
|
|
35
|
+
if (page.tooBig) return fail(TOO_BIG, 413);
|
|
36
|
+
if (page.hash !== body.hash)
|
|
37
|
+
return fail("Changed since the preview; review it again.", 409);
|
|
38
|
+
|
|
39
|
+
const share = await createRemote({
|
|
40
|
+
kind: req.kind,
|
|
41
|
+
title: page.title,
|
|
42
|
+
html: page.html,
|
|
43
|
+
expiresIn,
|
|
44
|
+
});
|
|
45
|
+
insertSharedLink({
|
|
46
|
+
id: share.id,
|
|
47
|
+
url: share.url,
|
|
48
|
+
kind: req.kind,
|
|
49
|
+
title: page.title,
|
|
50
|
+
session_id: req.sessionId ?? null,
|
|
51
|
+
artifact_id: req.artifactId ?? null,
|
|
52
|
+
expires_at: share.expires_at,
|
|
53
|
+
created_at: share.created_at,
|
|
54
|
+
revoked_at: null,
|
|
55
|
+
});
|
|
56
|
+
return NextResponse.json(
|
|
57
|
+
{ id: share.id, url: share.url, expiresAt: share.expires_at },
|
|
58
|
+
{ status: 201 }
|
|
59
|
+
);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
return toResponse(err);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// GET /api/shares - the links made here, with views and status from Connect
|
|
66
|
+
// when it can be reached.
|
|
67
|
+
export async function GET() {
|
|
68
|
+
const refused = refuseDemo();
|
|
69
|
+
if (refused) return refused;
|
|
70
|
+
const local = listSharedLinks();
|
|
71
|
+
let remote: Awaited<ReturnType<typeof listRemote>> | null = null;
|
|
72
|
+
if (linked()) remote = await listRemote().catch(() => null);
|
|
73
|
+
const byId = new Map(remote?.map((s) => [s.id, s]));
|
|
74
|
+
const now = Date.now();
|
|
75
|
+
return NextResponse.json({
|
|
76
|
+
connect: !linked() ? "unlinked" : remote ? "ok" : "unavailable",
|
|
77
|
+
shares: local.map((l) => {
|
|
78
|
+
const r = byId.get(l.id);
|
|
79
|
+
const status =
|
|
80
|
+
r?.status ??
|
|
81
|
+
(l.revoked_at
|
|
82
|
+
? "revoked"
|
|
83
|
+
: l.expires_at && Date.parse(l.expires_at) <= now
|
|
84
|
+
? "expired"
|
|
85
|
+
: "active");
|
|
86
|
+
return {
|
|
87
|
+
id: l.id,
|
|
88
|
+
url: l.url,
|
|
89
|
+
kind: l.kind,
|
|
90
|
+
title: l.title,
|
|
91
|
+
sessionId: l.session_id,
|
|
92
|
+
artifactId: l.artifact_id,
|
|
93
|
+
createdAt: l.created_at,
|
|
94
|
+
expiresAt: l.expires_at,
|
|
95
|
+
status,
|
|
96
|
+
views: r ? r.view_count : null,
|
|
97
|
+
lastViewedAt: r?.last_viewed_at ?? null,
|
|
98
|
+
};
|
|
99
|
+
}),
|
|
100
|
+
});
|
|
101
|
+
}
|
package/app/page.tsx
CHANGED
|
@@ -118,6 +118,10 @@ const MoveDialog = dynamic(
|
|
|
118
118
|
() => import("@/components/Tasks/MoveDialog").then((m) => m.MoveDialog),
|
|
119
119
|
{ ssr: false }
|
|
120
120
|
);
|
|
121
|
+
const ShareDialog = dynamic(
|
|
122
|
+
() => import("@/components/Share/ShareDialog").then((m) => m.ShareDialog),
|
|
123
|
+
{ ssr: false }
|
|
124
|
+
);
|
|
121
125
|
const CommandPalette = dynamic(
|
|
122
126
|
() =>
|
|
123
127
|
import("@/components/CommandPalette/CommandPalette").then(
|
|
@@ -143,6 +147,7 @@ const AppDialogs = memo(function AppDialogs({
|
|
|
143
147
|
<LumifyHubDialogs />
|
|
144
148
|
<UsageDialog />
|
|
145
149
|
<MoveDialog />
|
|
150
|
+
<ShareDialog />
|
|
146
151
|
<AwayReport />
|
|
147
152
|
<CommandPalette />
|
|
148
153
|
</>
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useState } from "react";
|
|
4
|
-
import { ExternalLink, Maximize2, X } from "lucide-react";
|
|
4
|
+
import { ExternalLink, Maximize2, Share2, X } from "lucide-react";
|
|
5
5
|
import type { ChatItem } from "@/lib/chat/events";
|
|
6
6
|
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
|
7
|
+
import { shareUiActions } from "@/stores/shareUi";
|
|
7
8
|
import { ArtifactFrame, artifactUrl } from "./ArtifactFrame";
|
|
8
9
|
|
|
9
10
|
type ArtifactItem = Extract<ChatItem, { kind: "artifact" }>;
|
|
@@ -31,6 +32,21 @@ function Actions({
|
|
|
31
32
|
<Maximize2 className="h-4 w-4" />
|
|
32
33
|
</button>
|
|
33
34
|
)}
|
|
35
|
+
<button
|
|
36
|
+
type="button"
|
|
37
|
+
onClick={() =>
|
|
38
|
+
shareUiActions.open({
|
|
39
|
+
kind: "artifact",
|
|
40
|
+
artifactId: item.artifactId,
|
|
41
|
+
title: item.title,
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
className={ICON_BUTTON}
|
|
45
|
+
aria-label="Share"
|
|
46
|
+
title="Share"
|
|
47
|
+
>
|
|
48
|
+
<Share2 className="h-4 w-4" />
|
|
49
|
+
</button>
|
|
34
50
|
<a
|
|
35
51
|
href={artifactUrl(item.artifactId)}
|
|
36
52
|
target="_blank"
|