@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.
- package/README.md +40 -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/app/api/devices/route.ts +2 -0
- package/app/api/push/test/route.test.ts +112 -0
- package/app/api/push/test/route.ts +38 -0
- package/components/Devices/DeviceRow.tsx +52 -3
- package/components/Devices/DevicesPanel.tsx +13 -0
- package/data/devices/queries.ts +30 -2
- package/lib/away/run.ts +15 -3
- package/lib/chat/registry.ts +2 -0
- package/lib/chat/runner.ts +3 -0
- package/lib/connect/control-api.ts +38 -0
- package/lib/connect/serve.ts +107 -31
- package/lib/connect/supervise.ts +110 -11
- package/lib/db/migrations.ts +59 -0
- package/lib/done/index.ts +5 -1
- package/lib/load/alert.ts +5 -1
- package/lib/notify/copy.ts +190 -0
- package/lib/notify/index.ts +46 -7
- package/lib/notify/limiter.ts +6 -1
- package/lib/notify/push/encode.ts +42 -0
- package/lib/notify/push/events.ts +72 -0
- package/lib/notify/push/index.ts +183 -0
- package/lib/notify/push/outbox.ts +238 -0
- package/lib/notify/push/policy.ts +33 -0
- package/lib/notify/push/presence.ts +45 -0
- package/lib/notify/push/relay.ts +50 -0
- package/lib/notify/push/sender.ts +271 -0
- package/lib/notify/push/testing.ts +30 -0
- package/lib/notify/push/waiting.ts +29 -0
- package/lib/notify/push-types.ts +79 -0
- package/lib/notify/redact.ts +68 -0
- package/lib/orchestrator/asks.ts +5 -1
- package/lib/orchestrator/review.ts +3 -0
- package/lib/program-status/tap.ts +11 -1
- package/lib/schedules/start.ts +11 -2
- package/lib/security/demo.ts +1 -0
- package/lib/security/device-me.ts +12 -0
- package/lib/security/devices.ts +213 -5
- package/lib/security/net.ts +47 -11
- package/lib/tasks/finish.ts +2 -0
- package/lib/tasks/session.ts +26 -2
- package/package.json +1 -1
- 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
|
+
}
|
package/lib/orchestrator/asks.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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
|
package/lib/schedules/start.ts
CHANGED
|
@@ -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
|
|
package/lib/security/demo.ts
CHANGED
|
@@ -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 });
|
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;
|
|
@@ -80,13 +86,52 @@ export function touchDevice(id: string, address?: string): boolean {
|
|
|
80
86
|
return true;
|
|
81
87
|
}
|
|
82
88
|
|
|
83
|
-
|
|
84
|
-
|
|
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')
|
|
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
|
+
}
|
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/lib/tasks/finish.ts
CHANGED
|
@@ -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;
|
package/lib/tasks/session.ts
CHANGED
|
@@ -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))
|
|
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
|
+
}
|