@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.
- package/README.md +13 -1
- package/app/api/devices/me/push/route.test.ts +209 -0
- package/app/api/devices/me/push/route.ts +115 -0
- package/app/api/devices/me/route.ts +12 -0
- package/lib/connect/serve.ts +107 -31
- package/lib/connect/supervise.ts +110 -11
- package/lib/db/migrations.ts +28 -0
- package/lib/notify/copy.ts +190 -0
- package/lib/notify/push-types.ts +77 -0
- package/lib/notify/redact.ts +57 -0
- package/lib/security/demo.ts +1 -0
- package/lib/security/device-me.ts +12 -0
- package/lib/security/devices.ts +156 -1
- package/lib/security/net.ts +47 -11
- package/package.json +1 -1
- package/server.ts +1 -0
package/lib/security/devices.ts
CHANGED
|
@@ -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')
|
|
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.
|
package/lib/security/net.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
191
|
+
return (
|
|
192
|
+
hostAllowed(req.host, policy) && originAllowed(req.origin, policy, req.host)
|
|
193
|
+
);
|
|
158
194
|
}
|
package/package.json
CHANGED
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 &&
|