@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.
Files changed (40) hide show
  1. package/README.md +1 -0
  2. package/app/api/shares/[id]/route.ts +23 -0
  3. package/app/api/shares/preview/route.ts +30 -0
  4. package/app/api/shares/route.test.ts +455 -0
  5. package/app/api/shares/route.ts +101 -0
  6. package/app/page.tsx +5 -0
  7. package/components/Chat/Artifact.tsx +17 -1
  8. package/components/Pane/MobileTabBar.tsx +19 -0
  9. package/components/Pane/PaneMenu.tsx +16 -0
  10. package/components/SessionList/SessionRowMenu.tsx +16 -0
  11. package/components/Settings/SettingsDialog.test.ts +3 -0
  12. package/components/Settings/SettingsDialog.tsx +9 -0
  13. package/components/Settings/commands.ts +9 -0
  14. package/components/Settings/entry-points.test.ts +1 -0
  15. package/components/Share/ShareDialog.tsx +336 -0
  16. package/components/Share/format.test.ts +20 -0
  17. package/components/Share/format.ts +33 -0
  18. package/components/Shares/SharedLinksPanel.tsx +147 -0
  19. package/data/shares/index.ts +2 -0
  20. package/data/shares/keys.ts +4 -0
  21. package/data/shares/queries.ts +111 -0
  22. package/lib/chat/drivers/claude.ts +5 -4
  23. package/lib/connect/control-api.ts +84 -0
  24. package/lib/db/migrations.ts +23 -0
  25. package/lib/orchestrator/untrusted.ts +12 -2
  26. package/lib/security/demo.ts +1 -0
  27. package/lib/session-audit/normalize.ts +1 -1
  28. package/lib/settings-url.ts +1 -0
  29. package/lib/share/connect.ts +86 -0
  30. package/lib/share/context.ts +50 -0
  31. package/lib/share/redact.ts +190 -0
  32. package/lib/share/render.tsx +161 -0
  33. package/lib/share/route.ts +33 -0
  34. package/lib/share/service.ts +159 -0
  35. package/lib/share/snapshot.ts +261 -0
  36. package/lib/share/static-markup.ts +16 -0
  37. package/lib/share/store.ts +39 -0
  38. package/lib/share/styles.ts +83 -0
  39. package/package.json +1 -1
  40. 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&apos;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&apos;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&apos;s menu, or the share
142
+ button on a page.
143
+ </div>
144
+ )}
145
+ </div>
146
+ );
147
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./keys";
2
+ export * from "./queries";
@@ -0,0 +1,4 @@
1
+ export const shareKeys = {
2
+ all: ["shares"] as const,
3
+ list: () => [...shareKeys.all, "list"] as const,
4
+ };
@@ -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 { redact } from "../../orchestrator/untrusted";
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
+ }
@@ -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
 
@@ -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
+ }