@saadnvd1/agent-os 0.3.700 → 0.3.715
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 +1 -0
- package/app/api/bus/groups/route.ts +44 -0
- package/app/api/bus/spawn/route.ts +12 -1
- package/app/api/claims/[id]/route.ts +20 -0
- package/app/api/claims/check/route.ts +23 -0
- package/app/api/claims/check.test.ts +113 -0
- package/app/api/claims/exchange/route.ts +42 -0
- package/app/api/claims/outside.test.ts +90 -0
- package/app/api/claims/route.test.ts +193 -0
- package/app/api/claims/route.ts +73 -0
- package/app/api/hosts/[id]/route.ts +9 -1
- package/app/api/peer/unlink/route.ts +17 -0
- package/app/api/peers/[hostId]/audit/route.ts +14 -0
- package/app/api/peers/[hostId]/route.ts +20 -0
- package/app/api/peers/gate.test.ts +64 -1
- package/app/api/peers/pending/route.test.ts +37 -0
- package/app/api/peers/pending/route.ts +11 -4
- package/app/api/session-groups/[id]/restore/route.ts +11 -0
- package/app/api/session-groups/[id]/route.ts +25 -0
- package/app/api/session-groups/order/route.ts +15 -0
- package/app/api/session-groups/route.ts +21 -0
- package/app/api/sessions/[id]/fork/route.ts +3 -0
- package/app/api/sessions/[id]/group/route.ts +18 -0
- package/app/api/sessions/[id]/route.ts +14 -4
- package/app/api/sessions/[id]/summarize/route.ts +2 -0
- package/app/api/sessions/claims.test.ts +122 -0
- package/app/api/sessions/group/route.ts +17 -0
- package/app/api/sessions/route.ts +15 -14
- package/app/page.tsx +5 -0
- package/components/Claims/ClaimFiles.tsx +38 -0
- package/components/Claims/ClaimRow.tsx +82 -0
- package/components/Claims/ClaimsSheet.tsx +176 -0
- package/components/Claims/OverlapPill.tsx +31 -0
- package/components/Claims/StartClaimsNotice.tsx +70 -0
- package/components/Claims/claims-ui.test.ts +161 -0
- package/components/Claims/format.ts +57 -0
- package/components/Claims/index.ts +3 -0
- package/components/Draft/DraftPanel.tsx +40 -2
- package/components/Hosts/AccountMachines.tsx +32 -18
- package/components/Hosts/HostRow.tsx +155 -14
- package/components/Hosts/IncomingLinkSheet.tsx +28 -10
- package/components/Hosts/PeerActivitySheet.tsx +111 -0
- package/components/SessionList/GroupShelves.tsx +195 -0
- package/components/SessionList/MoveToGroupMenu.tsx +105 -0
- package/components/SessionList/RowSubtitle.tsx +7 -1
- package/components/SessionList/SelectionToolbar.tsx +52 -1
- package/components/SessionList/SessionRow.tsx +1 -0
- package/components/SessionList/SessionRowMenu.tsx +17 -0
- package/components/SessionList/SessionShelves.tsx +5 -0
- package/components/SessionList/SidebarBody.tsx +6 -0
- package/components/SessionList/SidebarContextMenu.test.ts +104 -0
- package/components/SessionList/SidebarContextMenu.tsx +59 -0
- package/components/SessionList/SidebarHeader.tsx +5 -0
- package/components/SessionList/WorkspaceSwitcher.tsx +23 -6
- package/components/SessionList/index.tsx +18 -14
- package/components/SessionList/useSidebarData.ts +10 -0
- package/components/ui/notice.tsx +46 -0
- package/components/ui/status-pill.tsx +39 -0
- package/data/claims/index.ts +2 -0
- package/data/claims/keys.ts +5 -0
- package/data/claims/queries.ts +47 -0
- package/data/peers/keys.ts +1 -0
- package/data/peers/queries.ts +31 -0
- package/data/push/topics.ts +4 -0
- package/data/session-groups/index.ts +156 -0
- package/data/sessions/index.ts +0 -1
- package/data/sessions/pin.ts +2 -6
- package/data/sessions/queries.ts +10 -29
- package/data/sessions/shelf.ts +2 -6
- package/data/tasks/index.ts +2 -0
- package/hooks/useSessions.ts +0 -12
- package/lib/__fixtures__/peer-rpc.ts +65 -33
- package/lib/agents/spawn.ts +4 -0
- package/lib/bus/index.ts +8 -0
- package/lib/claims/collectLocal.ts +121 -0
- package/lib/claims/describe.ts +41 -0
- package/lib/claims/exchange.ts +91 -0
- package/lib/claims/goal.ts +45 -0
- package/lib/claims/index.ts +10 -0
- package/lib/claims/loop.ts +51 -0
- package/lib/claims/machine.ts +21 -0
- package/lib/claims/mine.ts +43 -0
- package/lib/claims/notifyOverlaps.ts +101 -0
- package/lib/claims/onMerged.ts +85 -0
- package/lib/claims/outside.ts +36 -0
- package/lib/claims/overlap.ts +87 -0
- package/lib/claims/overlapCache.ts +86 -0
- package/lib/claims/overlapText.ts +36 -0
- package/lib/claims/peers.ts +56 -0
- package/lib/claims/startCheck.ts +100 -0
- package/lib/claims/startWarning.ts +29 -0
- package/lib/claims/store.ts +366 -0
- package/lib/claims/types.ts +102 -0
- package/lib/claims/view.ts +80 -0
- package/lib/claims/yield.ts +24 -0
- package/lib/db/changes.ts +6 -0
- package/lib/db/migrations/20261020T1400_work_claims.ts +35 -0
- package/lib/db/migrations/20261020T1515_peer_unlinked.ts +22 -0
- package/lib/db/migrations/20261020T1700_clean_connect_names.ts +18 -0
- package/lib/db/migrations/20261020T1800_session_groups.ts +49 -0
- package/lib/db/migrations/20261020T1900_task_queue_after_claim.ts +13 -0
- package/lib/db/migrations/20261020T2000_claim_notices.ts +20 -0
- package/lib/db/migrations/20261020T2100_claims_via_host.ts +21 -0
- package/lib/db/migrations/20261020T2200_work_claims_watch.ts +10 -0
- package/lib/db/queries.ts +0 -40
- package/lib/db/session-parts/columns.ts +1 -0
- package/lib/db/types.ts +16 -8
- package/lib/doctor/checks.ts +64 -1
- package/lib/git-remote.ts +43 -0
- package/lib/hosts/index.ts +10 -1
- package/lib/orchestration.ts +2 -0
- package/lib/orchestrator/act.ts +54 -4
- package/lib/orchestrator/brief.ts +2 -1
- package/lib/orchestrator/claim-wait.ts +71 -0
- package/lib/orchestrator/conditions.ts +28 -1
- package/lib/orchestrator/describe.ts +6 -1
- package/lib/orchestrator/facts.ts +5 -0
- package/lib/orchestrator/serve.ts +37 -2
- package/lib/orchestrator/tool-names.ts +1 -0
- package/lib/orchestrator/tool-schemas.ts +31 -1
- package/lib/orchestrator/tools.ts +3 -1
- package/lib/orchestrator/watcher.ts +19 -1
- package/lib/peer/account.ts +74 -2
- package/lib/peer/allowlist.ts +1 -1
- package/lib/peer/client.ts +26 -1
- package/lib/peer/link-cli.ts +19 -4
- package/lib/peer/links.ts +117 -5
- package/lib/peer/machines.ts +14 -2
- package/lib/peer/name.ts +32 -0
- package/lib/peer/pairing.ts +34 -5
- package/lib/peer/pin.ts +10 -3
- package/lib/peer/reasons.ts +7 -0
- package/lib/peer/revoke.ts +75 -0
- package/lib/peer/rpc-server.ts +53 -5
- package/lib/peer/unlink.ts +62 -0
- package/lib/peer/unlinked-proof.ts +44 -0
- package/lib/projects.ts +5 -2
- package/lib/security/demo.ts +7 -0
- package/lib/session-groups/assignSessions.ts +81 -0
- package/lib/session-groups/cli.ts +61 -0
- package/lib/session-groups/createGroup.ts +28 -0
- package/lib/session-groups/index.ts +21 -0
- package/lib/session-groups/removeGroup.ts +36 -0
- package/lib/session-groups/reorderGroups.ts +31 -0
- package/lib/session-groups/resolveGroup.ts +19 -0
- package/lib/session-groups/route.ts +32 -0
- package/lib/session-groups/store.ts +85 -0
- package/lib/session-groups/types.ts +25 -0
- package/lib/session-groups/updateGroup.ts +35 -0
- package/lib/session-groups/workspace.ts +21 -0
- package/lib/sessions/launch.ts +5 -2
- package/lib/sidebar/rollup.ts +1 -0
- package/lib/sidebar/row-menu.ts +2 -0
- package/lib/sidebar/shelves.ts +90 -15
- package/lib/sidebar/visible-order.ts +5 -0
- package/lib/tasks/hold-checks.ts +3 -1
- package/lib/tasks/holds.ts +3 -0
- package/lib/tasks/index.ts +5 -0
- package/lib/tasks/queue-rows.ts +8 -1
- package/lib/tasks/queue-start.ts +1 -0
- package/lib/tasks/queue-types.ts +9 -1
- package/lib/tasks/queue.ts +15 -5
- package/lib/tasks/schedule-types.ts +1 -0
- package/lib/test-temp.ts +19 -3
- package/lib/workspaces.ts +11 -3
- package/lib/worktree-sweep/testTemp.ts +23 -106
- package/package.json +1 -1
- package/scripts/agent-os +1 -1
- package/scripts/autodeploy +2 -0
- package/scripts/autodeploy.test.ts +31 -1
- package/scripts/claim-hook.sh +31 -0
- package/scripts/claim-hook.test.ts +230 -0
- package/scripts/lib/cli-links.sh +36 -0
- package/scripts/lib/commands.sh +3 -0
- package/scripts/lib/machine.sh +27 -2
- package/scripts/lib/machine.test.ts +25 -3
- package/scripts/sync-cli-links +7 -0
- package/scripts/sync-cli-links.test.ts +69 -0
- package/server.ts +3 -0
- package/stores/claimsUi.ts +22 -0
- package/app/api/groups/[...path]/route.ts +0 -136
- package/app/api/groups/route.ts +0 -93
- package/data/groups/index.ts +0 -1
- package/data/groups/mutations.ts +0 -95
- package/hooks/useGroups.ts +0 -37
package/lib/peer/links.ts
CHANGED
|
@@ -64,16 +64,18 @@ export const pinForHost = (hostId: string): PeerPin | null =>
|
|
|
64
64
|
|
|
65
65
|
/** Pins a peer's keys to a Connect host. Pairing (S2) is the only caller. */
|
|
66
66
|
export function savePin(pin: Omit<PeerPin, "state" | "suspendedReason">) {
|
|
67
|
-
getDb()
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
const db = getDb();
|
|
68
|
+
db.prepare(`DELETE FROM peer_unlinked WHERE machine_id = ?`).run(
|
|
69
|
+
pin.machineId
|
|
70
|
+
);
|
|
71
|
+
db.prepare(
|
|
72
|
+
`INSERT INTO peer_links (host_id, machine_id, hostname, sign_pub, box_pub)
|
|
70
73
|
VALUES (?, ?, ?, ?, ?)
|
|
71
74
|
ON CONFLICT(host_id) DO UPDATE SET machine_id = excluded.machine_id,
|
|
72
75
|
hostname = excluded.hostname, sign_pub = excluded.sign_pub,
|
|
73
76
|
box_pub = excluded.box_pub, state = 'active', suspended_reason = NULL,
|
|
74
77
|
paired_at = datetime('now'), last_error = NULL`
|
|
75
|
-
|
|
76
|
-
.run(pin.hostId, pin.machineId, pin.hostname, pin.signPub, pin.boxPub);
|
|
78
|
+
).run(pin.hostId, pin.machineId, pin.hostname, pin.signPub, pin.boxPub);
|
|
77
79
|
}
|
|
78
80
|
|
|
79
81
|
export function suspendPin(hostId: string, reason: string) {
|
|
@@ -84,6 +86,112 @@ export function suspendPin(hostId: string, reason: string) {
|
|
|
84
86
|
.run(reason, hostId);
|
|
85
87
|
}
|
|
86
88
|
|
|
89
|
+
export function resumePin(hostId: string) {
|
|
90
|
+
getDb()
|
|
91
|
+
.prepare(
|
|
92
|
+
`UPDATE peer_links SET state = 'active', suspended_reason = NULL WHERE host_id = ?`
|
|
93
|
+
)
|
|
94
|
+
.run(hostId);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Every pin, for checking them against the account (revoke.ts). */
|
|
98
|
+
export const allPins = (): PeerPin[] =>
|
|
99
|
+
(getDb().prepare(SELECT).all() as Row[]).map((r) => toPin(r)!);
|
|
100
|
+
|
|
101
|
+
/** How long an unlinked machine is still answered "unknown peer", signed. */
|
|
102
|
+
export const UNLINKED_KEEP_MS = 30 * 24 * 3_600_000;
|
|
103
|
+
|
|
104
|
+
export interface Unlinked {
|
|
105
|
+
machineId: string;
|
|
106
|
+
hostId: string | null;
|
|
107
|
+
signPub: string;
|
|
108
|
+
/** The other machine unlinked it, rather than a person here. */
|
|
109
|
+
byPeer: boolean;
|
|
110
|
+
reason: string;
|
|
111
|
+
at: number;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
interface UnlinkedRow {
|
|
115
|
+
machine_id: string;
|
|
116
|
+
host_id: string | null;
|
|
117
|
+
sign_pub: string;
|
|
118
|
+
by_peer: number;
|
|
119
|
+
reason: string;
|
|
120
|
+
at: number;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const toUnlinked = (r: UnlinkedRow | undefined): Unlinked | null =>
|
|
124
|
+
r
|
|
125
|
+
? {
|
|
126
|
+
machineId: r.machine_id,
|
|
127
|
+
hostId: r.host_id,
|
|
128
|
+
signPub: r.sign_pub,
|
|
129
|
+
byPeer: r.by_peer === 1,
|
|
130
|
+
reason: r.reason,
|
|
131
|
+
at: r.at,
|
|
132
|
+
}
|
|
133
|
+
: null;
|
|
134
|
+
|
|
135
|
+
/** A machine unlinked here within the last 30 days, by its Connect id. */
|
|
136
|
+
export const unlinkedMachine = (
|
|
137
|
+
machineId: string,
|
|
138
|
+
now = Date.now()
|
|
139
|
+
): Unlinked | null =>
|
|
140
|
+
toUnlinked(
|
|
141
|
+
getDb()
|
|
142
|
+
.prepare(`SELECT * FROM peer_unlinked WHERE machine_id = ? AND at > ?`)
|
|
143
|
+
.get(machineId, now - UNLINKED_KEEP_MS) as UnlinkedRow | undefined
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
export const unlinkedHost = (hostId: string): Unlinked | null =>
|
|
147
|
+
toUnlinked(
|
|
148
|
+
getDb()
|
|
149
|
+
.prepare(
|
|
150
|
+
`SELECT * FROM peer_unlinked WHERE host_id = ? ORDER BY at DESC LIMIT 1`
|
|
151
|
+
)
|
|
152
|
+
.get(hostId) as UnlinkedRow | undefined
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
export const hasRecentUnlinked = (now = Date.now()): boolean =>
|
|
156
|
+
getDb()
|
|
157
|
+
.prepare(`SELECT 1 FROM peer_unlinked WHERE at > ? LIMIT 1`)
|
|
158
|
+
.get(now - UNLINKED_KEEP_MS) !== undefined;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Removes a pin, keeping its signing key as unlinked (see the migration).
|
|
162
|
+
* The host stays: it may still hold sessions, and it says who unlinked it.
|
|
163
|
+
*/
|
|
164
|
+
export function dropPin(
|
|
165
|
+
hostId: string,
|
|
166
|
+
o: { byPeer: boolean; reason: string },
|
|
167
|
+
now = Date.now()
|
|
168
|
+
): PeerPin | null {
|
|
169
|
+
const pin = pinForHost(hostId);
|
|
170
|
+
if (!pin) return null;
|
|
171
|
+
const db = getDb();
|
|
172
|
+
db.transaction(() => {
|
|
173
|
+
db.prepare(`DELETE FROM peer_unlinked WHERE at <= ?`).run(
|
|
174
|
+
now - UNLINKED_KEEP_MS
|
|
175
|
+
);
|
|
176
|
+
db.prepare(
|
|
177
|
+
`INSERT INTO peer_unlinked (machine_id, host_id, sign_pub, by_peer, reason, at)
|
|
178
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
179
|
+
ON CONFLICT(machine_id) DO UPDATE SET host_id = excluded.host_id,
|
|
180
|
+
sign_pub = excluded.sign_pub, by_peer = excluded.by_peer,
|
|
181
|
+
reason = excluded.reason, at = excluded.at`
|
|
182
|
+
).run(
|
|
183
|
+
pin.machineId,
|
|
184
|
+
hostId,
|
|
185
|
+
pin.signPub,
|
|
186
|
+
o.byPeer ? 1 : 0,
|
|
187
|
+
o.reason.slice(0, 200),
|
|
188
|
+
now
|
|
189
|
+
);
|
|
190
|
+
db.prepare(`DELETE FROM peer_links WHERE host_id = ?`).run(hostId);
|
|
191
|
+
})();
|
|
192
|
+
return pin;
|
|
193
|
+
}
|
|
194
|
+
|
|
87
195
|
/** Last outcome of a call to that peer, for Machines. */
|
|
88
196
|
export function notePinResult(hostId: string, error: string | null) {
|
|
89
197
|
getDb()
|
|
@@ -94,3 +202,7 @@ export function notePinResult(hostId: string, error: string | null) {
|
|
|
94
202
|
)
|
|
95
203
|
.run(...(error ? [error.slice(0, 300), hostId] : [hostId]));
|
|
96
204
|
}
|
|
205
|
+
|
|
206
|
+
/** The other machine, `name`, unlinked this one: its signed call or refusal. */
|
|
207
|
+
export const unlinkedByPeer = (hostId: string, name: string): boolean =>
|
|
208
|
+
dropPin(hostId, { byPeer: true, reason: `Unlinked by ${name}` }) !== null;
|
package/lib/peer/machines.ts
CHANGED
|
@@ -17,6 +17,7 @@ export interface AccountMachine {
|
|
|
17
17
|
/** The Machines host it's linked as, when it is. */
|
|
18
18
|
hostId: string | null;
|
|
19
19
|
linkState: "active" | "suspended" | null;
|
|
20
|
+
suspendedReason: string | null;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
export interface AccountMachines {
|
|
@@ -24,6 +25,8 @@ export interface AccountMachines {
|
|
|
24
25
|
entitlements: Entitlements | null;
|
|
25
26
|
fetchedAt: number;
|
|
26
27
|
error: string | null;
|
|
28
|
+
/** Set when the service hasn't answered for a day: since when. */
|
|
29
|
+
uncheckedSince: number | null;
|
|
27
30
|
machines: AccountMachine[];
|
|
28
31
|
}
|
|
29
32
|
|
|
@@ -39,10 +42,16 @@ export async function accountMachines(
|
|
|
39
42
|
(
|
|
40
43
|
getDb()
|
|
41
44
|
.prepare(
|
|
42
|
-
`SELECT p.machine_id, p.host_id, p.state
|
|
45
|
+
`SELECT p.machine_id, p.host_id, p.state, p.suspended_reason
|
|
46
|
+
FROM peer_links p
|
|
43
47
|
JOIN hosts h ON h.id = p.host_id WHERE h.kind = 'connect'`
|
|
44
48
|
)
|
|
45
|
-
.all() as {
|
|
49
|
+
.all() as {
|
|
50
|
+
machine_id: string;
|
|
51
|
+
host_id: string;
|
|
52
|
+
state: string;
|
|
53
|
+
suspended_reason: string | null;
|
|
54
|
+
}[]
|
|
46
55
|
).map((r) => [r.machine_id, r])
|
|
47
56
|
);
|
|
48
57
|
return {
|
|
@@ -50,6 +59,7 @@ export async function accountMachines(
|
|
|
50
59
|
entitlements: view.entitlements,
|
|
51
60
|
fetchedAt: view.fetchedAt,
|
|
52
61
|
error: view.error,
|
|
62
|
+
uncheckedSince: view.uncheckedSince,
|
|
53
63
|
machines: view.peers.map((p) => {
|
|
54
64
|
const pin = pins.get(p.id);
|
|
55
65
|
const seen = p.lastSeenAt ? Date.parse(p.lastSeenAt) : NaN;
|
|
@@ -66,6 +76,8 @@ export async function accountMachines(
|
|
|
66
76
|
? "active"
|
|
67
77
|
: "suspended"
|
|
68
78
|
: null,
|
|
79
|
+
suspendedReason:
|
|
80
|
+
pin && pin.state !== "active" ? pin.suspended_reason : null,
|
|
69
81
|
};
|
|
70
82
|
}),
|
|
71
83
|
};
|
package/lib/peer/name.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A machine's name as the Connect service tells it, made safe to show next
|
|
3
|
+
* to a pairing code: plain text on one line, no direction overrides, short,
|
|
4
|
+
* and with no digits at all once it holds 4 or more, so a lying service
|
|
5
|
+
* can't name a machine "type 482, 913" and have the person type that
|
|
6
|
+
* instead. Separators can't be enumerated ("482 and 913"), so the count is
|
|
7
|
+
* over the whole name.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const NAME_MAX = 48;
|
|
11
|
+
|
|
12
|
+
// C0/C1 controls, zero-width and joiner characters, bidi embeddings,
|
|
13
|
+
// overrides and isolates, the byte-order mark.
|
|
14
|
+
const INVISIBLE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu;
|
|
15
|
+
// Digits, any script.
|
|
16
|
+
const DIGIT = /\p{Nd}/gu;
|
|
17
|
+
const MAX_DIGITS = 3;
|
|
18
|
+
|
|
19
|
+
export const UNNAMED = "Unnamed machine";
|
|
20
|
+
|
|
21
|
+
export function cleanMachineName(raw: unknown): string {
|
|
22
|
+
if (typeof raw !== "string") return UNNAMED;
|
|
23
|
+
let s = raw
|
|
24
|
+
.normalize("NFKC")
|
|
25
|
+
.replace(/\s+/g, " ")
|
|
26
|
+
.replace(INVISIBLE, "")
|
|
27
|
+
.trim();
|
|
28
|
+
if ((s.match(DIGIT) ?? []).length > MAX_DIGITS) s = s.replace(DIGIT, "#");
|
|
29
|
+
if ([...s].length > NAME_MAX)
|
|
30
|
+
s = `${[...s].slice(0, NAME_MAX - 1).join("")}…`;
|
|
31
|
+
return s || UNNAMED;
|
|
32
|
+
}
|
package/lib/peer/pairing.ts
CHANGED
|
@@ -159,7 +159,10 @@ export async function receiveHello(
|
|
|
159
159
|
s.hellos.length >= UNPROVEN
|
|
160
160
|
)
|
|
161
161
|
return no(429, "too many requests");
|
|
162
|
-
|
|
162
|
+
// Tagged later through this reference: other hellos may have been pushed
|
|
163
|
+
// by then, while this one waited on the plan and the account list.
|
|
164
|
+
const entry: Store["hellos"][number] = { at: now, from: null, source };
|
|
165
|
+
s.hellos.push(entry);
|
|
163
166
|
const allowed = await deps.allowed();
|
|
164
167
|
if (allowed !== true)
|
|
165
168
|
return no(402, "Linking machines needs AgentOS Connect Pro");
|
|
@@ -179,7 +182,7 @@ export async function receiveHello(
|
|
|
179
182
|
proven.filter((h) => h.from === hello.from).length >= PER_SENDER
|
|
180
183
|
)
|
|
181
184
|
return no(429, "too many link requests; wait a few minutes");
|
|
182
|
-
|
|
185
|
+
entry.from = hello.from;
|
|
183
186
|
for (const old of s.requests.values())
|
|
184
187
|
if (old.peer.id === hello.from && open(old)) {
|
|
185
188
|
old.state = "declined";
|
|
@@ -309,14 +312,28 @@ export interface PendingView {
|
|
|
309
312
|
lastSeenAt: string | null;
|
|
310
313
|
expiresAt: number;
|
|
311
314
|
attemptsLeft: number;
|
|
315
|
+
/** `accepted`: the code matched here; `confirmCode` goes on the other machine. */
|
|
316
|
+
state: "pending" | "accepted";
|
|
317
|
+
/** Only for a caller that could have accepted it (withCodes). */
|
|
318
|
+
confirmCode?: string;
|
|
312
319
|
}
|
|
313
320
|
|
|
314
|
-
/**
|
|
315
|
-
|
|
321
|
+
/**
|
|
322
|
+
* Requests waiting for a person here to type the code, and accepted ones
|
|
323
|
+
* still within their 5 minutes, whose second code must survive a reload.
|
|
324
|
+
*/
|
|
325
|
+
export function pendingRequests(
|
|
326
|
+
now = Date.now(),
|
|
327
|
+
o: { withCodes?: boolean } = {}
|
|
328
|
+
): PendingView[] {
|
|
316
329
|
const s = store();
|
|
317
330
|
sweep(s, now);
|
|
318
331
|
return [...s.requests.values()]
|
|
319
|
-
.filter(
|
|
332
|
+
.filter(
|
|
333
|
+
(r) =>
|
|
334
|
+
r.state === "pending" ||
|
|
335
|
+
(r.state === "accepted" && now - r.createdAt <= REQUEST_TTL_MS)
|
|
336
|
+
)
|
|
320
337
|
.map((r) => ({
|
|
321
338
|
id: r.id,
|
|
322
339
|
machineId: r.peer.id,
|
|
@@ -324,6 +341,10 @@ export function pendingRequests(now = Date.now()): PendingView[] {
|
|
|
324
341
|
lastSeenAt: r.peer.lastSeenAt,
|
|
325
342
|
expiresAt: r.createdAt + REQUEST_TTL_MS,
|
|
326
343
|
attemptsLeft: MAX_WRONG - r.wrong,
|
|
344
|
+
state: r.state === "accepted" ? "accepted" : "pending",
|
|
345
|
+
...(o.withCodes &&
|
|
346
|
+
r.state === "accepted" &&
|
|
347
|
+
r.confirm && { confirmCode: r.confirm }),
|
|
327
348
|
}));
|
|
328
349
|
}
|
|
329
350
|
|
|
@@ -379,6 +400,14 @@ export async function acceptRequest(
|
|
|
379
400
|
status: 402,
|
|
380
401
|
error: "Linking machines needs AgentOS Connect Pro.",
|
|
381
402
|
};
|
|
403
|
+
// It may have been declined, replaced, expired or accepted meanwhile.
|
|
404
|
+
sweep(s, deps.now());
|
|
405
|
+
if (r.state !== "pending" || s.requests.get(id) !== r)
|
|
406
|
+
return {
|
|
407
|
+
ok: false,
|
|
408
|
+
status: 404,
|
|
409
|
+
error: "That request has ended. Start again from the other machine.",
|
|
410
|
+
};
|
|
382
411
|
let hostId: string;
|
|
383
412
|
try {
|
|
384
413
|
hostId = deps.pin({ ...r.peer, signPub: r.aSign, boxPub: r.aBox });
|
package/lib/peer/pin.ts
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
* of a pin: a machine whose keys changed is unlinked first, by a person.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { createConnectHost, listRemoteHosts } from "../hosts";
|
|
8
|
-
import { pinForMachine, savePin } from "./links";
|
|
7
|
+
import { createConnectHost, getHost, listRemoteHosts } from "../hosts";
|
|
8
|
+
import { pinForMachine, savePin, unlinkedMachine } from "./links";
|
|
9
|
+
import { cleanMachineName } from "./name";
|
|
9
10
|
|
|
10
11
|
export interface Paired {
|
|
11
12
|
id: string;
|
|
@@ -43,7 +44,13 @@ export function keysChanged(
|
|
|
43
44
|
export function pinPeer(p: Paired): string {
|
|
44
45
|
const pinned = pinForMachine(p.id);
|
|
45
46
|
if (keysChanged(p.id, p)) throw new Error(KEY_CHANGED);
|
|
46
|
-
|
|
47
|
+
// Linked again after an unlink: back on the host it had, if it's still here.
|
|
48
|
+
const was = unlinkedMachine(p.id)?.hostId;
|
|
49
|
+
const reuse = was && getHost(was)?.kind === "connect" ? was : null;
|
|
50
|
+
const hostId =
|
|
51
|
+
pinned?.hostId ??
|
|
52
|
+
reuse ??
|
|
53
|
+
createConnectHost(freeName(cleanMachineName(p.name))).id;
|
|
47
54
|
savePin({
|
|
48
55
|
hostId,
|
|
49
56
|
machineId: p.id,
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Why a Connect link is suspended (revoke.ts), shared with the UI. */
|
|
2
|
+
|
|
3
|
+
import { CONNECT_SITE } from "../connect/view";
|
|
4
|
+
|
|
5
|
+
export const REMOVED_FROM_ACCOUNT = "removed from your account";
|
|
6
|
+
export const NEEDS_PRO = "Linking machines needs Pro";
|
|
7
|
+
export const UPGRADE_URL = `${CONNECT_SITE}/settings/billing`;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Suspending links when the account says so, and lifting that when it says
|
|
3
|
+
* otherwise. A pin is never deleted here: a machine removed from the
|
|
4
|
+
* account by mistake comes back linked as soon as its key is listed again,
|
|
5
|
+
* with no new pairing. The account can only take trust away this way, never
|
|
6
|
+
* add it: a pin it didn't suspend is never touched, and a suspended pin is
|
|
7
|
+
* resumed only with the very key it was paired with.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { allPins, resumePin, suspendPin, type PeerPin } from "./links";
|
|
11
|
+
import { NEEDS_PRO, REMOVED_FROM_ACCOUNT } from "./reasons";
|
|
12
|
+
|
|
13
|
+
export { NEEDS_PRO, REMOVED_FROM_ACCOUNT };
|
|
14
|
+
|
|
15
|
+
const OURS: ReadonlySet<string> = new Set([REMOVED_FROM_ACCOUNT, NEEDS_PRO]);
|
|
16
|
+
|
|
17
|
+
export interface AccountSays {
|
|
18
|
+
/** The plan's answer; null when it hasn't said. */
|
|
19
|
+
linkMachines: boolean | null;
|
|
20
|
+
/** Each listed machine's signing key, or null when this isn't a fresh list. */
|
|
21
|
+
listed: Map<string, string> | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** What this pin's state should be, or null to leave it as it is. */
|
|
25
|
+
export function wanted(
|
|
26
|
+
pin: PeerPin,
|
|
27
|
+
says: AccountSays
|
|
28
|
+
): { state: "active" } | { state: "suspended"; reason: string } | null {
|
|
29
|
+
if (pin.state === "suspended" && !OURS.has(pin.suspendedReason ?? ""))
|
|
30
|
+
return null;
|
|
31
|
+
if (says.linkMachines === false)
|
|
32
|
+
return { state: "suspended", reason: NEEDS_PRO };
|
|
33
|
+
const key = says.listed?.get(pin.machineId);
|
|
34
|
+
if (says.listed && key !== pin.signPub)
|
|
35
|
+
return { state: "suspended", reason: REMOVED_FROM_ACCOUNT };
|
|
36
|
+
if (pin.state === "active") return null;
|
|
37
|
+
const lifted =
|
|
38
|
+
pin.suspendedReason === NEEDS_PRO
|
|
39
|
+
? says.linkMachines === true
|
|
40
|
+
: says.listed !== null && key === pin.signPub;
|
|
41
|
+
return lifted ? { state: "active" } : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface Changed {
|
|
45
|
+
hostId: string;
|
|
46
|
+
state: "active" | "suspended";
|
|
47
|
+
reason: string | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Applies what the account says to every pin; returns what changed. */
|
|
51
|
+
export function reconcilePins(
|
|
52
|
+
says: AccountSays,
|
|
53
|
+
pins: PeerPin[] = allPins()
|
|
54
|
+
): Changed[] {
|
|
55
|
+
const changed: Changed[] = [];
|
|
56
|
+
for (const pin of pins) {
|
|
57
|
+
const want = wanted(pin, says);
|
|
58
|
+
if (!want) continue;
|
|
59
|
+
if (want.state === "active") {
|
|
60
|
+
resumePin(pin.hostId);
|
|
61
|
+
changed.push({ hostId: pin.hostId, state: "active", reason: null });
|
|
62
|
+
} else if (
|
|
63
|
+
pin.state !== "suspended" ||
|
|
64
|
+
pin.suspendedReason !== want.reason
|
|
65
|
+
) {
|
|
66
|
+
suspendPin(pin.hostId, want.reason);
|
|
67
|
+
changed.push({
|
|
68
|
+
hostId: pin.hostId,
|
|
69
|
+
state: "suspended",
|
|
70
|
+
reason: want.reason,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return changed;
|
|
75
|
+
}
|
package/lib/peer/rpc-server.ts
CHANGED
|
@@ -15,7 +15,10 @@
|
|
|
15
15
|
* Refusals before decryption are plain and fixed (`bad envelope`, `bad
|
|
16
16
|
* signature`, `stale`, `replay`, `busy`). An unknown sender, a suspended one
|
|
17
17
|
* and a bad signature all read `bad signature`, so a stranger can't tell
|
|
18
|
-
* which machines are linked here; the audit keeps the difference.
|
|
18
|
+
* which machines are linked here; the audit keeps the difference. The one
|
|
19
|
+
* exception is a machine unlinked here, proven by the key it was pinned
|
|
20
|
+
* with: it gets 403 `unknown peer`, signed (unlinked-proof.ts), so it can
|
|
21
|
+
* show that it was unlinked. Everything
|
|
19
22
|
* after is inside the envelope,
|
|
20
23
|
* always HTTP 200, so whoever carries the traffic never learns the route or
|
|
21
24
|
* its status.
|
|
@@ -45,7 +48,14 @@ import {
|
|
|
45
48
|
type BoxKey,
|
|
46
49
|
type Identity,
|
|
47
50
|
} from "./keys";
|
|
48
|
-
import {
|
|
51
|
+
import {
|
|
52
|
+
hasActivePin,
|
|
53
|
+
hasRecentUnlinked,
|
|
54
|
+
pinForMachine,
|
|
55
|
+
unlinkedMachine,
|
|
56
|
+
type PeerPin,
|
|
57
|
+
} from "./links";
|
|
58
|
+
import { UNLINKED_PROOF_HEADER, signUnlinked } from "./unlinked-proof";
|
|
49
59
|
import { sqliteNonceStore } from "./nonce-store";
|
|
50
60
|
import { createReplayGuard, type ReplayGuard } from "./replay";
|
|
51
61
|
import {
|
|
@@ -75,7 +85,11 @@ export interface RpcDeps {
|
|
|
75
85
|
identity: () => Identity | null;
|
|
76
86
|
boxKey: () => BoxKey;
|
|
77
87
|
pinForMachine: (machineId: string) => PeerPin | null;
|
|
78
|
-
/**
|
|
88
|
+
/** A machine unlinked here lately: the key it was pinned with. */
|
|
89
|
+
unlinked?: (
|
|
90
|
+
machineId: string
|
|
91
|
+
) => { signPub: string; hostId: string | null } | null;
|
|
92
|
+
/** False while no link is active or recently unlinked: the route is closed to everyone. */
|
|
79
93
|
anyActive: () => boolean;
|
|
80
94
|
replay: ReplayGuard;
|
|
81
95
|
dispatch: (d: Dispatch) => Promise<PeerResponse>;
|
|
@@ -125,6 +139,10 @@ export async function handlePeerRpc(
|
|
|
125
139
|
const self = deps.identity();
|
|
126
140
|
let fromMachine: string | null = null;
|
|
127
141
|
let pin: PeerPin | null = null;
|
|
142
|
+
// An unlinked sender whose header its old key proved, and its nonce.
|
|
143
|
+
let unlinkedHost: string | null = null;
|
|
144
|
+
let viaUnlinked = false;
|
|
145
|
+
let provenUnlinked: string | null = null;
|
|
128
146
|
const audit = (
|
|
129
147
|
outcome: Outcome,
|
|
130
148
|
status: number,
|
|
@@ -133,7 +151,7 @@ export async function handlePeerRpc(
|
|
|
133
151
|
bytes = 0
|
|
134
152
|
) =>
|
|
135
153
|
deps.audit({
|
|
136
|
-
hostId: pin?.hostId ??
|
|
154
|
+
hostId: pin?.hostId ?? unlinkedHost,
|
|
137
155
|
machineId: fromMachine,
|
|
138
156
|
direction: "in",
|
|
139
157
|
method,
|
|
@@ -144,6 +162,19 @@ export async function handlePeerRpc(
|
|
|
144
162
|
outcome,
|
|
145
163
|
});
|
|
146
164
|
const refuse = (refusal: Refusal): null => {
|
|
165
|
+
if (refusal === "unknown_peer" && provenUnlinked && self && fromMachine) {
|
|
166
|
+
audit("unknown_peer", 403);
|
|
167
|
+
res.setHeader(
|
|
168
|
+
UNLINKED_PROOF_HEADER,
|
|
169
|
+
signUnlinked(self.signKey, {
|
|
170
|
+
from: self.machineId,
|
|
171
|
+
to: fromMachine,
|
|
172
|
+
nonce: provenUnlinked,
|
|
173
|
+
})
|
|
174
|
+
);
|
|
175
|
+
plain(res, 403, "unknown peer");
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
147
178
|
const [status, text, outcome] = REFUSED[refusal];
|
|
148
179
|
audit(outcome, status);
|
|
149
180
|
plain(res, status, text);
|
|
@@ -161,6 +192,18 @@ export async function handlePeerRpc(
|
|
|
161
192
|
: found.state !== "active"
|
|
162
193
|
? "suspended"
|
|
163
194
|
: null;
|
|
195
|
+
if (!found) {
|
|
196
|
+
const gone = deps.unlinked?.(machineId);
|
|
197
|
+
if (gone)
|
|
198
|
+
try {
|
|
199
|
+
const key = signPublic(gone.signPub);
|
|
200
|
+
unlinkedHost = gone.hostId;
|
|
201
|
+
viaUnlinked = true;
|
|
202
|
+
return key;
|
|
203
|
+
} catch {
|
|
204
|
+
// Unreadable: a decoy, like any stranger.
|
|
205
|
+
}
|
|
206
|
+
}
|
|
164
207
|
if (!found || hidden) return DECOY_KEY;
|
|
165
208
|
try {
|
|
166
209
|
return signPublic(found.signPub);
|
|
@@ -210,6 +253,10 @@ export async function handlePeerRpc(
|
|
|
210
253
|
now
|
|
211
254
|
);
|
|
212
255
|
if (fresh !== "ok") return { refusal: fresh };
|
|
256
|
+
if (viaUnlinked) {
|
|
257
|
+
provenUnlinked = header.nonce;
|
|
258
|
+
return { refusal: "unknown_peer" };
|
|
259
|
+
}
|
|
213
260
|
claimed = `${header.from}:${header.nonce}`;
|
|
214
261
|
return { peer: header.from };
|
|
215
262
|
},
|
|
@@ -360,7 +407,8 @@ export function rpcDeps(port: number): RpcDeps {
|
|
|
360
407
|
identity: () => currentIdentity(),
|
|
361
408
|
boxKey: () => (box ??= loadBoxKey()),
|
|
362
409
|
pinForMachine,
|
|
363
|
-
|
|
410
|
+
unlinked: (machineId) => unlinkedMachine(machineId),
|
|
411
|
+
anyActive: () => hasActivePin() || hasRecentUnlinked(),
|
|
364
412
|
replay: createReplayGuard({ store: sqliteNonceStore() }),
|
|
365
413
|
dispatch: loopbackDispatch(port),
|
|
366
414
|
audit: recordPeerCall,
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unlinking a machine linked through Connect, from either side. Here: the
|
|
3
|
+
* pin goes at once (links.ts dropPin), then the other machine is told with a
|
|
4
|
+
* signed POST /api/peer/unlink, best effort. There: the same call drops its
|
|
5
|
+
* pin and its Machines says "Unlinked by <this machine>". If that call never
|
|
6
|
+
* arrives, the other machine's next call here is answered with a signed
|
|
7
|
+
* "unknown peer" (rpc-server.ts), which does the same.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { deleteHost } from "../hosts";
|
|
11
|
+
import { hostLink, type HostLink } from "../hosts/remote-api";
|
|
12
|
+
import { peerCall } from "./client";
|
|
13
|
+
import { dropPin, pinForHost } from "./links";
|
|
14
|
+
|
|
15
|
+
export const UNLINKED_HERE = "Unlinked here";
|
|
16
|
+
|
|
17
|
+
export interface UnlinkDeps {
|
|
18
|
+
tell: (link: HostLink) => Promise<unknown>;
|
|
19
|
+
remove: (hostId: string) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const realDeps: UnlinkDeps = {
|
|
23
|
+
tell: (link) =>
|
|
24
|
+
peerCall(link, "/api/peer/unlink", { body: {}, timeout: 8_000 }),
|
|
25
|
+
remove: deleteHost,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export interface UnlinkResult {
|
|
29
|
+
name: string;
|
|
30
|
+
/** The other machine confirmed it dropped its side. */
|
|
31
|
+
told: boolean;
|
|
32
|
+
/** The machine left Machines here too (it held no sessions or projects). */
|
|
33
|
+
removed: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A person here unlinks it. Throws when it isn't linked through Connect. */
|
|
37
|
+
export async function unlinkPeer(
|
|
38
|
+
hostId: string,
|
|
39
|
+
deps: UnlinkDeps = realDeps
|
|
40
|
+
): Promise<UnlinkResult> {
|
|
41
|
+
const link = hostLink(hostId);
|
|
42
|
+
if (!link?.connect || !pinForHost(hostId))
|
|
43
|
+
throw new Error("That machine isn't linked through Connect.");
|
|
44
|
+
dropPin(hostId, { byPeer: false, reason: UNLINKED_HERE });
|
|
45
|
+
let told = false;
|
|
46
|
+
// A suspended link is refused there too; nothing would arrive.
|
|
47
|
+
if (link.connect.state === "active")
|
|
48
|
+
try {
|
|
49
|
+
await deps.tell(link);
|
|
50
|
+
told = true;
|
|
51
|
+
} catch {
|
|
52
|
+
// Best effort: its next call here is refused and says why.
|
|
53
|
+
}
|
|
54
|
+
let removed = false;
|
|
55
|
+
try {
|
|
56
|
+
deps.remove(hostId);
|
|
57
|
+
removed = true;
|
|
58
|
+
} catch {
|
|
59
|
+
// It still holds sessions or projects; it stays, unlinked.
|
|
60
|
+
}
|
|
61
|
+
return { name: link.hostName, told, removed };
|
|
62
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one refusal that is signed: "unknown peer", sent to a machine that was
|
|
3
|
+
* unlinked here, bound to its own request's nonce. Only the key it had pinned
|
|
4
|
+
* for this machine verifies it, so whoever carries the traffic can't forge an
|
|
5
|
+
* unlink, and a replayed one never matches a new request.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import crypto, { type KeyObject } from "crypto";
|
|
9
|
+
|
|
10
|
+
export const UNLINKED_PROOF_HEADER = "x-agentos-peer-unlinked";
|
|
11
|
+
const DOMAIN = "agentos-peer-unlinked-v1";
|
|
12
|
+
|
|
13
|
+
interface Bound {
|
|
14
|
+
/** The machine refusing: the one that unlinked. */
|
|
15
|
+
from: string;
|
|
16
|
+
/** The caller, which it no longer knows. */
|
|
17
|
+
to: string;
|
|
18
|
+
/** The caller's request nonce, base64url. */
|
|
19
|
+
nonce: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const message = (o: Bound) =>
|
|
23
|
+
Buffer.from(`${DOMAIN}|${o.from}|${o.to}|${o.nonce}`);
|
|
24
|
+
|
|
25
|
+
export const signUnlinked = (signKey: KeyObject, o: Bound): string =>
|
|
26
|
+
crypto.sign(null, message(o), signKey).toString("base64url");
|
|
27
|
+
|
|
28
|
+
export function verifyUnlinked(
|
|
29
|
+
signPub: KeyObject,
|
|
30
|
+
o: Bound,
|
|
31
|
+
sig: string | null
|
|
32
|
+
): boolean {
|
|
33
|
+
if (!sig || !/^[A-Za-z0-9_-]{86}$/.test(sig)) return false;
|
|
34
|
+
try {
|
|
35
|
+
return crypto.verify(
|
|
36
|
+
null,
|
|
37
|
+
message(o),
|
|
38
|
+
signPub,
|
|
39
|
+
Buffer.from(sig, "base64url")
|
|
40
|
+
);
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
package/lib/projects.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
type DevServerType,
|
|
20
20
|
} from "./db";
|
|
21
21
|
import { resolveModelForAgent } from "./model-catalog";
|
|
22
|
+
import { clearForeignGroup } from "./session-groups/assignSessions";
|
|
22
23
|
import type { AgentType } from "./providers";
|
|
23
24
|
|
|
24
25
|
const execAsync = promisify(exec);
|
|
@@ -312,7 +313,7 @@ export function getProjectSessions(projectId: string): Session[] {
|
|
|
312
313
|
export function moveSessionToProject(
|
|
313
314
|
sessionId: string,
|
|
314
315
|
projectId: string
|
|
315
|
-
):
|
|
316
|
+
): { clearedGroup: string | null } {
|
|
316
317
|
const to = getProject(projectId);
|
|
317
318
|
const from = db
|
|
318
319
|
.prepare(
|
|
@@ -321,7 +322,7 @@ export function moveSessionToProject(
|
|
|
321
322
|
)
|
|
322
323
|
.get(sessionId) as
|
|
323
324
|
{ workspace_id: string | null; own: string | null } | undefined;
|
|
324
|
-
db.transaction(() => {
|
|
325
|
+
return db.transaction(() => {
|
|
325
326
|
queries.updateSessionProject(db).run(projectId, sessionId);
|
|
326
327
|
// A project's session follows the project; one left in none keeps the
|
|
327
328
|
// workspace it was in.
|
|
@@ -333,6 +334,8 @@ export function moveSessionToProject(
|
|
|
333
334
|
: (from?.workspace_id ?? from?.own ?? null),
|
|
334
335
|
sessionId
|
|
335
336
|
);
|
|
337
|
+
// A group belongs to the workspace the session just left.
|
|
338
|
+
return { clearedGroup: clearForeignGroup(sessionId) };
|
|
336
339
|
})();
|
|
337
340
|
}
|
|
338
341
|
|