@saadnvd1/agent-os 0.3.698 → 0.3.700
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/app/api/bus/send/route.ts +11 -1
- package/app/api/bus/wait/route.ts +22 -0
- package/app/api/peer/hello/reveal/route.ts +6 -0
- package/app/api/peer/hello/route.ts +9 -0
- package/app/api/peer/hello/status/route.ts +6 -0
- package/app/api/peers/account/route.ts +9 -0
- package/app/api/peers/gate.test.ts +111 -0
- package/app/api/peers/link/[id]/confirm/route.ts +27 -0
- package/app/api/peers/link/[id]/route.ts +20 -0
- package/app/api/peers/link/route.ts +23 -0
- package/app/api/peers/pending/[id]/accept/route.ts +31 -0
- package/app/api/peers/pending/[id]/decline/route.ts +13 -0
- package/app/api/peers/pending/route.ts +7 -0
- package/app/api/sessions/route.ts +18 -1
- package/app/page.tsx +2 -0
- package/components/Hosts/AccountMachines.tsx +180 -0
- package/components/Hosts/HostRow.tsx +44 -35
- package/components/Hosts/HostsDialog.tsx +49 -37
- package/components/Hosts/IncomingLinkSheet.tsx +228 -0
- package/components/Hosts/LinkCodeSheet.tsx +186 -0
- package/components/Hosts/sheet.ts +3 -0
- package/components/SessionList/RowSubtitle.tsx +11 -1
- package/components/Welcome/ConnectBlock.tsx +1 -1
- package/data/peers/index.ts +2 -0
- package/data/peers/keys.ts +6 -0
- package/data/peers/queries.ts +141 -0
- package/lib/agents/brief.ts +3 -1
- package/lib/bus/format.ts +25 -4
- package/lib/bus/index.ts +113 -106
- package/lib/bus/resolve.ts +16 -2
- package/lib/bus/targets.ts +122 -0
- package/lib/bus/waits/kick.ts +12 -0
- package/lib/bus/waits/lines.ts +69 -0
- package/lib/bus/waits/register.ts +83 -0
- package/lib/bus/waits/store.ts +196 -0
- package/lib/bus/waits/tick.ts +192 -0
- package/lib/connect/control-api.ts +108 -2
- package/lib/connect/heartbeat.ts +11 -3
- package/lib/connect/serve.ts +2 -0
- package/lib/db/migrations/20261020T1600_bus_waits.ts +35 -0
- package/lib/db/types.ts +4 -0
- package/lib/done/archive.ts +2 -0
- package/lib/orchestrator/describe.ts +13 -0
- package/lib/orchestrator/facts.ts +17 -0
- package/lib/peer/account.ts +198 -0
- package/lib/peer/cli.ts +24 -0
- package/lib/peer/code.ts +3 -0
- package/lib/peer/hello-route.ts +32 -0
- package/lib/peer/hello.ts +346 -0
- package/lib/peer/link-cli.ts +233 -0
- package/lib/peer/machines.ts +72 -0
- package/lib/peer/notify.ts +32 -0
- package/lib/peer/pairing-out.ts +382 -0
- package/lib/peer/pairing.ts +403 -0
- package/lib/peer/pin.ts +55 -0
- package/lib/peer/rpc-intake.ts +37 -8
- package/lib/peer/rpc-server.ts +19 -1
- package/lib/schedules/when.ts +1 -1
- package/lib/security/auth.ts +10 -2
- package/lib/security/gate.ts +3 -0
- package/lib/sessions/end.ts +3 -0
- package/package.json +1 -1
- package/scripts/agent-os +2 -0
- package/scripts/lib/commands.sh +2 -0
- package/server.ts +3 -0
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pairing, on the machine being asked (B). A hello is taken only from a key
|
|
3
|
+
* the Connect account lists for its sender, and only while the plan
|
|
4
|
+
* includes linking; it then waits, in memory, for the sender to reveal the
|
|
5
|
+
* nonce it committed to, and then for a person here to type the code the
|
|
6
|
+
* sender shows. Nothing is trusted until that code matches: B pins A then
|
|
7
|
+
* and shows a second code, which the person types on A before A pins B.
|
|
8
|
+
* A machine linked here already keeps its keys: one that comes back with
|
|
9
|
+
* other keys is refused until a person unlinks it.
|
|
10
|
+
*
|
|
11
|
+
* Limits: hellos 5 per sender and 20 in all per 10 minutes, at most 3
|
|
12
|
+
* requests open and one per sender (a newer one replaces it), each for 5
|
|
13
|
+
* minutes, and 3 wrong codes void a request.
|
|
14
|
+
* A server restart forgets every open request (the sender sees "expired").
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { currentIdentity, loadBoxKey, type Identity } from "./keys";
|
|
18
|
+
import { linkingAllowed, listedPeer, type AccountPeer } from "./account";
|
|
19
|
+
import {
|
|
20
|
+
commitTo,
|
|
21
|
+
deriveCode,
|
|
22
|
+
parseHello,
|
|
23
|
+
parseReveal,
|
|
24
|
+
parseStatusAsk,
|
|
25
|
+
randomNonce,
|
|
26
|
+
sameCode,
|
|
27
|
+
signAck,
|
|
28
|
+
signAnswer,
|
|
29
|
+
typedCode,
|
|
30
|
+
verifyHello,
|
|
31
|
+
verifyReveal,
|
|
32
|
+
verifyStatusAsk,
|
|
33
|
+
withinSkew,
|
|
34
|
+
SKEW_MS,
|
|
35
|
+
type LinkState,
|
|
36
|
+
} from "./hello";
|
|
37
|
+
import { KEY_CHANGED, keysChanged, pinPeer } from "./pin";
|
|
38
|
+
import { notifyLinked, notifyLinkRequest } from "./notify";
|
|
39
|
+
|
|
40
|
+
export const REQUEST_TTL_MS = 5 * 60_000;
|
|
41
|
+
export const MAX_OPEN = 3;
|
|
42
|
+
export const MAX_WRONG = 3;
|
|
43
|
+
const WINDOW_MS = 10 * 60_000;
|
|
44
|
+
const PER_SENDER = 5;
|
|
45
|
+
const GLOBAL = 20;
|
|
46
|
+
// Hellos that get as far as the account list, from anyone: bounds the
|
|
47
|
+
// signature checks and list fetches a stranger can cause. One address (an
|
|
48
|
+
// IPv6 /64) is held to a share of it first, so it can't use it all up.
|
|
49
|
+
const UNPROVEN = 120;
|
|
50
|
+
const PER_SOURCE = 20;
|
|
51
|
+
|
|
52
|
+
interface Incoming {
|
|
53
|
+
id: string;
|
|
54
|
+
peer: AccountPeer;
|
|
55
|
+
aSign: string;
|
|
56
|
+
aBox: string;
|
|
57
|
+
commit: string;
|
|
58
|
+
bNonce: string;
|
|
59
|
+
code: string | null;
|
|
60
|
+
confirm: string | null;
|
|
61
|
+
state: "revealing" | LinkState;
|
|
62
|
+
why: string | null;
|
|
63
|
+
wrong: number;
|
|
64
|
+
createdAt: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface PairingDeps {
|
|
68
|
+
identity: () => Identity | null;
|
|
69
|
+
boxPub: () => string;
|
|
70
|
+
listed: (machineId: string) => Promise<AccountPeer | null>;
|
|
71
|
+
allowed: () => Promise<boolean | null>;
|
|
72
|
+
pin: (p: AccountPeer & { boxPub: string }) => string;
|
|
73
|
+
onRequest: (name: string) => void;
|
|
74
|
+
onLinked: (name: string) => void;
|
|
75
|
+
now: () => number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface Store {
|
|
79
|
+
requests: Map<string, Incoming>;
|
|
80
|
+
hellos: { at: number; from: string | null; source: string }[];
|
|
81
|
+
seen: Map<string, number>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const g = globalThis as unknown as { __agentosPairingIn?: Store };
|
|
85
|
+
const store = (): Store =>
|
|
86
|
+
(g.__agentosPairingIn ??= {
|
|
87
|
+
requests: new Map(),
|
|
88
|
+
hellos: [],
|
|
89
|
+
seen: new Map(),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
/** Tests: forget every request and every limit. */
|
|
93
|
+
export const resetIncoming = () => {
|
|
94
|
+
g.__agentosPairingIn = undefined;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
let box: string | null = null;
|
|
98
|
+
export const realPairingDeps = (): PairingDeps => ({
|
|
99
|
+
identity: () => currentIdentity(),
|
|
100
|
+
boxPub: () => (box ??= loadBoxKey().publicRaw),
|
|
101
|
+
listed: listedPeer,
|
|
102
|
+
allowed: linkingAllowed,
|
|
103
|
+
pin: pinPeer,
|
|
104
|
+
onRequest: notifyLinkRequest,
|
|
105
|
+
onLinked: notifyLinked,
|
|
106
|
+
now: Date.now,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
export interface Reply {
|
|
110
|
+
status: number;
|
|
111
|
+
body: Record<string, unknown>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const no = (status: number, error: string): Reply => ({
|
|
115
|
+
status,
|
|
116
|
+
body: { error },
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
function sweep(s: Store, now: number) {
|
|
120
|
+
for (const r of s.requests.values()) {
|
|
121
|
+
if (r.state !== "accepted" && now - r.createdAt > REQUEST_TTL_MS) {
|
|
122
|
+
if (r.state === "revealing" || r.state === "pending") r.state = "expired";
|
|
123
|
+
}
|
|
124
|
+
// Finished ones are kept a little so the sender can learn how it ended.
|
|
125
|
+
if (now - r.createdAt > 2 * REQUEST_TTL_MS) s.requests.delete(r.id);
|
|
126
|
+
}
|
|
127
|
+
s.hellos = s.hellos.filter((h) => now - h.at < WINDOW_MS);
|
|
128
|
+
for (const [k, until] of s.seen) if (until < now) s.seen.delete(k);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const open = (r: Incoming) => r.state === "revealing" || r.state === "pending";
|
|
132
|
+
|
|
133
|
+
/** A nonce, once per sender, for as long as its timestamp would pass. */
|
|
134
|
+
function fresh(s: Store, from: string, nonce: string, ts: number): boolean {
|
|
135
|
+
const k = `${from}:${nonce}`;
|
|
136
|
+
if (s.seen.has(k)) return false;
|
|
137
|
+
s.seen.set(k, ts + SKEW_MS);
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** POST /api/peer/hello */
|
|
142
|
+
export async function receiveHello(
|
|
143
|
+
body: unknown,
|
|
144
|
+
deps: PairingDeps = realPairingDeps(),
|
|
145
|
+
source = "unknown"
|
|
146
|
+
): Promise<Reply> {
|
|
147
|
+
const s = store();
|
|
148
|
+
const now = deps.now();
|
|
149
|
+
sweep(s, now);
|
|
150
|
+
const hello = parseHello(body);
|
|
151
|
+
if (!hello) return no(400, "bad hello");
|
|
152
|
+
const self = deps.identity();
|
|
153
|
+
if (!self || hello.to !== self.machineId)
|
|
154
|
+
return no(403, "this isn't the machine you meant");
|
|
155
|
+
if (!withinSkew(hello.ts, now))
|
|
156
|
+
return no(401, "stale: the clocks on the two machines differ");
|
|
157
|
+
if (
|
|
158
|
+
s.hellos.filter((h) => h.source === source).length >= PER_SOURCE ||
|
|
159
|
+
s.hellos.length >= UNPROVEN
|
|
160
|
+
)
|
|
161
|
+
return no(429, "too many requests");
|
|
162
|
+
s.hellos.push({ at: now, from: null, source });
|
|
163
|
+
const allowed = await deps.allowed();
|
|
164
|
+
if (allowed !== true)
|
|
165
|
+
return no(402, "Linking machines needs AgentOS Connect Pro");
|
|
166
|
+
const listed = await deps.listed(hello.from);
|
|
167
|
+
// Only a key this machine's own account lists for that machine may ask.
|
|
168
|
+
if (!listed || listed.signPub !== hello.sign_pub)
|
|
169
|
+
return no(403, "that machine isn't on this account");
|
|
170
|
+
if (!verifyHello(hello)) return no(401, "bad signature");
|
|
171
|
+
if (!fresh(s, hello.from, hello.nonce, hello.ts)) return no(401, "replay");
|
|
172
|
+
if (
|
|
173
|
+
keysChanged(hello.from, { signPub: hello.sign_pub, boxPub: hello.box_pub })
|
|
174
|
+
)
|
|
175
|
+
return no(409, KEY_CHANGED);
|
|
176
|
+
const proven = s.hellos.filter((h) => h.from !== null);
|
|
177
|
+
if (
|
|
178
|
+
proven.length >= GLOBAL ||
|
|
179
|
+
proven.filter((h) => h.from === hello.from).length >= PER_SENDER
|
|
180
|
+
)
|
|
181
|
+
return no(429, "too many link requests; wait a few minutes");
|
|
182
|
+
s.hellos[s.hellos.length - 1].from = hello.from;
|
|
183
|
+
for (const old of s.requests.values())
|
|
184
|
+
if (old.peer.id === hello.from && open(old)) {
|
|
185
|
+
old.state = "declined";
|
|
186
|
+
old.why = "replaced by a newer request";
|
|
187
|
+
}
|
|
188
|
+
if ([...s.requests.values()].filter(open).length >= MAX_OPEN)
|
|
189
|
+
return no(429, "too many link requests waiting here");
|
|
190
|
+
|
|
191
|
+
const r: Incoming = {
|
|
192
|
+
id: randomNonce(16),
|
|
193
|
+
peer: listed,
|
|
194
|
+
aSign: hello.sign_pub,
|
|
195
|
+
aBox: hello.box_pub,
|
|
196
|
+
commit: hello.commit,
|
|
197
|
+
bNonce: randomNonce(32),
|
|
198
|
+
code: null,
|
|
199
|
+
confirm: null,
|
|
200
|
+
state: "revealing",
|
|
201
|
+
why: null,
|
|
202
|
+
wrong: 0,
|
|
203
|
+
createdAt: now,
|
|
204
|
+
};
|
|
205
|
+
s.requests.set(r.id, r);
|
|
206
|
+
const ack = signAck(
|
|
207
|
+
self.signKey,
|
|
208
|
+
{
|
|
209
|
+
request_id: r.id,
|
|
210
|
+
sign_pub: self.signPub,
|
|
211
|
+
box_pub: deps.boxPub(),
|
|
212
|
+
b_nonce: r.bNonce,
|
|
213
|
+
},
|
|
214
|
+
hello
|
|
215
|
+
);
|
|
216
|
+
return { status: 200, body: { ...ack } };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Finds the request a signed reveal or status call is about. */
|
|
220
|
+
function askedAbout(
|
|
221
|
+
s: Store,
|
|
222
|
+
ask: {
|
|
223
|
+
from: string;
|
|
224
|
+
to: string;
|
|
225
|
+
ts: number;
|
|
226
|
+
nonce: string;
|
|
227
|
+
request_id: string;
|
|
228
|
+
},
|
|
229
|
+
self: Identity | null,
|
|
230
|
+
now: number
|
|
231
|
+
): Incoming | Reply {
|
|
232
|
+
if (!self || ask.to !== self.machineId)
|
|
233
|
+
return no(403, "this isn't the machine you meant");
|
|
234
|
+
if (!withinSkew(ask.ts, now)) return no(401, "stale");
|
|
235
|
+
const r = s.requests.get(ask.request_id);
|
|
236
|
+
if (!r || r.peer.id !== ask.from) return no(404, "no such request");
|
|
237
|
+
return r;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** POST /api/peer/hello/reveal */
|
|
241
|
+
export function receiveReveal(
|
|
242
|
+
body: unknown,
|
|
243
|
+
deps: PairingDeps = realPairingDeps()
|
|
244
|
+
): Reply {
|
|
245
|
+
const s = store();
|
|
246
|
+
const now = deps.now();
|
|
247
|
+
sweep(s, now);
|
|
248
|
+
const reveal = parseReveal(body);
|
|
249
|
+
if (!reveal) return no(400, "bad reveal");
|
|
250
|
+
const self = deps.identity();
|
|
251
|
+
const r = askedAbout(s, reveal, self, now);
|
|
252
|
+
if (!("id" in r)) return r;
|
|
253
|
+
if (!verifyReveal(reveal, r.aSign)) return no(401, "bad signature");
|
|
254
|
+
if (!fresh(s, reveal.from, reveal.nonce, reveal.ts)) return no(401, "replay");
|
|
255
|
+
if (r.state !== "revealing")
|
|
256
|
+
return {
|
|
257
|
+
status: 200,
|
|
258
|
+
body: { ...signAnswer(self!.signKey, r.state, reveal) },
|
|
259
|
+
};
|
|
260
|
+
if (commitTo(r.aBox, r.aSign, reveal.a_nonce) !== r.commit) {
|
|
261
|
+
// Whoever sent this chose its keys after seeing ours: void it.
|
|
262
|
+
r.state = "declined";
|
|
263
|
+
r.why = "its keys didn't match what it committed to";
|
|
264
|
+
return no(403, "that doesn't match the hello");
|
|
265
|
+
}
|
|
266
|
+
const transcript = {
|
|
267
|
+
from: r.peer.id,
|
|
268
|
+
to: self!.machineId,
|
|
269
|
+
aSign: r.aSign,
|
|
270
|
+
aBox: r.aBox,
|
|
271
|
+
bSign: self!.signPub,
|
|
272
|
+
bBox: deps.boxPub(),
|
|
273
|
+
aNonce: reveal.a_nonce,
|
|
274
|
+
bNonce: r.bNonce,
|
|
275
|
+
};
|
|
276
|
+
r.code = deriveCode(transcript);
|
|
277
|
+
r.confirm = deriveCode(transcript, "confirm");
|
|
278
|
+
r.state = "pending";
|
|
279
|
+
deps.onRequest(r.peer.name);
|
|
280
|
+
return {
|
|
281
|
+
status: 200,
|
|
282
|
+
body: { ...signAnswer(self!.signKey, "pending", reveal) },
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** POST /api/peer/hello/status */
|
|
287
|
+
export function receiveStatus(
|
|
288
|
+
body: unknown,
|
|
289
|
+
deps: PairingDeps = realPairingDeps()
|
|
290
|
+
): Reply {
|
|
291
|
+
const s = store();
|
|
292
|
+
const now = deps.now();
|
|
293
|
+
sweep(s, now);
|
|
294
|
+
const ask = parseStatusAsk(body);
|
|
295
|
+
if (!ask) return no(400, "bad status request");
|
|
296
|
+
const self = deps.identity();
|
|
297
|
+
const r = askedAbout(s, ask, self, now);
|
|
298
|
+
if (!("id" in r)) return r;
|
|
299
|
+
if (!verifyStatusAsk(ask, r.aSign)) return no(401, "bad signature");
|
|
300
|
+
if (!fresh(s, ask.from, ask.nonce, ask.ts)) return no(401, "replay");
|
|
301
|
+
const state: LinkState = r.state === "revealing" ? "pending" : r.state;
|
|
302
|
+
return { status: 200, body: { ...signAnswer(self!.signKey, state, ask) } };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export interface PendingView {
|
|
306
|
+
id: string;
|
|
307
|
+
machineId: string;
|
|
308
|
+
name: string;
|
|
309
|
+
lastSeenAt: string | null;
|
|
310
|
+
expiresAt: number;
|
|
311
|
+
attemptsLeft: number;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Requests waiting for a person here to type the code. */
|
|
315
|
+
export function pendingRequests(now = Date.now()): PendingView[] {
|
|
316
|
+
const s = store();
|
|
317
|
+
sweep(s, now);
|
|
318
|
+
return [...s.requests.values()]
|
|
319
|
+
.filter((r) => r.state === "pending")
|
|
320
|
+
.map((r) => ({
|
|
321
|
+
id: r.id,
|
|
322
|
+
machineId: r.peer.id,
|
|
323
|
+
name: r.peer.name,
|
|
324
|
+
lastSeenAt: r.peer.lastSeenAt,
|
|
325
|
+
expiresAt: r.createdAt + REQUEST_TTL_MS,
|
|
326
|
+
attemptsLeft: MAX_WRONG - r.wrong,
|
|
327
|
+
}));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export type AcceptResult =
|
|
331
|
+
| { ok: true; hostId: string; name: string; confirmCode: string }
|
|
332
|
+
| { ok: false; status: number; error: string; attemptsLeft?: number };
|
|
333
|
+
|
|
334
|
+
/** The code a person typed here, from a trusted device (the route checks). */
|
|
335
|
+
export async function acceptRequest(
|
|
336
|
+
id: string,
|
|
337
|
+
input: unknown,
|
|
338
|
+
deps: PairingDeps = realPairingDeps()
|
|
339
|
+
): Promise<AcceptResult> {
|
|
340
|
+
const s = store();
|
|
341
|
+
sweep(s, deps.now());
|
|
342
|
+
const r = s.requests.get(id);
|
|
343
|
+
if (!r || r.state !== "pending" || !r.code || !r.confirm)
|
|
344
|
+
return {
|
|
345
|
+
ok: false,
|
|
346
|
+
status: 404,
|
|
347
|
+
error: "That request has ended. Start again from the other machine.",
|
|
348
|
+
};
|
|
349
|
+
const code = typedCode(input);
|
|
350
|
+
if (!code)
|
|
351
|
+
return {
|
|
352
|
+
ok: false,
|
|
353
|
+
status: 400,
|
|
354
|
+
error: "Type the 6 digits the other machine shows.",
|
|
355
|
+
};
|
|
356
|
+
if (!sameCode(code, r.code)) {
|
|
357
|
+
r.wrong += 1;
|
|
358
|
+
if (r.wrong >= MAX_WRONG) {
|
|
359
|
+
r.state = "declined";
|
|
360
|
+
r.why = "wrong code 3 times";
|
|
361
|
+
return {
|
|
362
|
+
ok: false,
|
|
363
|
+
status: 403,
|
|
364
|
+
error:
|
|
365
|
+
"Wrong code 3 times: the request is void. Start again from the other machine.",
|
|
366
|
+
attemptsLeft: 0,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
return {
|
|
370
|
+
ok: false,
|
|
371
|
+
status: 403,
|
|
372
|
+
error: "That code doesn't match.",
|
|
373
|
+
attemptsLeft: MAX_WRONG - r.wrong,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
if ((await deps.allowed()) !== true)
|
|
377
|
+
return {
|
|
378
|
+
ok: false,
|
|
379
|
+
status: 402,
|
|
380
|
+
error: "Linking machines needs AgentOS Connect Pro.",
|
|
381
|
+
};
|
|
382
|
+
let hostId: string;
|
|
383
|
+
try {
|
|
384
|
+
hostId = deps.pin({ ...r.peer, signPub: r.aSign, boxPub: r.aBox });
|
|
385
|
+
} catch (err) {
|
|
386
|
+
r.state = "declined";
|
|
387
|
+
r.why = "it couldn't be linked here";
|
|
388
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
389
|
+
return { ok: false, status: 409, error };
|
|
390
|
+
}
|
|
391
|
+
r.state = "accepted";
|
|
392
|
+
deps.onLinked(r.peer.name);
|
|
393
|
+
// The person types this on the other machine, which pins this one then.
|
|
394
|
+
return { ok: true, hostId, name: r.peer.name, confirmCode: r.confirm };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export function declineRequest(id: string): boolean {
|
|
398
|
+
const r = store().requests.get(id);
|
|
399
|
+
if (!r || !open(r)) return false;
|
|
400
|
+
r.state = "declined";
|
|
401
|
+
r.why = "declined here";
|
|
402
|
+
return true;
|
|
403
|
+
}
|
package/lib/peer/pin.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins a machine after pairing: its keys on a Connect host of its own, the
|
|
3
|
+
* one it already has if it paired before. A re-pair never changes the keys
|
|
4
|
+
* of a pin: a machine whose keys changed is unlinked first, by a person.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createConnectHost, listRemoteHosts } from "../hosts";
|
|
8
|
+
import { pinForMachine, savePin } from "./links";
|
|
9
|
+
|
|
10
|
+
export interface Paired {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
hostname: string;
|
|
14
|
+
signPub: string;
|
|
15
|
+
boxPub: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A name no other machine here has, so `aos move <name>` stays clear. */
|
|
19
|
+
function freeName(name: string): string {
|
|
20
|
+
const taken = new Set(listRemoteHosts().map((h) => h.name.toLowerCase()));
|
|
21
|
+
if (!taken.has(name.toLowerCase()) && name.toLowerCase() !== "local")
|
|
22
|
+
return name;
|
|
23
|
+
for (let n = 2; ; n++)
|
|
24
|
+
if (!taken.has(`${name} (${n})`.toLowerCase())) return `${name} (${n})`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const KEY_CHANGED =
|
|
28
|
+
"This machine's keys changed since it was linked. Unlink it here first, then link it again.";
|
|
29
|
+
|
|
30
|
+
/** Whether a machine already linked here holds other keys than these. */
|
|
31
|
+
export function keysChanged(
|
|
32
|
+
machineId: string,
|
|
33
|
+
keys: { signPub: string; boxPub?: string }
|
|
34
|
+
): boolean {
|
|
35
|
+
const pin = pinForMachine(machineId);
|
|
36
|
+
return (
|
|
37
|
+
!!pin &&
|
|
38
|
+
(pin.signPub !== keys.signPub ||
|
|
39
|
+
(keys.boxPub !== undefined && pin.boxPub !== keys.boxPub))
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function pinPeer(p: Paired): string {
|
|
44
|
+
const pinned = pinForMachine(p.id);
|
|
45
|
+
if (keysChanged(p.id, p)) throw new Error(KEY_CHANGED);
|
|
46
|
+
const hostId = pinned?.hostId ?? createConnectHost(freeName(p.name)).id;
|
|
47
|
+
savePin({
|
|
48
|
+
hostId,
|
|
49
|
+
machineId: p.id,
|
|
50
|
+
hostname: p.hostname,
|
|
51
|
+
signPub: p.signPub,
|
|
52
|
+
boxPub: p.boxPub,
|
|
53
|
+
});
|
|
54
|
+
return hostId;
|
|
55
|
+
}
|
package/lib/peer/rpc-intake.ts
CHANGED
|
@@ -119,16 +119,35 @@ export function createBodyBudget(o: Partial<BudgetLimits> = {}): BodyBudget {
|
|
|
119
119
|
/**
|
|
120
120
|
* Where a request came from: the visitor's address Cloudflare stamps on a
|
|
121
121
|
* tunnel request (the socket there is always cloudflared), else the socket's.
|
|
122
|
+
* An IPv6 address counts by its /64: one host is usually handed a whole one.
|
|
122
123
|
*/
|
|
123
124
|
export function sourceOf(req: IncomingMessage): string {
|
|
125
|
+
let addr = req.socket.remoteAddress ?? "unknown";
|
|
124
126
|
if (isTunnelSocket(req.socket)) {
|
|
125
127
|
const cf = String(req.headers["cf-connecting-ip"] ?? "").trim();
|
|
126
|
-
if (isIP(cf))
|
|
128
|
+
if (isIP(cf)) addr = cf;
|
|
127
129
|
}
|
|
128
|
-
return
|
|
130
|
+
return isIP(addr) === 6 ? ipv6Prefix64(addr) : addr;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The first four groups of an IPv6 address, expanded: its /64. */
|
|
134
|
+
export function ipv6Prefix64(addr: string): string {
|
|
135
|
+
const plain = addr.split("%")[0].toLowerCase();
|
|
136
|
+
if (/^::ffff:\d+\.\d+\.\d+\.\d+$/.test(plain)) return plain.slice(7);
|
|
137
|
+
const [head, tail = ""] = plain.split("::");
|
|
138
|
+
const a = head ? head.split(":") : [];
|
|
139
|
+
const b = plain.includes("::") && tail ? tail.split(":") : [];
|
|
140
|
+
// An embedded IPv4 tail takes two groups; it never reaches the first four
|
|
141
|
+
// unless the address is mostly zeros, which then pads them.
|
|
142
|
+
const fill = Array(Math.max(0, 8 - a.length - b.length)).fill("0");
|
|
143
|
+
const groups = [...a, ...fill, ...b].slice(0, 4);
|
|
144
|
+
return `${groups.map((g) => g.replace(/^0+(?=.)/, "")).join(":")}::/64`;
|
|
129
145
|
}
|
|
130
146
|
|
|
131
147
|
export interface Pace {
|
|
148
|
+
/** How long the header and its signature may take: they fit in the
|
|
149
|
+
* first packet, so a sender that holds them back is stalling. */
|
|
150
|
+
headerMs: number;
|
|
132
151
|
/** How long the first bytes may take before a rate is required. */
|
|
133
152
|
graceMs: number;
|
|
134
153
|
/** Bytes per second the body must keep up after the grace period, */
|
|
@@ -138,6 +157,7 @@ export interface Pace {
|
|
|
138
157
|
}
|
|
139
158
|
|
|
140
159
|
export const DEFAULT_PACE: Pace = {
|
|
160
|
+
headerMs: 3_000,
|
|
141
161
|
graceMs: 10_000,
|
|
142
162
|
minRate: 512 * 1024,
|
|
143
163
|
windowMs: 5_000,
|
|
@@ -183,7 +203,7 @@ export function readEnvelope(
|
|
|
183
203
|
if (v instanceof Error) reject(v);
|
|
184
204
|
else resolve(v);
|
|
185
205
|
};
|
|
186
|
-
const { graceMs, minRate, windowMs } = o.pace;
|
|
206
|
+
const { headerMs, graceMs, minRate, windowMs } = o.pace;
|
|
187
207
|
// (time, bytes so far), back to the start of the current window.
|
|
188
208
|
const samples = [{ t: started, total: 0 }];
|
|
189
209
|
const timer = setInterval(
|
|
@@ -192,12 +212,14 @@ export function readEnvelope(
|
|
|
192
212
|
samples.push({ t: now, total });
|
|
193
213
|
while (samples.length > 1 && samples[1].t <= now - windowMs)
|
|
194
214
|
samples.shift();
|
|
215
|
+
if (!proven && now - started > headerMs)
|
|
216
|
+
return finish({ tooSlow: true });
|
|
195
217
|
if (now - started <= graceMs) return;
|
|
196
218
|
const base = samples[0];
|
|
197
219
|
if (total - base.total < ((now - base.t) / 1000) * minRate)
|
|
198
220
|
finish({ tooSlow: true });
|
|
199
221
|
},
|
|
200
|
-
Math.max(25, Math.min(1000, graceMs / 4, windowMs / 4))
|
|
222
|
+
Math.max(25, Math.min(1000, headerMs / 4, graceMs / 4, windowMs / 4))
|
|
201
223
|
);
|
|
202
224
|
req.on("data", (chunk: Buffer) => {
|
|
203
225
|
if (done) return;
|
|
@@ -211,10 +233,17 @@ export function readEnvelope(
|
|
|
211
233
|
const len = head.readUInt32BE(0);
|
|
212
234
|
if (len > MAX_HEADER) return finish({ refusal: "bad_envelope" });
|
|
213
235
|
if (head.length < 4 + len + HEADER_SIG) return;
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
236
|
+
let seen;
|
|
237
|
+
try {
|
|
238
|
+
seen = o.early(
|
|
239
|
+
head.subarray(4, 4 + len),
|
|
240
|
+
head.subarray(4 + len, 4 + len + HEADER_SIG)
|
|
241
|
+
);
|
|
242
|
+
} catch {
|
|
243
|
+
// A throw here would escape the 'data' handler and take the server
|
|
244
|
+
// down; whatever failed (the database, say), the caller is told busy.
|
|
245
|
+
return finish({ refusal: "busy" });
|
|
246
|
+
}
|
|
218
247
|
if ("refusal" in seen) return finish({ refusal: seen.refusal });
|
|
219
248
|
if (!o.ticket.prove(seen.peer)) return finish({ refusal: "busy" });
|
|
220
249
|
proven = true;
|
package/lib/peer/rpc-server.ts
CHANGED
|
@@ -177,6 +177,13 @@ export async function handlePeerRpc(
|
|
|
177
177
|
return null;
|
|
178
178
|
};
|
|
179
179
|
|
|
180
|
+
// The one nonce the header claimed: the whole envelope must carry it.
|
|
181
|
+
let claimed: string | null = null;
|
|
182
|
+
const spent: ReplayGuard = {
|
|
183
|
+
check: (peer, nonce) =>
|
|
184
|
+
claimed !== null && claimed === `${peer}:${nonce}` ? "ok" : "replay",
|
|
185
|
+
};
|
|
186
|
+
|
|
180
187
|
if (!deps.anyActive()) return plain(res, 404, "not found");
|
|
181
188
|
const ticket = deps.budget.open(sourceOf(req));
|
|
182
189
|
if (!ticket) return void refuse("busy");
|
|
@@ -193,6 +200,17 @@ export async function handlePeerRpc(
|
|
|
193
200
|
return { refusal: "bad_sig" };
|
|
194
201
|
const now = deps.now?.() ?? Date.now();
|
|
195
202
|
if (Math.abs(now - header.ts) > SKEW_MS) return { refusal: "stale" };
|
|
203
|
+
// The nonce is spent the moment its header is proven, so a header
|
|
204
|
+
// seen on the wire can't be sent again with junk bodies to hold
|
|
205
|
+
// the sender's share while the first is still arriving.
|
|
206
|
+
const fresh = deps.replay.check(
|
|
207
|
+
header.from,
|
|
208
|
+
header.nonce,
|
|
209
|
+
header.ts,
|
|
210
|
+
now
|
|
211
|
+
);
|
|
212
|
+
if (fresh !== "ok") return { refusal: fresh };
|
|
213
|
+
claimed = `${header.from}:${header.nonce}`;
|
|
196
214
|
return { peer: header.from };
|
|
197
215
|
},
|
|
198
216
|
ticket,
|
|
@@ -209,7 +227,7 @@ export async function handlePeerRpc(
|
|
|
209
227
|
self: self.machineId,
|
|
210
228
|
boxKey: deps.boxKey().privateKey,
|
|
211
229
|
signKeyFor,
|
|
212
|
-
replay:
|
|
230
|
+
replay: spent,
|
|
213
231
|
now: deps.now?.(),
|
|
214
232
|
});
|
|
215
233
|
};
|
package/lib/schedules/when.ts
CHANGED
|
@@ -26,7 +26,7 @@ const UNIT_MS: Record<string, number> = {
|
|
|
26
26
|
d: 86_400_000,
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
-
function duration(text: string): number | null {
|
|
29
|
+
export function duration(text: string): number | null {
|
|
30
30
|
let total = 0;
|
|
31
31
|
const rest = text.replace(
|
|
32
32
|
/(\d+(?:\.\d+)?|an?)\s*(minutes?|mins?|m|hours?|hrs?|h|days?|d)\b/g,
|
package/lib/security/auth.ts
CHANGED
|
@@ -18,6 +18,9 @@ export const DEVICE_COOKIE = "aos_device";
|
|
|
18
18
|
export const DEVICE_HEADER = "x-agentos-device";
|
|
19
19
|
export const TRUST_HEADER = "x-agentos-trust";
|
|
20
20
|
export const REMOTE_HEADER = "x-agentos-remote";
|
|
21
|
+
// Who sent it, for per-sender limits: the visitor's address on a Connect
|
|
22
|
+
// request (lib/peer/rpc-intake sourceOf), an IPv6 address by its /64.
|
|
23
|
+
export const SOURCE_HEADER = "x-agentos-source";
|
|
21
24
|
// The rpc server's loopback hop (lib/peer/rpc-server): the per-boot secret,
|
|
22
25
|
// and the linked machine it verified. Never trusted from anywhere else.
|
|
23
26
|
export const PEER_INTERNAL_HEADER = "x-agentos-peer-internal";
|
|
@@ -81,12 +84,17 @@ const FORWARDING = new Set([
|
|
|
81
84
|
const FORWARDING_PREFIXES = ["x-forwarded-", "cf-", "tailscale-"];
|
|
82
85
|
|
|
83
86
|
// Reached without a token: the pairing page, what it needs to render, the
|
|
84
|
-
// claim it posts,
|
|
85
|
-
//
|
|
87
|
+
// claim it posts, a linked machine's rpc (its envelope is the proof) and
|
|
88
|
+
// the pairing hellos (signed by a key the account lists). Exact paths, plus
|
|
89
|
+
// a few static asset folders whose paths may only use plain characters (no
|
|
90
|
+
// traversal, no encoding).
|
|
86
91
|
const PUBLIC_EXACT = new Set([
|
|
87
92
|
"/pair",
|
|
88
93
|
"/api/pair/claim",
|
|
89
94
|
"/api/peer/rpc",
|
|
95
|
+
"/api/peer/hello",
|
|
96
|
+
"/api/peer/hello/reveal",
|
|
97
|
+
"/api/peer/hello/status",
|
|
90
98
|
"/manifest.webmanifest",
|
|
91
99
|
"/manifest.json",
|
|
92
100
|
"/favicon.ico",
|
package/lib/security/gate.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
DEVICE_HEADER,
|
|
15
15
|
TRUST_HEADER,
|
|
16
16
|
REMOTE_HEADER,
|
|
17
|
+
SOURCE_HEADER,
|
|
17
18
|
PEER_HEADER,
|
|
18
19
|
PEER_INTERNAL_HEADER,
|
|
19
20
|
type AuthPolicy,
|
|
@@ -25,6 +26,7 @@ import { tailscaleAddresses } from "./net";
|
|
|
25
26
|
import { networkSetting } from "./network-settings";
|
|
26
27
|
import { pairLocation } from "../session-url";
|
|
27
28
|
import { peerSecret } from "../peer/internal";
|
|
29
|
+
import { sourceOf } from "../peer/rpc-intake";
|
|
28
30
|
|
|
29
31
|
// Interface addresses change rarely; don't re-read them on every request.
|
|
30
32
|
let tailnetCache = { at: 0, ips: [] as string[] };
|
|
@@ -63,6 +65,7 @@ function decide(req: IncomingMessage, policy: AuthPolicy): AuthResult {
|
|
|
63
65
|
delete req.headers[PEER_HEADER];
|
|
64
66
|
const tunnel = isTunnelSocket(req.socket);
|
|
65
67
|
req.headers[REMOTE_HEADER] = sourceLabel(req.socket.remoteAddress, tunnel);
|
|
68
|
+
req.headers[SOURCE_HEADER] = sourceOf(req);
|
|
66
69
|
let result: AuthResult;
|
|
67
70
|
try {
|
|
68
71
|
result = authorize(
|
package/lib/sessions/end.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { waitForExit } from "../chat/worker/client";
|
|
|
4
4
|
import { endTmuxSession } from "../tmux/end";
|
|
5
5
|
import { releaseOnEnd, shutTaskSimulators } from "../simulator";
|
|
6
6
|
import { forgetPlanWait } from "../models/plan-step";
|
|
7
|
+
import { kickWaits } from "../bus/waits/kick";
|
|
7
8
|
|
|
8
9
|
type Agent = Pick<Session, "id" | "host_id" | "tmux_name"> &
|
|
9
10
|
Partial<Pick<Session, "worktree_path" | "branch_name">>;
|
|
@@ -20,4 +21,6 @@ export async function endSessionAgent(s: Agent): Promise<void> {
|
|
|
20
21
|
shutTaskSimulators(s);
|
|
21
22
|
void waitForExit(s.id).catch(() => {});
|
|
22
23
|
await endTmuxSession(s.host_id, s.tmux_name);
|
|
24
|
+
// Whoever waits on it is told now, not at the next sweep.
|
|
25
|
+
kickWaits();
|
|
23
26
|
}
|
package/package.json
CHANGED
package/scripts/agent-os
CHANGED
|
@@ -83,6 +83,8 @@ case "${1:-}" in
|
|
|
83
83
|
pair) node "$SCRIPT_DIR/pair.mjs" "$PORT" ;;
|
|
84
84
|
connect) shift; (cd "$(dirname "$SCRIPT_DIR")" && npx tsx lib/connect/cli.ts "$@") ;;
|
|
85
85
|
disconnect) (cd "$(dirname "$SCRIPT_DIR")" && npx tsx lib/connect/cli.ts --off) ;;
|
|
86
|
+
link) shift; (cd "$(dirname "$SCRIPT_DIR")" && npx tsx lib/peer/cli.ts "$PORT" "$@") ;;
|
|
87
|
+
unlink) shift; (cd "$(dirname "$SCRIPT_DIR")" && npx tsx lib/peer/cli.ts "$PORT" --unlink "$@") ;;
|
|
86
88
|
passkeys) shift; (cd "$(dirname "$SCRIPT_DIR")" && npx tsx lib/security/passkeys-cli.ts "$@") ;;
|
|
87
89
|
logs) cmd_logs ;;
|
|
88
90
|
update) cmd_update ;;
|