@saadnvd1/agent-os 0.3.513 → 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/chat/registry.ts +8 -0
- package/lib/chat/store.ts +5 -0
- package/lib/connect/serve.ts +107 -31
- package/lib/connect/supervise.ts +110 -11
- package/lib/db/migrations.ts +28 -0
- package/lib/demo-reseed.ts +2 -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/orchestrator/armed.ts +2 -0
- package/lib/orchestrator/checks.ts +3 -0
- package/lib/orchestrator/facts.ts +5 -1
- package/lib/orchestrator/gates.ts +3 -0
- package/lib/orchestrator/merge-approvals.ts +2 -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/lib/status/collect.ts +39 -51
- package/lib/status/dirty.ts +42 -0
- package/lib/status/incremental.ts +151 -0
- package/lib/status/pr-stages.ts +34 -31
- package/lib/tasks/session.ts +3 -0
- package/package.json +1 -1
- package/server.ts +1 -0
package/lib/connect/supervise.ts
CHANGED
|
@@ -3,13 +3,24 @@
|
|
|
3
3
|
* the Connect service for a fresh connector token, which goes to the child
|
|
4
4
|
* in TUNNEL_TOKEN only: never argv, never a file, never a log line. The
|
|
5
5
|
* child exits → it's started again with backoff; stop() ends it.
|
|
6
|
+
*
|
|
7
|
+
* cloudflared must never outlive AgentOS: it would go on publishing
|
|
8
|
+
* whatever next holds the tunnel port. So it runs in a process group of its
|
|
9
|
+
* own under a small shell that kills it the moment AgentOS goes, however it
|
|
10
|
+
* goes (SIGKILL and out-of-memory included), and a copy a crash left behind
|
|
11
|
+
* anyway is killed before the next one starts.
|
|
6
12
|
*/
|
|
7
13
|
|
|
8
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
execFile,
|
|
16
|
+
spawn as nodeSpawn,
|
|
17
|
+
type ChildProcess,
|
|
18
|
+
type SpawnOptions,
|
|
19
|
+
} from "child_process";
|
|
9
20
|
import fs from "fs";
|
|
10
21
|
import path from "path";
|
|
11
22
|
import readline from "readline";
|
|
12
|
-
import type { Readable } from "stream";
|
|
23
|
+
import type { Readable, Writable } from "stream";
|
|
13
24
|
import { ApiError, clean } from "./control-api";
|
|
14
25
|
|
|
15
26
|
export type TunnelState = "connecting" | "up" | "denied" | "error" | "stopped";
|
|
@@ -21,6 +32,7 @@ export interface Prepared {
|
|
|
21
32
|
|
|
22
33
|
interface Child {
|
|
23
34
|
pid?: number;
|
|
35
|
+
stdin?: Writable | null;
|
|
24
36
|
stdout: Readable | null;
|
|
25
37
|
stderr: Readable | null;
|
|
26
38
|
kill(signal?: NodeJS.Signals): boolean;
|
|
@@ -53,7 +65,48 @@ const DELAYS = {
|
|
|
53
65
|
// cloudflared can be chatty while the network flaps.
|
|
54
66
|
const LOG_LINES_PER_MINUTE = 20;
|
|
55
67
|
|
|
56
|
-
|
|
68
|
+
// $1 is cloudflared, the rest its arguments. Its stdin is a pipe only
|
|
69
|
+
// AgentOS holds the other end of: when AgentOS ends, the read sees EOF and
|
|
70
|
+
// cloudflared is killed outright (a SIGTERM would let it drain, still
|
|
71
|
+
// forwarding, for half a minute). The shell exits with cloudflared's code.
|
|
72
|
+
const WATCHDOG = `exec 3<&0 </dev/null
|
|
73
|
+
"$@" 3<&- &
|
|
74
|
+
child=$!
|
|
75
|
+
trap 'kill -TERM "$child" 2>/dev/null' TERM INT HUP
|
|
76
|
+
{ read -r _ <&3; kill -KILL "$child" 2>/dev/null; } &
|
|
77
|
+
watch=$!
|
|
78
|
+
exec 3<&-
|
|
79
|
+
while :; do
|
|
80
|
+
wait "$child"; code=$?
|
|
81
|
+
kill -0 "$child" 2>/dev/null || break
|
|
82
|
+
done
|
|
83
|
+
kill "$watch" 2>/dev/null
|
|
84
|
+
exit "$code"`;
|
|
85
|
+
|
|
86
|
+
/** cloudflared under the watchdog, its group killed as one. */
|
|
87
|
+
export const spawnTunnel: Spawn = (bin, args, opts) => {
|
|
88
|
+
if (process.platform === "win32")
|
|
89
|
+
return (nodeSpawn as unknown as Spawn)(bin, args, opts);
|
|
90
|
+
const proc: ChildProcess = nodeSpawn(
|
|
91
|
+
"/bin/sh",
|
|
92
|
+
["-c", WATCHDOG, "agentos-cloudflared", bin, ...args],
|
|
93
|
+
{
|
|
94
|
+
env: opts.env,
|
|
95
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
96
|
+
detached: true,
|
|
97
|
+
} as SpawnOptions
|
|
98
|
+
);
|
|
99
|
+
proc.stdin?.on("error", () => {});
|
|
100
|
+
return Object.assign(proc, {
|
|
101
|
+
kill: (signal: NodeJS.Signals = "SIGTERM") => {
|
|
102
|
+
try {
|
|
103
|
+
if (proc.pid) return process.kill(-proc.pid, signal);
|
|
104
|
+
} catch {}
|
|
105
|
+
return proc.kill(signal);
|
|
106
|
+
},
|
|
107
|
+
}) as unknown as Child;
|
|
108
|
+
};
|
|
109
|
+
|
|
57
110
|
const commandOf = (pid: number) =>
|
|
58
111
|
new Promise<string>((resolve) =>
|
|
59
112
|
execFile(
|
|
@@ -64,25 +117,60 @@ const commandOf = (pid: number) =>
|
|
|
64
117
|
)
|
|
65
118
|
);
|
|
66
119
|
|
|
67
|
-
|
|
120
|
+
const signal = (pid: number, sig: NodeJS.Signals | 0) => {
|
|
121
|
+
try {
|
|
122
|
+
process.kill(pid, sig);
|
|
123
|
+
return true;
|
|
124
|
+
} catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export interface ReapDeps {
|
|
130
|
+
commandOf?: (pid: number) => Promise<string>;
|
|
131
|
+
/** process.kill: false when there was nothing to signal. */
|
|
132
|
+
kill?: (pid: number, sig: NodeJS.Signals | 0) => boolean;
|
|
133
|
+
waitMs?: number;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Kills a cloudflared an AgentOS that died left running, named by the pid
|
|
138
|
+
* file, and only if that pid is still one of ours (pids get reused). Call
|
|
139
|
+
* it only while holding connect.lock: then no live AgentOS owns it.
|
|
140
|
+
*/
|
|
141
|
+
export async function reapTunnel(
|
|
142
|
+
dir: string,
|
|
143
|
+
binDir = path.join(dir, "bin"),
|
|
144
|
+
deps: ReapDeps = {}
|
|
145
|
+
) {
|
|
146
|
+
const pidFile = path.join(dir, "cloudflared.pid");
|
|
147
|
+
const kill = deps.kill ?? signal;
|
|
68
148
|
let pid = 0;
|
|
69
149
|
try {
|
|
70
150
|
pid = Number(fs.readFileSync(pidFile, "utf8")) || 0;
|
|
71
151
|
} catch {
|
|
72
|
-
return;
|
|
152
|
+
return false;
|
|
73
153
|
}
|
|
154
|
+
let reaped = false;
|
|
74
155
|
if (pid > 1) {
|
|
75
|
-
const command = await commandOf(pid);
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
156
|
+
const command = await (deps.commandOf ?? commandOf)(pid);
|
|
157
|
+
if (command.includes(binDir + path.sep)) {
|
|
158
|
+
// Its group (the watchdog and cloudflared), or a bare cloudflared an
|
|
159
|
+
// older AgentOS started outside one.
|
|
160
|
+
reaped = kill(-pid, "SIGKILL");
|
|
161
|
+
reaped = kill(pid, "SIGKILL") || reaped;
|
|
162
|
+
const end = Date.now() + (deps.waitMs ?? 2000);
|
|
163
|
+
while (kill(pid, 0) && Date.now() < end)
|
|
164
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
165
|
+
}
|
|
79
166
|
}
|
|
80
167
|
fs.rmSync(pidFile, { force: true });
|
|
168
|
+
return reaped;
|
|
81
169
|
}
|
|
82
170
|
|
|
83
171
|
export function superviseTunnel(opts: SuperviseOptions) {
|
|
84
172
|
const log = opts.log ?? (() => {});
|
|
85
|
-
const spawn = opts.spawn ??
|
|
173
|
+
const spawn = opts.spawn ?? spawnTunnel;
|
|
86
174
|
const delays = opts.delays ?? DELAYS;
|
|
87
175
|
const pidFile = path.join(opts.dir, "cloudflared.pid");
|
|
88
176
|
const home = path.join(opts.dir, "cloudflared-home");
|
|
@@ -145,7 +233,7 @@ export function superviseTunnel(opts: SuperviseOptions) {
|
|
|
145
233
|
|
|
146
234
|
const run = async ({ bin, token }: Prepared) => {
|
|
147
235
|
const redact = (line: string) => clean(line.split(token).join("[token]"));
|
|
148
|
-
await
|
|
236
|
+
await reapTunnel(opts.dir, path.dirname(bin));
|
|
149
237
|
if (stopped) return;
|
|
150
238
|
fs.mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
151
239
|
// Its own config and home, so no config.yml elsewhere on the machine
|
|
@@ -256,8 +344,18 @@ export function superviseTunnel(opts: SuperviseOptions) {
|
|
|
256
344
|
proc.on("exit", (code) => end(`cloudflared exited (${code ?? "signal"})`));
|
|
257
345
|
};
|
|
258
346
|
|
|
347
|
+
// A stop that never ran (the process exiting, or a signal nobody else
|
|
348
|
+
// handles) still takes cloudflared's whole group down first.
|
|
259
349
|
const killNow = () => child?.kill("SIGTERM");
|
|
350
|
+
const SIGNALS = ["SIGTERM", "SIGINT", "SIGHUP"] as const;
|
|
351
|
+
const onSignal = (sig: NodeJS.Signals) => {
|
|
352
|
+
killNow();
|
|
353
|
+
for (const s of SIGNALS) process.removeListener(s, onSignal);
|
|
354
|
+
// Nobody else handles it: end the way the signal would have.
|
|
355
|
+
if (process.listenerCount(sig) === 0) process.kill(process.pid, sig);
|
|
356
|
+
};
|
|
260
357
|
process.once("exit", killNow);
|
|
358
|
+
for (const s of SIGNALS) process.on(s, onSignal);
|
|
261
359
|
tick();
|
|
262
360
|
|
|
263
361
|
return {
|
|
@@ -268,6 +366,7 @@ export function superviseTunnel(opts: SuperviseOptions) {
|
|
|
268
366
|
stopped = true;
|
|
269
367
|
state = "stopped";
|
|
270
368
|
process.removeListener("exit", killNow);
|
|
369
|
+
for (const s of SIGNALS) process.removeListener(s, onSignal);
|
|
271
370
|
if (timer) clearTimeout(timer);
|
|
272
371
|
const proc = child;
|
|
273
372
|
if (!proc) return;
|
package/lib/db/migrations.ts
CHANGED
|
@@ -1630,6 +1630,34 @@ const migrations: Migration[] = [
|
|
|
1630
1630
|
);
|
|
1631
1631
|
},
|
|
1632
1632
|
},
|
|
1633
|
+
{
|
|
1634
|
+
id: 71,
|
|
1635
|
+
name: "devices_push",
|
|
1636
|
+
up: (db) => {
|
|
1637
|
+
// Push belongs to a paired device, so revoking the device clears it.
|
|
1638
|
+
// push_categories is a JSON array; NULL means the defaults.
|
|
1639
|
+
// Each column checked, so a run cut off part way can be run again.
|
|
1640
|
+
const have = new Set(
|
|
1641
|
+
(
|
|
1642
|
+
db.prepare(`PRAGMA table_info(devices)`).all() as { name: string }[]
|
|
1643
|
+
).map((c) => c.name)
|
|
1644
|
+
);
|
|
1645
|
+
for (const col of [
|
|
1646
|
+
"push_token",
|
|
1647
|
+
"push_platform",
|
|
1648
|
+
"push_environment",
|
|
1649
|
+
"push_categories",
|
|
1650
|
+
"push_updated_at",
|
|
1651
|
+
"push_last_error",
|
|
1652
|
+
])
|
|
1653
|
+
if (!have.has(col))
|
|
1654
|
+
db.exec(`ALTER TABLE devices ADD COLUMN ${col} TEXT`);
|
|
1655
|
+
db.exec(
|
|
1656
|
+
`CREATE INDEX IF NOT EXISTS idx_devices_push_token ON devices(push_token)
|
|
1657
|
+
WHERE push_token IS NOT NULL`
|
|
1658
|
+
);
|
|
1659
|
+
},
|
|
1660
|
+
},
|
|
1633
1661
|
];
|
|
1634
1662
|
|
|
1635
1663
|
// `upTo`: stop after this id (tests that start from an older database).
|
package/lib/demo-reseed.ts
CHANGED
|
@@ -9,6 +9,7 @@ import fs from "fs";
|
|
|
9
9
|
import type Database from "better-sqlite3";
|
|
10
10
|
import { notifyStatusChanged } from "./status/hub";
|
|
11
11
|
import { resetDemoChat } from "./chat/demo";
|
|
12
|
+
import { markAllStatusDirty } from "./status/dirty";
|
|
12
13
|
|
|
13
14
|
export const RESEED_MS = 10 * 60 * 1000;
|
|
14
15
|
|
|
@@ -92,6 +93,7 @@ export function startDemoReseed(
|
|
|
92
93
|
try {
|
|
93
94
|
restoreDemo(db, file);
|
|
94
95
|
resetDemoChat();
|
|
96
|
+
markAllStatusDirty();
|
|
95
97
|
notifyStatusChanged();
|
|
96
98
|
onRestored();
|
|
97
99
|
} catch (error) {
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The category registry: one entry per kind of event, holding everything
|
|
3
|
+
* about it (priority, whether it's on by default, the copy, the deep link).
|
|
4
|
+
* Adding an event is one entry here. Pure, so the mobile app can sync it.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
PUSH_CATEGORIES,
|
|
9
|
+
PUSH_TITLE,
|
|
10
|
+
type PushCategory,
|
|
11
|
+
type PushEnvelope,
|
|
12
|
+
type PushEnvelopeV1,
|
|
13
|
+
type PushEvent,
|
|
14
|
+
type PushLevel,
|
|
15
|
+
} from "./push-types";
|
|
16
|
+
import { safeId, safeKey } from "./redact";
|
|
17
|
+
|
|
18
|
+
export interface CategoryDef {
|
|
19
|
+
level: PushLevel;
|
|
20
|
+
defaultOn: boolean;
|
|
21
|
+
/** Settings label. */
|
|
22
|
+
label: string;
|
|
23
|
+
/** The one word or phrase shown as the body. Fixed text, no event data. */
|
|
24
|
+
word: string;
|
|
25
|
+
/** App path for an id, or the fallback when the id is missing or unsafe. */
|
|
26
|
+
link: (id: string | null) => string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Where a tap goes when nothing more specific is known. */
|
|
30
|
+
export const FALLBACK_LINK = "/needs";
|
|
31
|
+
|
|
32
|
+
export const CATEGORIES: Record<PushCategory, CategoryDef> = {
|
|
33
|
+
asks: {
|
|
34
|
+
level: "time-sensitive",
|
|
35
|
+
defaultOn: true,
|
|
36
|
+
label: "Asks",
|
|
37
|
+
word: "Needs your call",
|
|
38
|
+
link: (id) => (id ? `/orchestrator?workspaceId=${id}` : FALLBACK_LINK),
|
|
39
|
+
},
|
|
40
|
+
"needs-you": {
|
|
41
|
+
level: "active",
|
|
42
|
+
defaultOn: true,
|
|
43
|
+
label: "Waiting for you",
|
|
44
|
+
word: "Needs you",
|
|
45
|
+
link: (id) => (id ? `/session/${id}` : FALLBACK_LINK),
|
|
46
|
+
},
|
|
47
|
+
review: {
|
|
48
|
+
level: "active",
|
|
49
|
+
defaultOn: true,
|
|
50
|
+
label: "Ready for review",
|
|
51
|
+
word: "Ready for review",
|
|
52
|
+
link: (id) => (id ? `/task/${id}` : FALLBACK_LINK),
|
|
53
|
+
},
|
|
54
|
+
ci: {
|
|
55
|
+
level: "active",
|
|
56
|
+
defaultOn: true,
|
|
57
|
+
label: "CI and review problems",
|
|
58
|
+
word: "Update",
|
|
59
|
+
link: (id) => (id ? `/task/${id}` : FALLBACK_LINK),
|
|
60
|
+
},
|
|
61
|
+
merged: {
|
|
62
|
+
level: "passive",
|
|
63
|
+
defaultOn: false,
|
|
64
|
+
label: "Merged",
|
|
65
|
+
word: "Update",
|
|
66
|
+
link: (id) => (id ? `/task/${id}` : FALLBACK_LINK),
|
|
67
|
+
},
|
|
68
|
+
agents: {
|
|
69
|
+
level: "active",
|
|
70
|
+
defaultOn: true,
|
|
71
|
+
label: "Messages from agents",
|
|
72
|
+
word: "Update",
|
|
73
|
+
link: (id) => (id ? `/session/${id}` : FALLBACK_LINK),
|
|
74
|
+
},
|
|
75
|
+
digest: {
|
|
76
|
+
level: "passive",
|
|
77
|
+
defaultOn: true,
|
|
78
|
+
label: "While you're away",
|
|
79
|
+
word: "Updates",
|
|
80
|
+
link: () => FALLBACK_LINK,
|
|
81
|
+
},
|
|
82
|
+
system: {
|
|
83
|
+
level: "active",
|
|
84
|
+
defaultOn: true,
|
|
85
|
+
label: "Machine alerts",
|
|
86
|
+
word: "Update",
|
|
87
|
+
link: () => "/machines",
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const isPushCategory = (c: unknown): c is PushCategory =>
|
|
92
|
+
typeof c === "string" && (PUSH_CATEGORIES as readonly string[]).includes(c);
|
|
93
|
+
|
|
94
|
+
export function defaultCategories(): PushCategory[] {
|
|
95
|
+
return PUSH_CATEGORIES.filter((c) => CATEGORIES[c].defaultOn);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The categories a device receives: its saved list, or the defaults when it
|
|
100
|
+
* has none. Unknown names are dropped so an old row survives a removed
|
|
101
|
+
* category.
|
|
102
|
+
*/
|
|
103
|
+
export function enabledCategories(
|
|
104
|
+
saved: readonly unknown[] | null | undefined
|
|
105
|
+
): PushCategory[] {
|
|
106
|
+
return saved
|
|
107
|
+
? PUSH_CATEGORIES.filter((c) => saved.includes(c))
|
|
108
|
+
: defaultCategories();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The v1 payload for an event. Built only from the registry and a vetted id
|
|
113
|
+
* and key, so it carries no event text whatever the caller passes.
|
|
114
|
+
*/
|
|
115
|
+
export function buildPushPayload(event: PushEvent): PushEnvelopeV1 {
|
|
116
|
+
const def = CATEGORIES[event.category];
|
|
117
|
+
const id = safeId(event.id);
|
|
118
|
+
return {
|
|
119
|
+
v: 1,
|
|
120
|
+
c: event.category,
|
|
121
|
+
t: PUSH_TITLE,
|
|
122
|
+
b: def.word,
|
|
123
|
+
l: def.link(id),
|
|
124
|
+
k: safeKey(event.key) ?? `${event.category}:${id ?? "-"}`,
|
|
125
|
+
p: def.level,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The app side: read what arrived. An unknown version or a malformed body
|
|
131
|
+
* gives the generic fallback, so a newer server never breaks an older app.
|
|
132
|
+
*/
|
|
133
|
+
export function parsePushEnvelope(raw: unknown): PushEnvelope {
|
|
134
|
+
const o = (raw && typeof raw === "object" ? raw : {}) as Record<
|
|
135
|
+
string,
|
|
136
|
+
unknown
|
|
137
|
+
>;
|
|
138
|
+
const category = isPushCategory(o.c) ? o.c : "system";
|
|
139
|
+
if (o.v === 1 || o.v === 2) {
|
|
140
|
+
const link =
|
|
141
|
+
typeof o.l === "string" && isAppLink(o.l)
|
|
142
|
+
? o.l
|
|
143
|
+
: CATEGORIES[category].link(null);
|
|
144
|
+
const base = {
|
|
145
|
+
c: category,
|
|
146
|
+
t: PUSH_TITLE,
|
|
147
|
+
b: typeof o.b === "string" ? o.b.slice(0, 80) : CATEGORIES[category].word,
|
|
148
|
+
l: link,
|
|
149
|
+
k: safeKey(o.k) ?? `${category}:-`,
|
|
150
|
+
p: CATEGORIES[category].level,
|
|
151
|
+
} as const;
|
|
152
|
+
// A v2 body is read as its generic v1 alert until this app can decrypt.
|
|
153
|
+
return { ...base, v: 1 };
|
|
154
|
+
}
|
|
155
|
+
return buildPushPayload({ category });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const APP_PATHS = [
|
|
159
|
+
/^\/needs$/,
|
|
160
|
+
/^\/machines$/,
|
|
161
|
+
/^\/session\/[A-Za-z0-9_-]{1,64}$/,
|
|
162
|
+
/^\/task\/[A-Za-z0-9_-]{1,64}$/,
|
|
163
|
+
/^\/orchestrator\?workspaceId=[A-Za-z0-9_-]{1,64}$/,
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
/** True for a link the registry could have produced. */
|
|
167
|
+
export function isAppLink(link: string): boolean {
|
|
168
|
+
return APP_PATHS.some((re) => re.test(link));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* True only for a payload the registry could have built: fixed title, the
|
|
173
|
+
* category's own word, a known link shape, a plain key. A sender checks this
|
|
174
|
+
* before it sends, so nothing else can leave the machine.
|
|
175
|
+
*/
|
|
176
|
+
export function isGenericPayload(p: unknown): p is PushEnvelopeV1 {
|
|
177
|
+
if (!p || typeof p !== "object") return false;
|
|
178
|
+
const o = p as Record<string, unknown>;
|
|
179
|
+
if (o.v !== 1 || !isPushCategory(o.c)) return false;
|
|
180
|
+
const def = CATEGORIES[o.c];
|
|
181
|
+
return (
|
|
182
|
+
Object.keys(o).sort().join() === "b,c,k,l,p,t,v" &&
|
|
183
|
+
o.t === PUSH_TITLE &&
|
|
184
|
+
o.b === def.word &&
|
|
185
|
+
o.p === def.level &&
|
|
186
|
+
typeof o.l === "string" &&
|
|
187
|
+
isAppLink(o.l) &&
|
|
188
|
+
safeKey(o.k) === o.k
|
|
189
|
+
);
|
|
190
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
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
|
+
/** True when the string looks like a token, key or JWT. */
|
|
20
|
+
export function looksSecret(text: string): boolean {
|
|
21
|
+
return [...SECRET_PATTERNS, LONG_RUN].some((re) => {
|
|
22
|
+
re.lastIndex = 0;
|
|
23
|
+
return re.test(text);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* First line only, collapsed whitespace, no backticks, secrets replaced by
|
|
29
|
+
* `…`, clipped. For the rich text of a later envelope version.
|
|
30
|
+
*/
|
|
31
|
+
export function scrubText(text: string | null | undefined, max = 120): string {
|
|
32
|
+
let s = (text ?? "").split(/\r?\n/).find((l) => l.trim()) ?? "";
|
|
33
|
+
s = s.replace(/`+/g, "");
|
|
34
|
+
for (const re of SECRET_PATTERNS) s = s.replace(re, "…");
|
|
35
|
+
s = s.replace(LONG_RUN, "…");
|
|
36
|
+
s = s.replace(/\s+/g, " ").trim();
|
|
37
|
+
return s.length > max ? s.slice(0, max - 1).trimEnd() + "…" : s;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** An id fit for a link or key: short and plain. Null if it isn't. */
|
|
41
|
+
export function safeId(id: unknown): string | null {
|
|
42
|
+
if (typeof id !== "string" || id.length === 0 || id.length > MAX_ID)
|
|
43
|
+
return null;
|
|
44
|
+
if (!SAFE_ID.test(id)) return null;
|
|
45
|
+
return looksSecret(id) ? null : id;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A collapse key: opaque and bounded. Anything with characters outside the
|
|
50
|
+
* plain set, or secret-looking, is dropped rather than cleaned.
|
|
51
|
+
*/
|
|
52
|
+
export function safeKey(key: unknown): string | null {
|
|
53
|
+
if (typeof key !== "string" || key.length === 0 || key.length > 128)
|
|
54
|
+
return null;
|
|
55
|
+
if (!/^[A-Za-z0-9_:.-]+$/.test(key)) return null;
|
|
56
|
+
return looksSecret(key) ? null : key;
|
|
57
|
+
}
|
|
@@ -26,6 +26,7 @@ import { isPaused } from "./pause";
|
|
|
26
26
|
import { review } from "./review";
|
|
27
27
|
import { judge, mergeJudged } from "./signoff";
|
|
28
28
|
import { workspaceTask } from "./targets";
|
|
29
|
+
import { markStatusDirty } from "../status/dirty";
|
|
29
30
|
|
|
30
31
|
// Judged again at least this often, for what the fingerprint can't see.
|
|
31
32
|
export const ARMED_RECHECK_MS = 5 * 60 * 1000;
|
|
@@ -38,6 +39,7 @@ export function setArmed(sessionId: string, armed: boolean): void {
|
|
|
38
39
|
merge_armed_seen = NULL WHERE id = ?`
|
|
39
40
|
).run(sessionId);
|
|
40
41
|
looked.delete(sessionId);
|
|
42
|
+
markStatusDirty(sessionId);
|
|
41
43
|
notifyStatusChanged();
|
|
42
44
|
}
|
|
43
45
|
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { db } from "../db";
|
|
8
8
|
import { notifyStatusChanged } from "../status/hub";
|
|
9
|
+
import { markAllStatusDirty, markStatusDirty } from "../status/dirty";
|
|
9
10
|
|
|
10
11
|
// ci: when the check count on a commit last changed, for settling.
|
|
11
12
|
export type CheckKind = "review" | "scope" | "ci";
|
|
@@ -58,6 +59,7 @@ export function putCheck(row: {
|
|
|
58
59
|
row.status,
|
|
59
60
|
row.detail ?? null
|
|
60
61
|
);
|
|
62
|
+
markStatusDirty(row.sessionId);
|
|
61
63
|
notifyStatusChanged();
|
|
62
64
|
return getCheck(row.sessionId, row.sha, row.kind)!;
|
|
63
65
|
}
|
|
@@ -68,4 +70,5 @@ export function clearStaleRunning(maxAgeMinutes = 30): void {
|
|
|
68
70
|
`UPDATE orchestrator_checks SET status = 'error', detail = 'Interrupted: run it again'
|
|
69
71
|
WHERE status = 'running' AND created_at < datetime('now', ?)`
|
|
70
72
|
).run(`-${maxAgeMinutes} minutes`);
|
|
73
|
+
markAllStatusDirty();
|
|
71
74
|
}
|
|
@@ -20,6 +20,7 @@ import { storedPR } from "../tasks/session";
|
|
|
20
20
|
import { commitTime, repoOf } from "./repo";
|
|
21
21
|
import { CHECKLESS_MS, checklessFor, ciSettleIn } from "./task-state";
|
|
22
22
|
import { repoHasCI } from "./repo-ci";
|
|
23
|
+
import { markStatusDirty } from "../status/dirty";
|
|
23
24
|
|
|
24
25
|
export type FactStatus = "running" | "waiting" | "idle" | "dead";
|
|
25
26
|
|
|
@@ -211,7 +212,10 @@ export async function settleIn(
|
|
|
211
212
|
let at = committed.get(pr.head);
|
|
212
213
|
if (at === undefined) {
|
|
213
214
|
at = await commitTime(repoOf(s), pr.head).catch(() => 0);
|
|
214
|
-
if (at)
|
|
215
|
+
if (at) {
|
|
216
|
+
committed.set(pr.head, at);
|
|
217
|
+
markStatusDirty(s.id);
|
|
218
|
+
}
|
|
215
219
|
}
|
|
216
220
|
return ciSettleIn({
|
|
217
221
|
workspaceId,
|
|
@@ -18,6 +18,7 @@ import type { CheckRow } from "./checks";
|
|
|
18
18
|
import { APPROVAL_GATES, mergeApprovalsOn } from "./merge-approvals";
|
|
19
19
|
import type { RepoCI } from "./repo-ci";
|
|
20
20
|
import { untrusted } from "./untrusted";
|
|
21
|
+
import { markStatusDirty } from "../status/dirty";
|
|
21
22
|
|
|
22
23
|
export type GateName =
|
|
23
24
|
| "ci"
|
|
@@ -244,6 +245,7 @@ function countOnce(
|
|
|
244
245
|
last_reason = excluded.last_reason,
|
|
245
246
|
last_sha = excluded.last_sha`
|
|
246
247
|
).run(sessionId, gate, workspaceId, reason, sha, fresh ? 1 : 0);
|
|
248
|
+
markStatusDirty(sessionId);
|
|
247
249
|
return failureOf(sessionId, gate)!.count;
|
|
248
250
|
}
|
|
249
251
|
|
|
@@ -261,6 +263,7 @@ export function markEscalated(sessionId: string, gate: string): void {
|
|
|
261
263
|
db.prepare(
|
|
262
264
|
`UPDATE orchestrator_gate_failures SET escalated_at = datetime('now') WHERE session_id = ? AND gate = ?`
|
|
263
265
|
).run(sessionId, gate);
|
|
266
|
+
markStatusDirty(sessionId);
|
|
264
267
|
}
|
|
265
268
|
|
|
266
269
|
export function escalations(sessionId: string): FailureRow[] {
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { db } from "../db";
|
|
10
|
+
import { markAllStatusDirty } from "../status/dirty";
|
|
10
11
|
|
|
11
12
|
const KEY = "orchestrator.merge_approvals";
|
|
12
13
|
|
|
@@ -26,4 +27,5 @@ export function setMergeApprovals(on: boolean): void {
|
|
|
26
27
|
`INSERT INTO settings (key, value) VALUES (?, ?)
|
|
27
28
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
|
|
28
29
|
).run(KEY, on ? "1" : "0");
|
|
30
|
+
markAllStatusDirty();
|
|
29
31
|
}
|
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 });
|