@saadnvd1/agent-os 0.3.514 → 0.3.517

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 (46) hide show
  1. package/README.md +40 -1
  2. package/app/api/devices/me/push/route.test.ts +209 -0
  3. package/app/api/devices/me/push/route.ts +115 -0
  4. package/app/api/devices/me/route.ts +12 -0
  5. package/app/api/devices/route.ts +2 -0
  6. package/app/api/push/test/route.test.ts +112 -0
  7. package/app/api/push/test/route.ts +38 -0
  8. package/components/Devices/DeviceRow.tsx +52 -3
  9. package/components/Devices/DevicesPanel.tsx +13 -0
  10. package/data/devices/queries.ts +30 -2
  11. package/lib/away/run.ts +15 -3
  12. package/lib/chat/registry.ts +2 -0
  13. package/lib/chat/runner.ts +3 -0
  14. package/lib/connect/control-api.ts +38 -0
  15. package/lib/connect/serve.ts +107 -31
  16. package/lib/connect/supervise.ts +110 -11
  17. package/lib/db/migrations.ts +59 -0
  18. package/lib/done/index.ts +5 -1
  19. package/lib/load/alert.ts +5 -1
  20. package/lib/notify/copy.ts +190 -0
  21. package/lib/notify/index.ts +46 -7
  22. package/lib/notify/limiter.ts +6 -1
  23. package/lib/notify/push/encode.ts +42 -0
  24. package/lib/notify/push/events.ts +72 -0
  25. package/lib/notify/push/index.ts +183 -0
  26. package/lib/notify/push/outbox.ts +238 -0
  27. package/lib/notify/push/policy.ts +33 -0
  28. package/lib/notify/push/presence.ts +45 -0
  29. package/lib/notify/push/relay.ts +50 -0
  30. package/lib/notify/push/sender.ts +271 -0
  31. package/lib/notify/push/testing.ts +30 -0
  32. package/lib/notify/push/waiting.ts +29 -0
  33. package/lib/notify/push-types.ts +79 -0
  34. package/lib/notify/redact.ts +68 -0
  35. package/lib/orchestrator/asks.ts +5 -1
  36. package/lib/orchestrator/review.ts +3 -0
  37. package/lib/program-status/tap.ts +11 -1
  38. package/lib/schedules/start.ts +11 -2
  39. package/lib/security/demo.ts +1 -0
  40. package/lib/security/device-me.ts +12 -0
  41. package/lib/security/devices.ts +213 -5
  42. package/lib/security/net.ts +47 -11
  43. package/lib/tasks/finish.ts +2 -0
  44. package/lib/tasks/session.ts +26 -2
  45. package/package.json +1 -1
  46. package/server.ts +3 -0
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Push notification types. Pure: no imports from the app, so the mobile app
3
+ * can sync this file as is.
4
+ *
5
+ * The payload is a versioned envelope. v1 is generic and content-free (a fixed
6
+ * title, a category word, a deep link). v2 is reserved for encrypted rich
7
+ * text; an app that doesn't know a version shows the outer alert and opens
8
+ * the fallback link, so v2 can ship without breaking older apps.
9
+ */
10
+
11
+ export const PUSH_CATEGORIES = [
12
+ "asks",
13
+ "needs-you",
14
+ "review",
15
+ "ci",
16
+ "merged",
17
+ "agents",
18
+ "digest",
19
+ "system",
20
+ ] as const;
21
+ export type PushCategory = (typeof PUSH_CATEGORIES)[number];
22
+
23
+ /** Maps onto the APNs interruption level. */
24
+ export type PushLevel = "time-sensitive" | "active" | "passive";
25
+
26
+ export const PUSH_PLATFORMS = ["ios", "android"] as const;
27
+ export type PushPlatform = (typeof PUSH_PLATFORMS)[number];
28
+
29
+ export const PUSH_ENVIRONMENTS = ["production", "sandbox"] as const;
30
+ export type PushEnvironment = (typeof PUSH_ENVIRONMENTS)[number];
31
+
32
+ export const PUSH_TITLE = "AgentOS";
33
+
34
+ /** Plain, content-free alert. */
35
+ export interface PushEnvelopeV1 {
36
+ v: 1;
37
+ /** Category, so the app can group and filter. */
38
+ c: PushCategory;
39
+ /** Fixed title. */
40
+ t: typeof PUSH_TITLE;
41
+ /** Category word from the registry, never event text. */
42
+ b: string;
43
+ /** Deep link, an app path like `/session/abc`. */
44
+ l: string;
45
+ /** Replaces an earlier alert with the same key. Opaque. */
46
+ k: string;
47
+ /** Interruption level. */
48
+ p: PushLevel;
49
+ }
50
+
51
+ /** Reserved: encrypted rich text. Not produced yet. */
52
+ export interface PushEnvelopeV2 {
53
+ v: 2;
54
+ c: PushCategory;
55
+ t: typeof PUSH_TITLE;
56
+ /** Generic fallback body shown if the app can't decrypt. */
57
+ b: string;
58
+ l: string;
59
+ k: string;
60
+ p: PushLevel;
61
+ /** Device id the key belongs to. */
62
+ d: string;
63
+ /** Base64url nonce and ciphertext. */
64
+ n: string;
65
+ x: string;
66
+ }
67
+
68
+ export type PushEnvelope = PushEnvelopeV1 | PushEnvelopeV2;
69
+
70
+ /** What a sender needs to say about an event. Ids only, never text. */
71
+ export interface PushEvent {
72
+ category: PushCategory;
73
+ /** Session, task or workspace id the link opens, when the category has one. */
74
+ id?: string | null;
75
+ /** Dedupe key, e.g. `ask:<id>` or `review:<task>:<sha>`. */
76
+ key?: string | null;
77
+ /** Events sharing this replace each other on the phone. Defaults to key. */
78
+ collapse?: string | null;
79
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Keeps free text and secret-looking strings out of push payloads. Pure, so
3
+ * the mobile app can sync it.
4
+ */
5
+
6
+ const MAX_ID = 64;
7
+ const SAFE_ID = /^[A-Za-z0-9_-]+$/;
8
+
9
+ /** Hex or base64 runs this long are treated as keys. */
10
+ const LONG_RUN = /[A-Za-z0-9+/_=-]{24,}/g;
11
+ const SECRET_PATTERNS: RegExp[] = [
12
+ /\baosd_[A-Za-z0-9_-]+/g,
13
+ /\bsk-[A-Za-z0-9_-]{8,}/g,
14
+ /\bgh[pousr]_[A-Za-z0-9]{8,}/g,
15
+ /\bAKIA[0-9A-Z]{8,}/g,
16
+ /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*/g,
17
+ ];
18
+
19
+ /**
20
+ * An id or key made by the app: no known token shape, and no single
21
+ * segment (split on - _ : .) that is a long hex or base64 run. A uuid or a
22
+ * timestamped id passes; a pasted key or a 40-character sha does not.
23
+ */
24
+ function plainIdent(text: string): boolean {
25
+ if (SECRET_PATTERNS.some((re) => ((re.lastIndex = 0), re.test(text))))
26
+ return false;
27
+ return !text.split(/[-_:.]/).some((seg) => seg.length >= 24);
28
+ }
29
+
30
+ /** True when the string looks like a token, key or JWT. */
31
+ export function looksSecret(text: string): boolean {
32
+ return [...SECRET_PATTERNS, LONG_RUN].some((re) => {
33
+ re.lastIndex = 0;
34
+ return re.test(text);
35
+ });
36
+ }
37
+
38
+ /**
39
+ * First line only, collapsed whitespace, no backticks, secrets replaced by
40
+ * `…`, clipped. For the rich text of a later envelope version.
41
+ */
42
+ export function scrubText(text: string | null | undefined, max = 120): string {
43
+ let s = (text ?? "").split(/\r?\n/).find((l) => l.trim()) ?? "";
44
+ s = s.replace(/`+/g, "");
45
+ for (const re of SECRET_PATTERNS) s = s.replace(re, "…");
46
+ s = s.replace(LONG_RUN, "…");
47
+ s = s.replace(/\s+/g, " ").trim();
48
+ return s.length > max ? s.slice(0, max - 1).trimEnd() + "…" : s;
49
+ }
50
+
51
+ /** An id fit for a link or key: short and plain. Null if it isn't. */
52
+ export function safeId(id: unknown): string | null {
53
+ if (typeof id !== "string" || id.length === 0 || id.length > MAX_ID)
54
+ return null;
55
+ if (!SAFE_ID.test(id)) return null;
56
+ return plainIdent(id) ? id : null;
57
+ }
58
+
59
+ /**
60
+ * A collapse key: opaque and bounded. Anything with characters outside the
61
+ * plain set, or secret-looking, is dropped rather than cleaned.
62
+ */
63
+ export function safeKey(key: unknown): string | null {
64
+ if (typeof key !== "string" || key.length === 0 || key.length > 128)
65
+ return null;
66
+ if (!/^[A-Za-z0-9_:.-]+$/.test(key)) return null;
67
+ return plainIdent(key) ? key : null;
68
+ }
@@ -6,6 +6,7 @@
6
6
  * gate or brake ask is spent on that one item: never standing permission.
7
7
  */
8
8
 
9
+ import { pushAskRaised } from "../notify/push/events";
9
10
  import { db } from "../db";
10
11
  import { answerKey, queueEvent } from "./events";
11
12
  import { addNote } from "./notes";
@@ -167,7 +168,7 @@ export function raiseAsk(input: {
167
168
  const sha = input.sha ?? null;
168
169
  const brakeKey = input.brakeKey ?? null;
169
170
  const raisedBy = input.raisedBy ?? null;
170
- return db.transaction(() => {
171
+ const result = db.transaction(() => {
171
172
  const open = openBySubject(input.workspaceId, input.subject);
172
173
  if (open) {
173
174
  db.prepare(
@@ -209,6 +210,9 @@ export function raiseAsk(input: {
209
210
  created: true,
210
211
  };
211
212
  })();
213
+ // A new ask buzzes the phone; a refresh of an open one doesn't.
214
+ if (result.created) pushAskRaised(result.ask.id, input.workspaceId);
215
+ return result;
212
216
  }
213
217
 
214
218
  export function openAsks(workspaceId: string): AskRow[] {
@@ -10,6 +10,7 @@
10
10
  * the orchestrator hears the verdict as an event.
11
11
  */
12
12
 
13
+ import { pushReviewBlocked, pushReviewReady } from "../notify/push/events";
13
14
  import { db, type Session } from "../db";
14
15
  import { amendmentsOf } from "./amendments";
15
16
  import type { TaskPR } from "../tasks/state";
@@ -275,6 +276,8 @@ export async function reviewAt(
275
276
  .then((done) => {
276
277
  if (done.status === "pass" && target.task)
277
278
  noteQuickReview(target.task, sha);
279
+ if (done.status === "pass") pushReviewReady(target.id, sha);
280
+ else if (done.status === "block") pushReviewBlocked(target.id, sha);
278
281
  const said =
279
282
  done.status === "pass"
280
283
  ? "passed"
@@ -24,7 +24,9 @@ import {
24
24
  applyProgramReport,
25
25
  dropProgramTransient,
26
26
  programSessions,
27
+ programSummary,
27
28
  } from "./store";
29
+ import { noteWaiting } from "../notify/push/waiting";
28
30
 
29
31
  const execFileAsync = promisify(execFile);
30
32
  const tmux = (args: string[]) =>
@@ -85,7 +87,15 @@ async function handle(name: string, event: OscEvent): Promise<void> {
85
87
  ? await foreground(name)
86
88
  : undefined
87
89
  );
88
- if (changed) notifyStatusChanged();
90
+ if (changed) {
91
+ notifyStatusChanged();
92
+ const id = (
93
+ getDb()
94
+ .prepare(`SELECT id FROM sessions WHERE tmux_name = ?`)
95
+ .get(name) as { id: string } | undefined
96
+ )?.id;
97
+ if (id) noteWaiting(id, programSummary(name)?.state === "blocked");
98
+ }
89
99
  }
90
100
 
91
101
  // What one pane has said but isn't applied yet: the latest report per
@@ -237,7 +237,11 @@ function notify(schedule: Schedule, why: string, paused?: boolean): void {
237
237
  const text = `Schedule "${schedule.name}" is paused: ${why}. Point it at another session or delete it.`;
238
238
  console.error(`[schedules] ${text}`);
239
239
  addNote(schedule.workspace_id, text, "pause");
240
- notifyPhone(`schedule:${schedule.id}`, text);
240
+ notifyPhone(`schedule:${schedule.id}`, text, {
241
+ category: "system",
242
+ key: `system:schedule:${schedule.id}:${Date.now()}`,
243
+ collapse: `system:schedule:${schedule.id}`,
244
+ });
241
245
  return;
242
246
  }
243
247
  console.error(`[schedules] ${schedule.name} failed: ${why}`);
@@ -248,7 +252,12 @@ function notify(schedule: Schedule, why: string, paused?: boolean): void {
248
252
  );
249
253
  notifyPhone(
250
254
  `schedule:${schedule.id}`,
251
- `Schedule "${schedule.name}" failed: ${why}`
255
+ `Schedule "${schedule.name}" failed: ${why}`,
256
+ {
257
+ category: "system",
258
+ key: `system:schedule:${schedule.id}:${Date.now()}`,
259
+ collapse: `system:schedule:${schedule.id}`,
260
+ }
252
261
  );
253
262
  }
254
263
 
@@ -59,6 +59,7 @@ export const STATIC_SIBLINGS = [
59
59
  "import",
60
60
  "init",
61
61
  "init-script",
62
+ "me",
62
63
  "items",
63
64
  "network",
64
65
  "queue",
@@ -0,0 +1,12 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { DEVICE_HEADER } from "./auth";
3
+
4
+ /**
5
+ * The calling device, from the gate's header (never the body). Null when the
6
+ * request wasn't made with a device token, e.g. from this machine's browser.
7
+ */
8
+ export const callingDevice = (request: NextRequest): string | null =>
9
+ request.headers.get(DEVICE_HEADER);
10
+
11
+ export const needsDevice = () =>
12
+ NextResponse.json({ error: "Needs a paired device token." }, { status: 403 });
@@ -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;
@@ -80,13 +86,52 @@ export function touchDevice(id: string, address?: string): boolean {
80
86
  return true;
81
87
  }
82
88
 
83
- export function listDevices(): Device[] {
84
- return getDb()
89
+ /** What the Devices page shows of a device's push: never the token. */
90
+ export interface DevicePushView {
91
+ enabled: boolean;
92
+ categories: PushCategory[];
93
+ lastError: string | null;
94
+ updatedAt: string | null;
95
+ }
96
+
97
+ export type ListedDevice = Device & { push: DevicePushView };
98
+
99
+ export function listDevices(): ListedDevice[] {
100
+ const rows = getDb()
85
101
  .prepare(
86
- `SELECT id, name, user_agent, created_at, last_seen_at, last_address, revoked_at, can_approve
102
+ `SELECT id, name, user_agent, created_at, last_seen_at, last_address, revoked_at, can_approve,
103
+ ${PUSH_COLS}
87
104
  FROM devices WHERE revoked_at IS NULL ORDER BY created_at DESC`
88
105
  )
89
- .all() as Device[];
106
+ .all() as (Device & PushRow)[];
107
+ return rows.map((r) => {
108
+ const {
109
+ push_token,
110
+ push_platform,
111
+ push_environment,
112
+ push_categories,
113
+ push_updated_at,
114
+ push_last_error,
115
+ ...device
116
+ } = r;
117
+ const push = pushOf({
118
+ push_token,
119
+ push_platform,
120
+ push_environment,
121
+ push_categories,
122
+ push_updated_at,
123
+ push_last_error,
124
+ });
125
+ return {
126
+ ...device,
127
+ push: {
128
+ enabled: !!push,
129
+ categories: push?.categories ?? [],
130
+ lastError: push_last_error,
131
+ updatedAt: push?.updatedAt ?? null,
132
+ },
133
+ };
134
+ });
90
135
  }
91
136
 
92
137
  export function renameDevice(id: string, name: string): boolean {
@@ -115,13 +160,162 @@ export function revokeDevice(id: string): boolean {
115
160
  const changed =
116
161
  getDb()
117
162
  .prepare(
118
- `UPDATE devices SET revoked_at = datetime('now') WHERE id = ? AND revoked_at IS NULL`
163
+ `UPDATE devices SET revoked_at = datetime('now'),
164
+ push_token = NULL, push_platform = NULL, push_environment = NULL,
165
+ push_categories = NULL, push_updated_at = NULL, push_last_error = NULL
166
+ WHERE id = ? AND revoked_at IS NULL`
119
167
  )
120
168
  .run(id).changes > 0;
121
169
  if (changed) dropDeviceSockets(id);
122
170
  return changed;
123
171
  }
124
172
 
173
+ export interface DevicePush {
174
+ token: string;
175
+ platform: PushPlatform;
176
+ environment: PushEnvironment;
177
+ /** What it receives: its saved list, or the defaults. */
178
+ categories: PushCategory[];
179
+ updatedAt: string;
180
+ lastError: string | null;
181
+ }
182
+
183
+ interface PushRow {
184
+ push_token: string | null;
185
+ push_platform: PushPlatform;
186
+ push_environment: PushEnvironment;
187
+ push_categories: string | null;
188
+ push_updated_at: string;
189
+ push_last_error: string | null;
190
+ }
191
+
192
+ // A corrupt saved list reads as "none saved", so the defaults apply.
193
+ function parseCategories(raw: string | null): unknown[] | null {
194
+ if (!raw) return null;
195
+ try {
196
+ const v = JSON.parse(raw);
197
+ return Array.isArray(v) ? v : null;
198
+ } catch {
199
+ return null;
200
+ }
201
+ }
202
+
203
+ const pushOf = (r: PushRow | undefined): DevicePush | null =>
204
+ r?.push_token
205
+ ? {
206
+ token: r.push_token,
207
+ platform: r.push_platform,
208
+ environment: r.push_environment,
209
+ categories: enabledCategories(parseCategories(r.push_categories)),
210
+ updatedAt: r.push_updated_at,
211
+ lastError: r.push_last_error,
212
+ }
213
+ : null;
214
+
215
+ const PUSH_COLS = `push_token, push_platform, push_environment, push_categories,
216
+ push_updated_at, push_last_error`;
217
+
218
+ export function getDevicePush(id: string): DevicePush | null {
219
+ return pushOf(
220
+ getDb()
221
+ .prepare(
222
+ `SELECT ${PUSH_COLS} FROM devices WHERE id = ? AND revoked_at IS NULL`
223
+ )
224
+ .get(id) as PushRow | undefined
225
+ );
226
+ }
227
+
228
+ /** Every live device that can be sent a push, for the sender. */
229
+ export function listPushDevices(): { id: string; push: DevicePush }[] {
230
+ const rows = getDb()
231
+ .prepare(
232
+ `SELECT id, ${PUSH_COLS} FROM devices
233
+ WHERE revoked_at IS NULL AND push_token IS NOT NULL`
234
+ )
235
+ .all() as (PushRow & { id: string })[];
236
+ return rows.flatMap((r) => {
237
+ const push = pushOf(r);
238
+ return push ? [{ id: r.id, push }] : [];
239
+ });
240
+ }
241
+
242
+ /**
243
+ * Registers or refreshes a device's push token. Idempotent. Saved categories
244
+ * are kept unless new ones are given. A token belongs to one device: if
245
+ * another row held it (a re-pair on the same phone), that row loses it so
246
+ * nothing is sent twice.
247
+ */
248
+ export function setDevicePush(
249
+ id: string,
250
+ p: {
251
+ token: string;
252
+ platform: PushPlatform;
253
+ environment: PushEnvironment;
254
+ categories?: PushCategory[];
255
+ }
256
+ ): boolean {
257
+ const db = getDb();
258
+ return db.transaction(() => {
259
+ db.prepare(
260
+ `UPDATE devices SET push_token = NULL, push_platform = NULL,
261
+ push_environment = NULL, push_categories = NULL,
262
+ push_updated_at = NULL, push_last_error = NULL
263
+ WHERE push_token = ? AND id != ?`
264
+ ).run(p.token, id);
265
+ return (
266
+ db
267
+ .prepare(
268
+ `UPDATE devices SET push_token = ?, push_platform = ?, push_environment = ?,
269
+ push_categories = COALESCE(?, push_categories),
270
+ push_updated_at = datetime('now'), push_last_error = NULL
271
+ WHERE id = ? AND revoked_at IS NULL`
272
+ )
273
+ .run(
274
+ p.token,
275
+ p.platform,
276
+ p.environment,
277
+ p.categories ? JSON.stringify(p.categories) : null,
278
+ id
279
+ ).changes > 0
280
+ );
281
+ })();
282
+ }
283
+
284
+ /** False when the device has no push registered. */
285
+ export function setDevicePushCategories(
286
+ id: string,
287
+ categories: PushCategory[]
288
+ ): boolean {
289
+ return (
290
+ getDb()
291
+ .prepare(
292
+ `UPDATE devices SET push_categories = ?, push_updated_at = datetime('now')
293
+ WHERE id = ? AND revoked_at IS NULL AND push_token IS NOT NULL`
294
+ )
295
+ .run(JSON.stringify(categories), id).changes > 0
296
+ );
297
+ }
298
+
299
+ /** The sender records why a send failed, so the Devices page can say. */
300
+ export function setDevicePushError(id: string, error: string | null): void {
301
+ getDb()
302
+ .prepare(`UPDATE devices SET push_last_error = ? WHERE id = ?`)
303
+ .run(error ? error.slice(0, 200) : null, id);
304
+ }
305
+
306
+ export function clearDevicePush(id: string): boolean {
307
+ return (
308
+ getDb()
309
+ .prepare(
310
+ `UPDATE devices SET push_token = NULL, push_platform = NULL,
311
+ push_environment = NULL, push_categories = NULL,
312
+ push_updated_at = NULL, push_last_error = NULL
313
+ WHERE id = ? AND push_token IS NOT NULL`
314
+ )
315
+ .run(id).changes > 0
316
+ );
317
+ }
318
+
125
319
  // Open WebSockets per device, so revoking one cuts its live terminal too.
126
320
  // On globalThis because server.ts and Next's route bundles load this module
127
321
  // separately.
@@ -146,3 +340,17 @@ function dropDeviceSockets(id: string): void {
146
340
  for (const s of sockets.get(id) ?? []) s.destroy();
147
341
  sockets.delete(id);
148
342
  }
343
+
344
+ /**
345
+ * The relay said this token is dead: forget it, keep the chosen categories,
346
+ * say why. The app registers a fresh token on its next launch.
347
+ */
348
+ export function pruneDevicePush(id: string, reason: string): void {
349
+ getDb()
350
+ .prepare(
351
+ `UPDATE devices SET push_token = NULL, push_platform = NULL,
352
+ push_environment = NULL, push_last_error = ?
353
+ WHERE id = ?`
354
+ )
355
+ .run(reason.slice(0, 200), id);
356
+ }
@@ -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
  }
@@ -12,6 +12,7 @@ import { itemName, liveChildren, signOffRefusal } from "../stacks/guard";
12
12
  import { resolveMergedTaskAsks } from "../orchestrator/ask-settle";
13
13
  import { endSessionAgent } from "../sessions/end";
14
14
  import { run } from "./gh";
15
+ import { pushMerged } from "../notify/push/events";
15
16
  import { gitFetch, gitPush } from "../git-exec";
16
17
  import {
17
18
  mergePolicy,
@@ -155,6 +156,7 @@ export async function signOffTask(
155
156
  signingOff.delete(id);
156
157
  }
157
158
  syncTaskCardInBackground(session, "merged", pr);
159
+ pushMerged(id);
158
160
  // Reads the plan file now: cleanup below removes the worktree.
159
161
  void captureMergedTask(id, { head: opts.head ?? pr!.head ?? null });
160
162
  const head = opts.head ?? pr!.head;
@@ -16,6 +16,12 @@ import { findPRStrict } from "./gh";
16
16
  import { currentBranch, git } from "../git-exec";
17
17
  import { lookupPR } from "./pr-poll";
18
18
  import { captureMergedTask } from "../session-logs";
19
+ import { getOrchestrator } from "../orchestrator/home";
20
+ import {
21
+ pushCiFailed,
22
+ pushMerged,
23
+ pushReviewReady,
24
+ } from "../notify/push/events";
19
25
  import { notifyStatusChanged } from "../status/hub";
20
26
  import type { TaskPR } from "./state";
21
27
  import { markStatusDirty } from "../status/dirty";
@@ -193,14 +199,19 @@ export async function prFor(
193
199
  prCache.set(session.id, { at: Date.now(), pr });
194
200
  markStatusDirty(session.id);
195
201
  // A PR whose stage inputs moved is news for the sidebar's badge.
196
- if (prSignature(before) !== prSignature(pr)) notifyStatusChanged();
202
+ if (prSignature(before) !== prSignature(pr)) {
203
+ notifyStatusChanged();
204
+ if (pr) pushPrChange(session, pr);
205
+ }
197
206
  if (pr) {
198
207
  db.prepare(
199
208
  `UPDATE sessions SET pr_url = ?, pr_number = ?, pr_status = ? WHERE id = ?`
200
209
  ).run(pr.url, pr.number, pr.state.toLowerCase(), session.id);
201
210
  // Merged on GitHub rather than through AgentOS: its log is due too.
202
- if (pr.state === "MERGED")
211
+ if (pr.state === "MERGED") {
212
+ if (session.pr_status !== "merged") pushMerged(session.id);
203
213
  void captureMergedTask(session.id, { head: pr.head });
214
+ }
204
215
  }
205
216
  // Only an answer just read from GitHub: a cached one may predate a
206
217
  // retarget AgentOS made itself (a restack).
@@ -283,3 +294,16 @@ export function forgetPR(sessionId: string): void {
283
294
  prCache.delete(sessionId);
284
295
  markStatusDirty(sessionId);
285
296
  }
297
+
298
+ // CI failing is news for the phone in any workspace. Green is "ready for
299
+ // review" only where no orchestrator runs a review of its own (its verdict is
300
+ // the signal there). Each head is pushed once: the event key holds the sha.
301
+ export function pushPrChange(session: Session, pr: TaskPR): void {
302
+ if (pr.state !== "OPEN" || !pr.head) return;
303
+ if (pr.checks === "fail") pushCiFailed(session.id, pr.head);
304
+ else if (
305
+ pr.checks === "pass" &&
306
+ !(session.workspace_id && getOrchestrator(session.workspace_id))
307
+ )
308
+ pushReviewReady(session.id, pr.head);
309
+ }