@saadnvd1/agent-os 0.3.514 → 0.3.516

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.
@@ -5,6 +5,12 @@
5
5
 
6
6
  import crypto from "crypto";
7
7
  import { getDb } from "@/lib/db";
8
+ import { enabledCategories } from "@/lib/notify/copy";
9
+ import type {
10
+ PushCategory,
11
+ PushEnvironment,
12
+ PushPlatform,
13
+ } from "@/lib/notify/push-types";
8
14
 
9
15
  export interface Device {
10
16
  id: string;
@@ -115,13 +121,162 @@ export function revokeDevice(id: string): boolean {
115
121
  const changed =
116
122
  getDb()
117
123
  .prepare(
118
- `UPDATE devices SET revoked_at = datetime('now') WHERE id = ? AND revoked_at IS NULL`
124
+ `UPDATE devices SET revoked_at = datetime('now'),
125
+ push_token = NULL, push_platform = NULL, push_environment = NULL,
126
+ push_categories = NULL, push_updated_at = NULL, push_last_error = NULL
127
+ WHERE id = ? AND revoked_at IS NULL`
119
128
  )
120
129
  .run(id).changes > 0;
121
130
  if (changed) dropDeviceSockets(id);
122
131
  return changed;
123
132
  }
124
133
 
134
+ export interface DevicePush {
135
+ token: string;
136
+ platform: PushPlatform;
137
+ environment: PushEnvironment;
138
+ /** What it receives: its saved list, or the defaults. */
139
+ categories: PushCategory[];
140
+ updatedAt: string;
141
+ lastError: string | null;
142
+ }
143
+
144
+ interface PushRow {
145
+ push_token: string | null;
146
+ push_platform: PushPlatform;
147
+ push_environment: PushEnvironment;
148
+ push_categories: string | null;
149
+ push_updated_at: string;
150
+ push_last_error: string | null;
151
+ }
152
+
153
+ // A corrupt saved list reads as "none saved", so the defaults apply.
154
+ function parseCategories(raw: string | null): unknown[] | null {
155
+ if (!raw) return null;
156
+ try {
157
+ const v = JSON.parse(raw);
158
+ return Array.isArray(v) ? v : null;
159
+ } catch {
160
+ return null;
161
+ }
162
+ }
163
+
164
+ const pushOf = (r: PushRow | undefined): DevicePush | null =>
165
+ r?.push_token
166
+ ? {
167
+ token: r.push_token,
168
+ platform: r.push_platform,
169
+ environment: r.push_environment,
170
+ categories: enabledCategories(parseCategories(r.push_categories)),
171
+ updatedAt: r.push_updated_at,
172
+ lastError: r.push_last_error,
173
+ }
174
+ : null;
175
+
176
+ const PUSH_COLS = `push_token, push_platform, push_environment, push_categories,
177
+ push_updated_at, push_last_error`;
178
+
179
+ export function getDevicePush(id: string): DevicePush | null {
180
+ return pushOf(
181
+ getDb()
182
+ .prepare(
183
+ `SELECT ${PUSH_COLS} FROM devices WHERE id = ? AND revoked_at IS NULL`
184
+ )
185
+ .get(id) as PushRow | undefined
186
+ );
187
+ }
188
+
189
+ /** Every live device that can be sent a push, for the sender. */
190
+ export function listPushDevices(): { id: string; push: DevicePush }[] {
191
+ const rows = getDb()
192
+ .prepare(
193
+ `SELECT id, ${PUSH_COLS} FROM devices
194
+ WHERE revoked_at IS NULL AND push_token IS NOT NULL`
195
+ )
196
+ .all() as (PushRow & { id: string })[];
197
+ return rows.flatMap((r) => {
198
+ const push = pushOf(r);
199
+ return push ? [{ id: r.id, push }] : [];
200
+ });
201
+ }
202
+
203
+ /**
204
+ * Registers or refreshes a device's push token. Idempotent. Saved categories
205
+ * are kept unless new ones are given. A token belongs to one device: if
206
+ * another row held it (a re-pair on the same phone), that row loses it so
207
+ * nothing is sent twice.
208
+ */
209
+ export function setDevicePush(
210
+ id: string,
211
+ p: {
212
+ token: string;
213
+ platform: PushPlatform;
214
+ environment: PushEnvironment;
215
+ categories?: PushCategory[];
216
+ }
217
+ ): boolean {
218
+ const db = getDb();
219
+ return db.transaction(() => {
220
+ db.prepare(
221
+ `UPDATE devices SET push_token = NULL, push_platform = NULL,
222
+ push_environment = NULL, push_categories = NULL,
223
+ push_updated_at = NULL, push_last_error = NULL
224
+ WHERE push_token = ? AND id != ?`
225
+ ).run(p.token, id);
226
+ return (
227
+ db
228
+ .prepare(
229
+ `UPDATE devices SET push_token = ?, push_platform = ?, push_environment = ?,
230
+ push_categories = COALESCE(?, push_categories),
231
+ push_updated_at = datetime('now'), push_last_error = NULL
232
+ WHERE id = ? AND revoked_at IS NULL`
233
+ )
234
+ .run(
235
+ p.token,
236
+ p.platform,
237
+ p.environment,
238
+ p.categories ? JSON.stringify(p.categories) : null,
239
+ id
240
+ ).changes > 0
241
+ );
242
+ })();
243
+ }
244
+
245
+ /** False when the device has no push registered. */
246
+ export function setDevicePushCategories(
247
+ id: string,
248
+ categories: PushCategory[]
249
+ ): boolean {
250
+ return (
251
+ getDb()
252
+ .prepare(
253
+ `UPDATE devices SET push_categories = ?, push_updated_at = datetime('now')
254
+ WHERE id = ? AND revoked_at IS NULL AND push_token IS NOT NULL`
255
+ )
256
+ .run(JSON.stringify(categories), id).changes > 0
257
+ );
258
+ }
259
+
260
+ /** The sender records why a send failed, so the Devices page can say. */
261
+ export function setDevicePushError(id: string, error: string | null): void {
262
+ getDb()
263
+ .prepare(`UPDATE devices SET push_last_error = ? WHERE id = ?`)
264
+ .run(error ? error.slice(0, 200) : null, id);
265
+ }
266
+
267
+ export function clearDevicePush(id: string): boolean {
268
+ return (
269
+ getDb()
270
+ .prepare(
271
+ `UPDATE devices SET push_token = NULL, push_platform = NULL,
272
+ push_environment = NULL, push_categories = NULL,
273
+ push_updated_at = NULL, push_last_error = NULL
274
+ WHERE id = ? AND push_token IS NOT NULL`
275
+ )
276
+ .run(id).changes > 0
277
+ );
278
+ }
279
+
125
280
  // Open WebSockets per device, so revoking one cuts its live terminal too.
126
281
  // On globalThis because server.ts and Next's route bundles load this module
127
282
  // separately.
@@ -5,6 +5,7 @@
5
5
  * another site's page (drive-by requests from any website the user visits).
6
6
  */
7
7
 
8
+ import { isIP } from "net";
8
9
  import os from "os";
9
10
 
10
11
  const TAILSCALE_V4 = /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./; // 100.64.0.0/10
@@ -72,6 +73,8 @@ export function bindAddresses(
72
73
  export interface AccessPolicy {
73
74
  bound: string[];
74
75
  extraHosts: string[];
76
+ /** This machine's Connect address (connect.json), when it has one. */
77
+ machineHost?: () => string | null;
75
78
  }
76
79
 
77
80
  const LOCAL_NAMES = new Set(["localhost", "127.0.0.1", "::1"]);
@@ -82,6 +85,23 @@ function hostname(hostOrUrlHost: string): string {
82
85
  return h.replace(/:\d+$/, "");
83
86
  }
84
87
 
88
+ // Every Connect machine is m-<16 hex>.runagentos.com, so all of them are
89
+ // the same site: another machine's page is never one of ours.
90
+ const MACHINE_HOST = /^m-[0-9a-f]{16}\.runagentos\.com$/;
91
+
92
+ const machineHostOf = (policy: AccessPolicy) =>
93
+ policy.machineHost?.()?.toLowerCase() || null;
94
+
95
+ /** Names that are this machine whatever it's bound to. */
96
+ function ownHost(h: string, policy: AccessPolicy): boolean {
97
+ return (
98
+ LOCAL_NAMES.has(h) ||
99
+ (h !== "0.0.0.0" && policy.bound.includes(h)) ||
100
+ policy.extraHosts.includes(h) ||
101
+ machineHostOf(policy) === h
102
+ );
103
+ }
104
+
85
105
  export function hostAllowed(
86
106
  host: string | undefined,
87
107
  policy: AccessPolicy
@@ -89,25 +109,39 @@ export function hostAllowed(
89
109
  if (!host) return false;
90
110
  if (policy.bound.includes("0.0.0.0")) return true;
91
111
  const h = hostname(host);
92
- return (
93
- LOCAL_NAMES.has(h) ||
94
- policy.bound.includes(h) ||
95
- h.endsWith(".ts.net") ||
96
- policy.extraHosts.includes(h)
97
- );
112
+ return ownHost(h, policy) || h.endsWith(".ts.net");
98
113
  }
99
114
 
115
+ /**
116
+ * A page may talk to this machine only from one of this machine's own
117
+ * origins, whatever AGENTOS_BIND says: its loopback, bound and configured
118
+ * names, its own Connect address, or the host the request itself names when
119
+ * that is one of its addresses (a page served here on any of its ports; the
120
+ * mobile app's native socket sends that one). A wildcard bind widens the
121
+ * Host check, never this one: not another machine's m- address, not a name
122
+ * rebound to this machine's address.
123
+ */
100
124
  export function originAllowed(
101
125
  origin: string | undefined,
102
- policy: AccessPolicy
126
+ policy: AccessPolicy,
127
+ requestHost?: string
103
128
  ): boolean {
104
- if (!origin) return true; // not a browser page: curl, aos, server-side
129
+ if (!origin) return true; // not a browser page: curl, aos, peers, the app
105
130
  if (origin === "null") return false;
131
+ let h: string;
106
132
  try {
107
- return hostAllowed(new URL(origin).host, policy);
133
+ h = hostname(new URL(origin).host);
108
134
  } catch {
109
135
  return false;
110
136
  }
137
+ if (!h) return false;
138
+ if (MACHINE_HOST.test(h)) return h === machineHostOf(policy);
139
+ if (ownHost(h, policy)) return true;
140
+ return (
141
+ !!requestHost &&
142
+ h === hostname(requestHost) &&
143
+ (h.endsWith(".ts.net") || isIP(h) !== 0)
144
+ );
111
145
  }
112
146
 
113
147
  const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
@@ -145,7 +179,7 @@ export function requestAllowed(
145
179
  if (!req.url?.startsWith("/api/")) return true;
146
180
  if (req.fetchSite === "cross-site") return crossSiteNavigationAllowed(req);
147
181
  if (!SAFE_METHODS.has((req.method ?? "GET").toUpperCase())) {
148
- return originAllowed(req.origin, policy);
182
+ return originAllowed(req.origin, policy, req.host);
149
183
  }
150
184
  return true;
151
185
  }
@@ -154,5 +188,7 @@ export function upgradeAllowed(
154
188
  req: { host?: string; origin?: string },
155
189
  policy: AccessPolicy
156
190
  ): boolean {
157
- return hostAllowed(req.host, policy) && originAllowed(req.origin, policy);
191
+ return (
192
+ hostAllowed(req.host, policy) && originAllowed(req.origin, policy, req.host)
193
+ );
158
194
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.514",
3
+ "version": "0.3.516",
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
@@ -318,6 +318,7 @@ app.prepare().then(async () => {
318
318
  const connect = demo
319
319
  ? { hostname: () => null }
320
320
  : startConnect({ onRequest, onUpgrade }, port);
321
+ policy.machineHost = () => connect.hostname();
321
322
  // HTTPS on the tailnet (its own port) for passkeys and other secure-origin APIs.
322
323
  if (
323
324
  !demo &&