@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,147 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { toast } from "sonner";
|
|
4
|
+
import { Copy, Link2 } from "lucide-react";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { writeClipboard } from "@/hooks/useCopyToClipboard";
|
|
7
|
+
import {
|
|
8
|
+
useRevokeShare,
|
|
9
|
+
useSharesQuery,
|
|
10
|
+
type SharedLinkRow,
|
|
11
|
+
} from "@/data/shares";
|
|
12
|
+
import { cn } from "@/lib/utils";
|
|
13
|
+
|
|
14
|
+
const when = (iso: string) =>
|
|
15
|
+
new Date(iso).toLocaleDateString(undefined, {
|
|
16
|
+
month: "short",
|
|
17
|
+
day: "numeric",
|
|
18
|
+
year: "numeric",
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
function Row({ share }: { share: SharedLinkRow }) {
|
|
22
|
+
const revoke = useRevokeShare();
|
|
23
|
+
const live = share.status === "active";
|
|
24
|
+
|
|
25
|
+
const copy = async () => {
|
|
26
|
+
try {
|
|
27
|
+
await writeClipboard(share.url);
|
|
28
|
+
toast.success("Link copied");
|
|
29
|
+
} catch {
|
|
30
|
+
toast.error("Could not copy to clipboard");
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
const onRevoke = () => {
|
|
34
|
+
if (
|
|
35
|
+
!window.confirm(
|
|
36
|
+
"Revoke this link? The page is deleted from Connect and the link stops working."
|
|
37
|
+
)
|
|
38
|
+
)
|
|
39
|
+
return;
|
|
40
|
+
revoke.mutate(share.id, {
|
|
41
|
+
onError: (err) => toast.error((err as Error).message),
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const title =
|
|
46
|
+
share.kind === "chat" && share.sessionId ? (
|
|
47
|
+
<a
|
|
48
|
+
href={`/?session=${encodeURIComponent(share.sessionId)}`}
|
|
49
|
+
className="hover:underline"
|
|
50
|
+
>
|
|
51
|
+
{share.title}
|
|
52
|
+
</a>
|
|
53
|
+
) : (
|
|
54
|
+
share.title
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<li className="bg-muted/40 flex flex-col gap-2 rounded-lg px-3 py-2.5 sm:flex-row sm:items-center">
|
|
59
|
+
<div className="min-w-0 flex-1">
|
|
60
|
+
<div className="truncate text-sm font-medium">{title}</div>
|
|
61
|
+
<div className="text-muted-foreground flex flex-wrap gap-x-3 text-xs">
|
|
62
|
+
<span>{share.kind === "chat" ? "Chat" : "Page"}</span>
|
|
63
|
+
<span>Created {when(share.createdAt)}</span>
|
|
64
|
+
<span>
|
|
65
|
+
{share.status === "revoked"
|
|
66
|
+
? "Revoked"
|
|
67
|
+
: share.status === "expired"
|
|
68
|
+
? "Expired"
|
|
69
|
+
: share.expiresAt
|
|
70
|
+
? `Expires ${when(share.expiresAt)}`
|
|
71
|
+
: "Never expires"}
|
|
72
|
+
</span>
|
|
73
|
+
<span>
|
|
74
|
+
{share.views === null
|
|
75
|
+
? "Views unavailable"
|
|
76
|
+
: `${share.views} ${share.views === 1 ? "view" : "views"}`}
|
|
77
|
+
</span>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
<div className="flex shrink-0 gap-2">
|
|
81
|
+
<Button
|
|
82
|
+
variant="outline"
|
|
83
|
+
className={cn("min-h-11 sm:min-h-8", !live && "invisible")}
|
|
84
|
+
onClick={copy}
|
|
85
|
+
disabled={!live}
|
|
86
|
+
>
|
|
87
|
+
<Copy className="h-4 w-4" />
|
|
88
|
+
Copy link
|
|
89
|
+
</Button>
|
|
90
|
+
<Button
|
|
91
|
+
variant="outline"
|
|
92
|
+
className={cn(
|
|
93
|
+
"text-destructive min-h-11 sm:min-h-8",
|
|
94
|
+
!live && "invisible"
|
|
95
|
+
)}
|
|
96
|
+
onClick={onRevoke}
|
|
97
|
+
disabled={!live || revoke.isPending}
|
|
98
|
+
>
|
|
99
|
+
Revoke
|
|
100
|
+
</Button>
|
|
101
|
+
</div>
|
|
102
|
+
</li>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Every snapshot link made from this machine, with views from Connect.
|
|
107
|
+
export function SharedLinksPanel() {
|
|
108
|
+
const { data, isLoading, error } = useSharesQuery();
|
|
109
|
+
return (
|
|
110
|
+
<div className="space-y-3">
|
|
111
|
+
<p className="text-muted-foreground text-sm">
|
|
112
|
+
Read-only snapshots you shared from a chat or a page. Revoking one
|
|
113
|
+
deletes it from Connect straight away.
|
|
114
|
+
</p>
|
|
115
|
+
{data?.connect === "unavailable" && (
|
|
116
|
+
<p className="text-muted-foreground bg-muted/40 rounded-lg px-3 py-2 text-xs">
|
|
117
|
+
Can't reach Connect, so views are hidden and revoking is
|
|
118
|
+
unavailable until it is back.
|
|
119
|
+
</p>
|
|
120
|
+
)}
|
|
121
|
+
{data?.connect === "unlinked" && data.shares.length > 0 && (
|
|
122
|
+
<p className="text-muted-foreground bg-muted/40 rounded-lg px-3 py-2 text-xs">
|
|
123
|
+
This machine isn't linked to Connect, so views are hidden and
|
|
124
|
+
revoking is unavailable.
|
|
125
|
+
</p>
|
|
126
|
+
)}
|
|
127
|
+
{error && (
|
|
128
|
+
<p className="text-destructive text-sm">{(error as Error).message}</p>
|
|
129
|
+
)}
|
|
130
|
+
{isLoading ? (
|
|
131
|
+
<p className="text-muted-foreground text-sm">Loading…</p>
|
|
132
|
+
) : data?.shares.length ? (
|
|
133
|
+
<ul className="space-y-2">
|
|
134
|
+
{data.shares.map((s) => (
|
|
135
|
+
<Row key={s.id} share={s} />
|
|
136
|
+
))}
|
|
137
|
+
</ul>
|
|
138
|
+
) : (
|
|
139
|
+
<div className="text-muted-foreground flex items-center gap-2 rounded-lg border border-dashed px-3 py-6 text-sm">
|
|
140
|
+
<Link2 className="h-4 w-4 shrink-0" />
|
|
141
|
+
Nothing shared yet. Use Share… in a chat's menu, or the share
|
|
142
|
+
button on a page.
|
|
143
|
+
</div>
|
|
144
|
+
)}
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import { shareKeys } from "./keys";
|
|
3
|
+
|
|
4
|
+
async function json<T>(res: Response): Promise<T> {
|
|
5
|
+
const data = await res.json().catch(() => ({}));
|
|
6
|
+
if (!res.ok) throw new Error(data.error || "Request failed");
|
|
7
|
+
return data;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type ShareExpiry = "1d" | "7d" | "30d" | "never";
|
|
11
|
+
|
|
12
|
+
export interface ShareTarget {
|
|
13
|
+
kind: "chat" | "artifact";
|
|
14
|
+
sessionId?: string;
|
|
15
|
+
artifactId?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PreviewRequest extends ShareTarget {
|
|
19
|
+
includeTools: boolean;
|
|
20
|
+
/** Messages left out of the share. */
|
|
21
|
+
dropIds?: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface PreviewItem {
|
|
25
|
+
id: string;
|
|
26
|
+
type: string;
|
|
27
|
+
tools: boolean;
|
|
28
|
+
excerpt: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SharePreview {
|
|
32
|
+
html: string;
|
|
33
|
+
title: string;
|
|
34
|
+
bytes: number;
|
|
35
|
+
hash: string;
|
|
36
|
+
sharedAt: string;
|
|
37
|
+
items: PreviewItem[];
|
|
38
|
+
hits: Record<"secret" | "path" | "ip" | "host" | "email", number>;
|
|
39
|
+
tooBig: boolean;
|
|
40
|
+
connectLinked: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const post = (url: string, body: unknown) =>
|
|
44
|
+
fetch(url, {
|
|
45
|
+
method: "POST",
|
|
46
|
+
headers: { "Content-Type": "application/json" },
|
|
47
|
+
body: JSON.stringify(body),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/** What would be uploaded, redacted. Local only. */
|
|
51
|
+
export function useSharePreview() {
|
|
52
|
+
return useMutation({
|
|
53
|
+
mutationFn: async (req: PreviewRequest) =>
|
|
54
|
+
json<SharePreview>(await post("/api/shares/preview", req)),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function useCreateShare() {
|
|
59
|
+
const queryClient = useQueryClient();
|
|
60
|
+
return useMutation({
|
|
61
|
+
mutationFn: async (
|
|
62
|
+
req: PreviewRequest & {
|
|
63
|
+
sharedAt: string;
|
|
64
|
+
hash: string;
|
|
65
|
+
expiresIn: ShareExpiry;
|
|
66
|
+
}
|
|
67
|
+
) =>
|
|
68
|
+
json<{ id: string; url: string; expiresAt: string | null }>(
|
|
69
|
+
await post("/api/shares", req)
|
|
70
|
+
),
|
|
71
|
+
onSuccess: () => queryClient.invalidateQueries({ queryKey: shareKeys.all }),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface SharedLinkRow {
|
|
76
|
+
id: string;
|
|
77
|
+
url: string;
|
|
78
|
+
kind: "chat" | "artifact";
|
|
79
|
+
title: string;
|
|
80
|
+
sessionId: string | null;
|
|
81
|
+
artifactId: string | null;
|
|
82
|
+
createdAt: string;
|
|
83
|
+
expiresAt: string | null;
|
|
84
|
+
status: "active" | "expired" | "revoked";
|
|
85
|
+
views: number | null;
|
|
86
|
+
lastViewedAt: string | null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface SharesResponse {
|
|
90
|
+
connect: "ok" | "unavailable" | "unlinked";
|
|
91
|
+
shares: SharedLinkRow[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function useSharesQuery(enabled = true) {
|
|
95
|
+
return useQuery({
|
|
96
|
+
queryKey: shareKeys.list(),
|
|
97
|
+
queryFn: async () => json<SharesResponse>(await fetch("/api/shares")),
|
|
98
|
+
enabled,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function useRevokeShare() {
|
|
103
|
+
const queryClient = useQueryClient();
|
|
104
|
+
return useMutation({
|
|
105
|
+
mutationFn: async (id: string) =>
|
|
106
|
+
json<{ ok: true }>(
|
|
107
|
+
await fetch(`/api/shares/${id}`, { method: "DELETE" })
|
|
108
|
+
),
|
|
109
|
+
onSuccess: () => queryClient.invalidateQueries({ queryKey: shareKeys.all }),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
@@ -19,7 +19,11 @@ import { toChatContext, usageTotals } from "../context";
|
|
|
19
19
|
import { cacheTtlMs } from "../stale";
|
|
20
20
|
import { ClaudeMapper, toCommand, type ClaudeMessage } from "./claude-mapper";
|
|
21
21
|
import { SuggestionTrace } from "./suggestion-trace";
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
redact,
|
|
24
|
+
SECRET_PARAM,
|
|
25
|
+
URL_USERINFO,
|
|
26
|
+
} from "../../orchestrator/untrusted";
|
|
23
27
|
import { NO_ATTRIBUTION } from "../../agents/attribution";
|
|
24
28
|
import { callFromAssistant } from "../../usage/calls";
|
|
25
29
|
|
|
@@ -52,9 +56,6 @@ const firstLine = (text?: string) => {
|
|
|
52
56
|
|
|
53
57
|
// A server's error text can quote its URL, and with it a key: shown and
|
|
54
58
|
// stored redacted, on one line, and short.
|
|
55
|
-
const URL_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)[^@\s/]+@/gi;
|
|
56
|
-
const SECRET_PARAM =
|
|
57
|
-
/([?&][\w.-]*(?:key|token|secret|password|auth|sig|code)[\w.-]*=)[^&\s#]+/gi;
|
|
58
59
|
function cleanError(text?: string): string | undefined {
|
|
59
60
|
if (!text) return undefined;
|
|
60
61
|
const line = redact(
|
|
@@ -264,3 +264,87 @@ export async function pushRelay(
|
|
|
264
264
|
throw new Error("the Connect service sent no push results");
|
|
265
265
|
return r.results;
|
|
266
266
|
}
|
|
267
|
+
|
|
268
|
+
export type ShareStatus = "active" | "expired" | "revoked";
|
|
269
|
+
|
|
270
|
+
/** A snapshot link as Connect reports it. The link itself is never in a list. */
|
|
271
|
+
export interface RemoteShare {
|
|
272
|
+
id: string;
|
|
273
|
+
kind: string;
|
|
274
|
+
title: string;
|
|
275
|
+
status: ShareStatus;
|
|
276
|
+
byte_size: number;
|
|
277
|
+
view_count: number;
|
|
278
|
+
last_viewed_at: string | null;
|
|
279
|
+
created_at: string;
|
|
280
|
+
expires_at: string | null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export type ShareExpiry = "1d" | "7d" | "30d" | "never";
|
|
284
|
+
|
|
285
|
+
const shareFrom = (r: unknown): RemoteShare => {
|
|
286
|
+
const s = (r ?? {}) as Record<string, unknown>;
|
|
287
|
+
if (typeof s.id !== "string" || typeof s.status !== "string")
|
|
288
|
+
throw new Error("the Connect service sent an unusable share");
|
|
289
|
+
return s as unknown as RemoteShare;
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
/** Uploads a rendered snapshot; the answer carries the public link, once. */
|
|
293
|
+
export async function uploadShare(
|
|
294
|
+
config: ConnectConfig,
|
|
295
|
+
privateKey: string,
|
|
296
|
+
share: {
|
|
297
|
+
kind: "chat" | "artifact";
|
|
298
|
+
title: string;
|
|
299
|
+
html: string;
|
|
300
|
+
expiresIn: ShareExpiry;
|
|
301
|
+
}
|
|
302
|
+
): Promise<RemoteShare & { url: string }> {
|
|
303
|
+
const res = await signed(config, privateKey)(
|
|
304
|
+
"POST",
|
|
305
|
+
`/api/v1/machines/${config.machineId}/shares`,
|
|
306
|
+
{
|
|
307
|
+
kind: share.kind,
|
|
308
|
+
title: share.title,
|
|
309
|
+
html: share.html,
|
|
310
|
+
expires_in: share.expiresIn,
|
|
311
|
+
}
|
|
312
|
+
);
|
|
313
|
+
const r = (await res.json().catch(() => null)) as Record<
|
|
314
|
+
string,
|
|
315
|
+
unknown
|
|
316
|
+
> | null;
|
|
317
|
+
const url = r?.url;
|
|
318
|
+
if (typeof url !== "string" || !/^https?:\/\//.test(url))
|
|
319
|
+
throw new Error("the Connect service sent no link");
|
|
320
|
+
return { ...shareFrom(r), url };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export async function listShares(
|
|
324
|
+
config: ConnectConfig,
|
|
325
|
+
privateKey: string
|
|
326
|
+
): Promise<RemoteShare[]> {
|
|
327
|
+
const res = await signed(config, privateKey)(
|
|
328
|
+
"GET",
|
|
329
|
+
`/api/v1/machines/${config.machineId}/shares`
|
|
330
|
+
);
|
|
331
|
+
const r = (await res.json().catch(() => null)) as {
|
|
332
|
+
shares?: unknown[];
|
|
333
|
+
} | null;
|
|
334
|
+
if (!Array.isArray(r?.shares))
|
|
335
|
+
throw new Error("the Connect service sent no share list");
|
|
336
|
+
return r.shares.map(shareFrom);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export async function revokeShare(
|
|
340
|
+
config: ConnectConfig,
|
|
341
|
+
privateKey: string,
|
|
342
|
+
id: string
|
|
343
|
+
): Promise<RemoteShare> {
|
|
344
|
+
if (!/^[a-z0-9]{12}$/.test(id)) throw new Error("not a share id");
|
|
345
|
+
const res = await signed(config, privateKey)(
|
|
346
|
+
"DELETE",
|
|
347
|
+
`/api/v1/machines/${config.machineId}/shares/${id}`
|
|
348
|
+
);
|
|
349
|
+
return shareFrom(await res.json().catch(() => null));
|
|
350
|
+
}
|
package/lib/db/migrations.ts
CHANGED
|
@@ -2114,6 +2114,29 @@ const migrations: Migration[] = [
|
|
|
2114
2114
|
`);
|
|
2115
2115
|
},
|
|
2116
2116
|
},
|
|
2117
|
+
{
|
|
2118
|
+
id: 88,
|
|
2119
|
+
name: "shared_links",
|
|
2120
|
+
up: (db) => {
|
|
2121
|
+
// Snapshot links made through Connect. Connect keeps only a digest of
|
|
2122
|
+
// the token, so this is the one place the link can be copied again.
|
|
2123
|
+
db.exec(`
|
|
2124
|
+
CREATE TABLE IF NOT EXISTS shared_links (
|
|
2125
|
+
id TEXT PRIMARY KEY,
|
|
2126
|
+
url TEXT NOT NULL,
|
|
2127
|
+
kind TEXT NOT NULL,
|
|
2128
|
+
title TEXT NOT NULL,
|
|
2129
|
+
session_id TEXT,
|
|
2130
|
+
artifact_id TEXT,
|
|
2131
|
+
expires_at TEXT,
|
|
2132
|
+
created_at TEXT NOT NULL,
|
|
2133
|
+
revoked_at TEXT
|
|
2134
|
+
);
|
|
2135
|
+
CREATE INDEX IF NOT EXISTS idx_shared_links_created
|
|
2136
|
+
ON shared_links(created_at DESC);
|
|
2137
|
+
`);
|
|
2138
|
+
},
|
|
2139
|
+
},
|
|
2117
2140
|
];
|
|
2118
2141
|
|
|
2119
2142
|
// `upTo`: stop after this id (tests that start from an older database).
|
|
@@ -20,8 +20,8 @@ const TOKEN_PATTERNS: RegExp[] = [...SECRET_TOKENS, /\b[a-fA-F0-9]{40,}\b/g];
|
|
|
20
20
|
|
|
21
21
|
// A long base64-ish run that reads as random rather than as a path or a
|
|
22
22
|
// slug: mixed case, digits, few separators.
|
|
23
|
-
const BASE64_RUN = /[A-Za-z0-9+/_-]{40,}={0,2}/g;
|
|
24
|
-
const looksRandom = (s: string) =>
|
|
23
|
+
export const BASE64_RUN = /[A-Za-z0-9+/_-]{40,}={0,2}/g;
|
|
24
|
+
export const looksRandom = (s: string) =>
|
|
25
25
|
/[A-Z]/.test(s) &&
|
|
26
26
|
/[a-z]/.test(s) &&
|
|
27
27
|
/[0-9]/.test(s) &&
|
|
@@ -31,6 +31,16 @@ const looksRandom = (s: string) =>
|
|
|
31
31
|
export const SECRET_ASSIGNMENT =
|
|
32
32
|
/^(\s*(?:export\s+)?[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASS|PWD|CREDENTIALS?|AUTH)[A-Z0-9_]*\s*[=:]\s*).+$/gm;
|
|
33
33
|
|
|
34
|
+
// A URL's userinfo (https://key@host, postgres://user:pw@host) and a query
|
|
35
|
+
// parameter that carries a credential (?token=, &api_key=).
|
|
36
|
+
export const URL_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)[^@\s/]+@/gi;
|
|
37
|
+
export const SECRET_PARAM =
|
|
38
|
+
/([?&][\w.-]*(?:key|token|secret|password|auth|sig|code)[\w.-]*=)[^&\s#]+/gi;
|
|
39
|
+
|
|
40
|
+
// A private key block's opening line.
|
|
41
|
+
export const PRIVATE_KEY_BLOCK =
|
|
42
|
+
/-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----[\s\S]*?(?:-----END (?:[A-Z]+ )?PRIVATE KEY-----|$)/g;
|
|
43
|
+
|
|
34
44
|
// The password in a URL: postgres://user:secret@host.
|
|
35
45
|
const URL_PASSWORD = /\b([a-z][a-z0-9+.-]*:\/\/[^:\s/@]+:)[^@\s]+@/gi;
|
|
36
46
|
|
package/lib/security/demo.ts
CHANGED
|
@@ -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;
|
package/lib/settings-url.ts
CHANGED
|
@@ -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
|
+
}
|