@saadnvd1/agent-os 0.3.775 → 0.3.777

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 (39) hide show
  1. package/app/api/live-shares/[id]/route.ts +72 -0
  2. package/app/api/live-shares/route.test.ts +45 -0
  3. package/app/api/live-shares/route.ts +40 -0
  4. package/app/page.tsx +5 -0
  5. package/components/Chat/ChatPanel.tsx +6 -0
  6. package/components/Chat/EventRow.tsx +6 -0
  7. package/components/Live/GuestPromptCard.tsx +64 -0
  8. package/components/Live/InviteDialog.tsx +225 -0
  9. package/components/Live/LiveChip.tsx +109 -0
  10. package/components/Live/LivePanel.tsx +81 -0
  11. package/components/Live/LobbyCard.tsx +64 -0
  12. package/components/Pane/MobileTabBar.tsx +8 -0
  13. package/components/Pane/PaneMenu.tsx +10 -0
  14. package/components/SessionList/SessionRowMenu.tsx +6 -0
  15. package/components/Shares/SharedLinksPanel.tsx +51 -0
  16. package/data/live/index.ts +2 -0
  17. package/data/live/keys.ts +4 -0
  18. package/data/live/queries.ts +81 -0
  19. package/lib/chat/events.ts +4 -1
  20. package/lib/chat/registry.ts +10 -0
  21. package/lib/connect/control-api.ts +44 -0
  22. package/lib/db/migrations/20261029T1400_live_shares.ts +42 -0
  23. package/lib/live/clean.ts +54 -0
  24. package/lib/live/frames.ts +132 -0
  25. package/lib/live/inbox.ts +225 -0
  26. package/lib/live/publisher.ts +84 -0
  27. package/lib/live/ring.ts +61 -0
  28. package/lib/live/runtime.ts +51 -0
  29. package/lib/live/service.ts +318 -0
  30. package/lib/live/socket.ts +293 -0
  31. package/lib/live/store.ts +149 -0
  32. package/lib/live/types.ts +39 -0
  33. package/lib/share/connect.ts +24 -0
  34. package/lib/share/redact.ts +23 -1
  35. package/lib/share/secret-patterns.ts +77 -0
  36. package/lib/sidebar/row-menu.ts +2 -0
  37. package/package.json +1 -1
  38. package/server.ts +2 -0
  39. package/stores/liveUi.ts +17 -0
@@ -0,0 +1,149 @@
1
+ import { db } from "../db";
2
+
3
+ export interface LiveShareRow {
4
+ id: string;
5
+ session_id: string;
6
+ title: string;
7
+ url: string;
8
+ ends_with_session: number;
9
+ max_guests: number;
10
+ created_at: string;
11
+ expires_at: string | null;
12
+ ended_at: string | null;
13
+ }
14
+
15
+ export type LogKind =
16
+ | "join_request"
17
+ | "admit"
18
+ | "deny"
19
+ | "kick"
20
+ | "suggestion"
21
+ | "sent"
22
+ | "dismissed";
23
+
24
+ export interface LogRow {
25
+ id: number;
26
+ share_id: string;
27
+ at: number;
28
+ kind: LogKind;
29
+ guest_id: string | null;
30
+ guest_name: string | null;
31
+ ref: string | null;
32
+ text: string | null;
33
+ sent_text: string | null;
34
+ }
35
+
36
+ export function insertLiveShare(row: LiveShareRow): void {
37
+ db.prepare(
38
+ `INSERT INTO live_shares
39
+ (id, session_id, title, url, ends_with_session, max_guests, created_at, expires_at, ended_at)
40
+ VALUES (@id, @session_id, @title, @url, @ends_with_session, @max_guests, @created_at, @expires_at, @ended_at)`
41
+ ).run(row);
42
+ }
43
+
44
+ export function getLiveShare(id: string): LiveShareRow | undefined {
45
+ return db.prepare(`SELECT * FROM live_shares WHERE id = ?`).get(id) as
46
+ LiveShareRow | undefined;
47
+ }
48
+
49
+ /** Shares this machine still holds: not ended, not past their time. */
50
+ export function activeLiveShares(now = Date.now()): LiveShareRow[] {
51
+ return (
52
+ db
53
+ .prepare(
54
+ `SELECT * FROM live_shares WHERE ended_at IS NULL ORDER BY created_at, rowid`
55
+ )
56
+ .all() as LiveShareRow[]
57
+ ).filter((s) => !s.expires_at || Date.parse(s.expires_at) > now);
58
+ }
59
+
60
+ export function activeLiveShareFor(
61
+ sessionId: string,
62
+ now = Date.now()
63
+ ): LiveShareRow | undefined {
64
+ return activeLiveShares(now).find((s) => s.session_id === sessionId);
65
+ }
66
+
67
+ export function markLiveEnded(id: string): void {
68
+ db.prepare(
69
+ `UPDATE live_shares SET ended_at = COALESCE(ended_at, ?) WHERE id = ?`
70
+ ).run(new Date().toISOString(), id);
71
+ }
72
+
73
+ export function logLive(
74
+ row: Omit<LogRow, "id" | "at"> & { at?: number }
75
+ ): void {
76
+ db.prepare(
77
+ `INSERT INTO live_share_log (share_id, at, kind, guest_id, guest_name, ref, text, sent_text)
78
+ VALUES (@share_id, @at, @kind, @guest_id, @guest_name, @ref, @text, @sent_text)`
79
+ ).run({ ...row, at: row.at ?? Date.now() });
80
+ }
81
+
82
+ export function listLog(shareId: string): LogRow[] {
83
+ return db
84
+ .prepare(`SELECT * FROM live_share_log WHERE share_id = ? ORDER BY id`)
85
+ .all(shareId) as LogRow[];
86
+ }
87
+
88
+ export interface OpenSuggestion {
89
+ id: string;
90
+ guestId: string;
91
+ guestName: string;
92
+ text: string;
93
+ at: number;
94
+ }
95
+
96
+ /** Suggestions with no sent or dismissed line yet, oldest first. */
97
+ export function openSuggestions(shareId: string): OpenSuggestion[] {
98
+ const rows = db
99
+ .prepare(
100
+ `SELECT s.ref, s.guest_id, s.guest_name, s.text, s.at FROM live_share_log s
101
+ WHERE s.share_id = ? AND s.kind = 'suggestion' AND NOT EXISTS (
102
+ SELECT 1 FROM live_share_log d
103
+ WHERE d.share_id = s.share_id AND d.ref = s.ref AND d.kind IN ('sent', 'dismissed'))
104
+ ORDER BY s.id`
105
+ )
106
+ .all(shareId) as {
107
+ ref: string;
108
+ guest_id: string;
109
+ guest_name: string;
110
+ text: string;
111
+ at: number;
112
+ }[];
113
+ return rows.map((r) => ({
114
+ id: r.ref,
115
+ guestId: r.guest_id,
116
+ guestName: r.guest_name,
117
+ text: r.text,
118
+ at: r.at,
119
+ }));
120
+ }
121
+
122
+ /** Guests this machine admitted and has not removed since. */
123
+ export function admittedGuestIds(shareId: string): Set<string> {
124
+ const out = new Set<string>();
125
+ for (const l of listLog(shareId)) {
126
+ if (!l.guest_id) continue;
127
+ if (l.kind === "admit") out.add(l.guest_id);
128
+ else if (l.kind === "kick" || l.kind === "deny") out.delete(l.guest_id);
129
+ }
130
+ return out;
131
+ }
132
+
133
+ /** Whether a suggestion with this id was ever logged for the share. */
134
+ export function hasSuggestion(shareId: string, ref: string): boolean {
135
+ return !!db
136
+ .prepare(
137
+ `SELECT 1 FROM live_share_log WHERE share_id = ? AND kind = 'suggestion' AND ref = ? LIMIT 1`
138
+ )
139
+ .get(shareId, ref);
140
+ }
141
+
142
+ /** Whether a join request from this guest was already logged. */
143
+ export function hasJoinRequest(shareId: string, guestId: string): boolean {
144
+ return !!db
145
+ .prepare(
146
+ `SELECT 1 FROM live_share_log WHERE share_id = ? AND kind = 'join_request' AND guest_id = ? LIMIT 1`
147
+ )
148
+ .get(shareId, guestId);
149
+ }
@@ -0,0 +1,39 @@
1
+ // What the host's screens show of a live share (served by /api/live-shares).
2
+ export interface LiveView {
3
+ id: string;
4
+ sessionId: string;
5
+ title: string;
6
+ url: string;
7
+ expiresAt: string | null;
8
+ endsWithSession: boolean;
9
+ maxGuests: number;
10
+ connected: boolean;
11
+ notice: string | null;
12
+ guests: {
13
+ id: string;
14
+ name: string;
15
+ status: "pending" | "admitted";
16
+ online: boolean;
17
+ /** Another guest here has the same name. */
18
+ duplicateName: boolean;
19
+ }[];
20
+ suggestions: {
21
+ id: string;
22
+ guestId: string;
23
+ guestName: string;
24
+ text: string;
25
+ at: number;
26
+ }[];
27
+ }
28
+
29
+ export type LiveExpiry = "1h" | "session";
30
+
31
+ export const EXPIRY_LABEL: Record<LiveExpiry, string> = {
32
+ "1h": "1 hour",
33
+ session: "Until the session ends",
34
+ };
35
+
36
+ export interface LiveResponse {
37
+ linked: boolean;
38
+ shares: LiveView[];
39
+ }
@@ -6,8 +6,10 @@ import {
6
6
  clean,
7
7
  listShares,
8
8
  replaceShare,
9
+ createLiveShare,
9
10
  revokeShare,
10
11
  uploadShare,
12
+ type LiveExpiry,
11
13
  type RemoteShare,
12
14
  type ShareExpiry,
13
15
  } from "@/lib/connect/control-api";
@@ -40,6 +42,16 @@ function explain(err: unknown): never {
40
42
  `Connect refused it as too big or over your quota: ${clean(err.message, 120)}`,
41
43
  413
42
44
  );
45
+ if (err.status === 402)
46
+ throw new ShareError(
47
+ clean(
48
+ typeof err.body.error === "string"
49
+ ? err.body.error
50
+ : "Your plan's live share limit is reached.",
51
+ 160
52
+ ),
53
+ 402
54
+ );
43
55
  if (err.status === 429)
44
56
  throw new ShareError(
45
57
  "Too many shares just now. Try again in a few minutes.",
@@ -95,3 +107,15 @@ export async function revokeRemote(id: string): Promise<void> {
95
107
  explain(err);
96
108
  }
97
109
  }
110
+
111
+ export async function createLiveRemote(share: {
112
+ title: string;
113
+ expiresIn: LiveExpiry;
114
+ }) {
115
+ const { config, machineKey } = files();
116
+ try {
117
+ return await createLiveShare(config, machineKey, share);
118
+ } catch (err) {
119
+ explain(err);
120
+ }
121
+ }
@@ -18,6 +18,15 @@ import {
18
18
  BASE64_RUN,
19
19
  looksRandom,
20
20
  } from "@/lib/orchestrator/untrusted";
21
+ import {
22
+ BASIC_AUTH,
23
+ CLI_FLAG,
24
+ DATA_URI,
25
+ KEY_VALUE,
26
+ MYSQL_P,
27
+ TOKEN_RUN,
28
+ isHighEntropyToken,
29
+ } from "./secret-patterns";
21
30
  import { HOME } from "@/lib/session-audit/normalize";
22
31
 
23
32
  export type Kind = "secret" | "path" | "ip" | "host" | "email";
@@ -59,7 +68,8 @@ const SECRET = "[secret]";
59
68
  const BEARER = /(\bbearer\s+)(?!\[secret\])[A-Za-z0-9._~+/=-]{8,}/gi;
60
69
  const INLINE_ASSIGNMENT =
61
70
  /(\b[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASS|PWD|CREDENTIALS?|AUTH)[A-Z0-9_]*=)(?!\[secret\])[^\s'"]+/g;
62
- const LONG_HEX = /\b[a-fA-F0-9]{40,}\b/g;
71
+ // Up to 64 hex characters is a git sha or a hash, not a key.
72
+ const LONG_HEX = /\b[a-fA-F0-9]{65,}\b/g;
63
73
 
64
74
  const IPV4 =
65
75
  /(?<![\w.])(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)(?![\w.]*\w)/g;
@@ -104,6 +114,10 @@ export function redactForShare(
104
114
  let t = input;
105
115
  const on = (k: Kind) => !opts.only || opts.only.includes(k);
106
116
 
117
+ // Inline base64 (images) is content, not a credential: set aside, put back.
118
+ const uris: string[] = [];
119
+ t = t.replace(DATA_URI, (m) => `\u0000${uris.push(m) - 1}\u0000`);
120
+
107
121
  // 1. Credentials by shape.
108
122
  t = sub(t, PRIVATE_KEY_BLOCK, SECRET, hits, "secret");
109
123
  t = sub(
@@ -132,9 +146,16 @@ export function redactForShare(
132
146
  "secret",
133
147
  (m) => m.endsWith(SECRET)
134
148
  );
149
+ const keepValue = (_m: string, k: string, v: string) =>
150
+ `${k}${/^["']/.test(v) ? v[0] + SECRET + v[0] : SECRET}`;
151
+ t = sub(t, KEY_VALUE, keepValue, hits, "secret");
152
+ t = sub(t, CLI_FLAG, keepValue, hits, "secret");
153
+ t = sub(t, MYSQL_P, (_m, k) => `${k}${SECRET}`, hits, "secret");
154
+ t = sub(t, BASIC_AUTH, (_m, k) => `${k}${SECRET}`, hits, "secret");
135
155
  for (const re of SECRET_TOKENS) t = sub(t, re, SECRET, hits, "secret");
136
156
  t = sub(t, LONG_HEX, SECRET, hits, "secret");
137
157
  t = sub(t, BASE64_RUN, SECRET, hits, "secret", (m) => !looksRandom(m));
158
+ t = sub(t, TOKEN_RUN, SECRET, hits, "secret", (m) => !isHighEntropyToken(m));
138
159
 
139
160
  // 2. This machine's own secrets, wherever they appear.
140
161
  for (const value of ctx.secrets) {
@@ -192,5 +213,6 @@ export function redactForShare(
192
213
  // 6. Email addresses.
193
214
  if (on("email")) t = sub(t, EMAIL, "[email]", hits, "email");
194
215
 
216
+ t = t.replace(/\u0000(\d+)\u0000/g, (_m, i) => uris[Number(i)]);
195
217
  return { text: t, hits };
196
218
  }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Generic credential shapes for redactForShare: key/value secrets in any
3
+ * syntax, Basic auth headers, mysql -p, and high-entropy tokens. Pure data
4
+ * and predicates; redact.ts applies them and counts the hits.
5
+ */
6
+
7
+ const WORDS =
8
+ "password|passwd|pwd|secret|token|api[_-]?key|apikey|access[_-]?key|client[_-]?secret|auth|private[_-]?key";
9
+
10
+ // A value that is a type, a placeholder or a call, not a credential.
11
+ const NOT_A_VALUE =
12
+ "(?![\"']?\\[secret\\])" +
13
+ "(?!(?:string|number|boolean|any|unknown|null|undefined|true|false|none|void|required|optional)\\b)" +
14
+ "(?![$<{(\\[%]|\\.\\.\\.)(?![\\w.]+\\()";
15
+
16
+ /**
17
+ * `password=x`, `"api_key": "x"`, `token: x`, `export DB_PASSWORD=x`,
18
+ * `--password=x`. The key may carry any prefix (GITHUB_TOKEN) but must end
19
+ * in the word, so `author` and `max_tokens` stay. Group 1 is kept.
20
+ */
21
+ export const KEY_VALUE = new RegExp(
22
+ `((?<![\\w.-])[\\w.-]*(?:${WORDS})["']?[ \\t]*[:=][ \\t]*)` +
23
+ `${NOT_A_VALUE}("[^"\\n]*"|'[^'\\n]*'|[^\\s,;'"&}\\])]+)`,
24
+ "gi"
25
+ );
26
+
27
+ /** `--password value`, `--api-key value` (space separated). */
28
+ export const CLI_FLAG = new RegExp(
29
+ `(--[\\w-]*?(?:${WORDS})[ \\t]+)(?!-)${NOT_A_VALUE}("[^"\\n]*"|'[^'\\n]*'|\\S+)`,
30
+ "gi"
31
+ );
32
+
33
+ /**
34
+ * mysql-family `-pSECRET` (attached: `-p secret` would be a database name).
35
+ * psql's -p is a port, so it is left alone.
36
+ */
37
+ export const MYSQL_P =
38
+ /(\b(?:mysql|mysqldump|mysqladmin|mariadb)\b[^\n|;&]*?\s-p)(?=\S)(\S+)/gi;
39
+
40
+ /** `Authorization: Basic <x>` (Bearer is handled in redact.ts). */
41
+ export const BASIC_AUTH =
42
+ /(\bauthorization["']?\s*[:=]\s*["']?basic\s+)[A-Za-z0-9+/=._~-]{4,}/gi;
43
+
44
+ /** A base64 data URI, such as an inline image. Kept as is: not a credential. */
45
+ export const DATA_URI =
46
+ /data:[\w.+-]+\/[\w.+-]+(?:;[\w=.-]+)*;base64,[A-Za-z0-9+/=_-]+/g;
47
+
48
+ /** Candidate runs for the entropy test. */
49
+ export const TOKEN_RUN = /(?<![A-Za-z0-9_\-+/=])[A-Za-z0-9_\-+/=]{32,}/g;
50
+
51
+ export const ENTROPY_MIN = 4.0;
52
+
53
+ export function shannon(s: string): number {
54
+ const counts = new Map<string, number>();
55
+ for (const c of s) counts.set(c, (counts.get(c) ?? 0) + 1);
56
+ let h = 0;
57
+ for (const n of counts.values())
58
+ h -= (n / s.length) * Math.log2(n / s.length);
59
+ return h;
60
+ }
61
+
62
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
63
+
64
+ function looksLikePath(s: string): boolean {
65
+ if (/^(?:\.{0,2}\/|~\/)/.test(s)) return true;
66
+ const parts = s.split("/");
67
+ return parts.length > 2 && parts.every((p) => p.length >= 2);
68
+ }
69
+
70
+ /** True when a run is a random-looking token rather than prose, a hash or a path. */
71
+ export function isHighEntropyToken(s: string): boolean {
72
+ if (/^[0-9a-f]+$/i.test(s) && s.length <= 64) return false; // git sha, md5/sha256
73
+ if (UUID.test(s)) return false;
74
+ if (looksLikePath(s)) return false;
75
+ if (!/[a-z]/.test(s) || !/[A-Z]/.test(s) || !/[0-9]/.test(s)) return false;
76
+ return shannon(s) > ENTROPY_MIN;
77
+ }
@@ -17,6 +17,7 @@ export type RowMenuItem =
17
17
  | "copy-link"
18
18
  | "share"
19
19
  | "export"
20
+ | "invite-live"
20
21
  | "fork"
21
22
  | "fresh-start"
22
23
  | "new-conversation"
@@ -127,6 +128,7 @@ export function rowMenu(input: RowMenuInput): RowMenuPlan {
127
128
  ...group("share", [
128
129
  "copy-link",
129
130
  s.view === "chat" && "share",
131
+ s.view === "chat" && "invite-live",
130
132
  s.view === "chat" && "export",
131
133
  ]),
132
134
  ...group("restart", [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.775",
3
+ "version": "0.3.777",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
package/server.ts CHANGED
@@ -54,6 +54,7 @@ import {
54
54
  import { lanEnabled } from "./lib/security/network-settings";
55
55
  import { handlePeerRpc, isPeerRpc, rpcDeps } from "./lib/peer/rpc-server";
56
56
  import { startConnect } from "./lib/connect/serve";
57
+ import { startLive } from "./lib/live/service";
57
58
  import { serverPort } from "./lib/connect/config";
58
59
  import { startUpdateChecks, updateChecksEnabled } from "./lib/updates/check";
59
60
  import {
@@ -399,6 +400,7 @@ app.prepare().then(async () => {
399
400
  ? { hostname: () => null }
400
401
  : startConnect({ onRequest, onUpgrade }, port);
401
402
  policy.machineHost = () => connect.hostname();
403
+ if (!demo) startLive();
402
404
  // HTTPS on the tailnet (its own port) for passkeys and other secure-origin APIs.
403
405
  if (
404
406
  !demo &&
@@ -0,0 +1,17 @@
1
+ import { proxy } from "valtio";
2
+
3
+ // The invite dialog: which chat it is open on, started from a menu.
4
+ export const liveUi = proxy<{
5
+ target: { sessionId: string; title: string } | null;
6
+ }>({
7
+ target: null,
8
+ });
9
+
10
+ export const liveUiActions = {
11
+ open: (sessionId: string, title: string) => {
12
+ liveUi.target = { sessionId, title };
13
+ },
14
+ close: () => {
15
+ liveUi.target = null;
16
+ },
17
+ };