@saadnvd1/agent-os 0.3.698 → 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/send/route.ts +11 -1
- package/app/api/bus/spawn/route.ts +12 -1
- package/app/api/bus/wait/route.ts +22 -0
- 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/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/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/account/route.ts +9 -0
- package/app/api/peers/gate.test.ts +174 -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.test.ts +37 -0
- package/app/api/peers/pending/route.ts +14 -0
- 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 +31 -13
- package/app/page.tsx +7 -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 +194 -0
- package/components/Hosts/HostRow.tsx +195 -45
- package/components/Hosts/HostsDialog.tsx +49 -37
- package/components/Hosts/IncomingLinkSheet.tsx +246 -0
- package/components/Hosts/LinkCodeSheet.tsx +186 -0
- package/components/Hosts/PeerActivitySheet.tsx +111 -0
- package/components/Hosts/sheet.ts +3 -0
- package/components/SessionList/GroupShelves.tsx +195 -0
- package/components/SessionList/MoveToGroupMenu.tsx +105 -0
- package/components/SessionList/RowSubtitle.tsx +18 -2
- 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/Welcome/ConnectBlock.tsx +1 -1
- 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/index.ts +2 -0
- package/data/peers/keys.ts +7 -0
- package/data/peers/queries.ts +172 -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/brief.ts +3 -1
- package/lib/agents/spawn.ts +4 -0
- package/lib/bus/format.ts +25 -4
- package/lib/bus/index.ts +121 -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/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/connect/control-api.ts +108 -2
- package/lib/connect/heartbeat.ts +11 -3
- package/lib/connect/serve.ts +2 -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/20261020T1600_bus_waits.ts +35 -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 +20 -8
- package/lib/doctor/checks.ts +64 -1
- package/lib/done/archive.ts +2 -0
- 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 +19 -1
- package/lib/orchestrator/facts.ts +22 -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 +270 -0
- package/lib/peer/allowlist.ts +1 -1
- package/lib/peer/cli.ts +24 -0
- package/lib/peer/client.ts +26 -1
- 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 +248 -0
- package/lib/peer/links.ts +117 -5
- package/lib/peer/machines.ts +84 -0
- package/lib/peer/name.ts +32 -0
- package/lib/peer/notify.ts +32 -0
- package/lib/peer/pairing-out.ts +382 -0
- package/lib/peer/pairing.ts +432 -0
- package/lib/peer/pin.ts +62 -0
- package/lib/peer/reasons.ts +7 -0
- package/lib/peer/revoke.ts +75 -0
- package/lib/peer/rpc-intake.ts +37 -8
- package/lib/peer/rpc-server.ts +72 -6
- package/lib/peer/unlink.ts +62 -0
- package/lib/peer/unlinked-proof.ts +44 -0
- package/lib/projects.ts +5 -2
- package/lib/schedules/when.ts +1 -1
- package/lib/security/auth.ts +10 -2
- package/lib/security/demo.ts +7 -0
- package/lib/security/gate.ts +3 -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/end.ts +3 -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 +3 -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 +5 -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 +6 -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;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What Machines shows under "On your AgentOS account": the account's other
|
|
3
|
+
* machines, each with whether it's linked here, and the plan.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { getDb } from "../db";
|
|
7
|
+
import { accountView } from "./account";
|
|
8
|
+
import type { Entitlements } from "../connect/control-api";
|
|
9
|
+
|
|
10
|
+
export interface AccountMachine {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
hostname: string;
|
|
14
|
+
online: boolean;
|
|
15
|
+
lastSeenAt: string | null;
|
|
16
|
+
version: string | null;
|
|
17
|
+
/** The Machines host it's linked as, when it is. */
|
|
18
|
+
hostId: string | null;
|
|
19
|
+
linkState: "active" | "suspended" | null;
|
|
20
|
+
suspendedReason: string | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface AccountMachines {
|
|
24
|
+
enrolled: boolean;
|
|
25
|
+
entitlements: Entitlements | null;
|
|
26
|
+
fetchedAt: number;
|
|
27
|
+
error: string | null;
|
|
28
|
+
/** Set when the service hasn't answered for a day: since when. */
|
|
29
|
+
uncheckedSince: number | null;
|
|
30
|
+
machines: AccountMachine[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The service shows a machine online for three missed beats.
|
|
34
|
+
const ONLINE_MS = 3 * 60_000;
|
|
35
|
+
|
|
36
|
+
export async function accountMachines(
|
|
37
|
+
refresh = false,
|
|
38
|
+
now = Date.now()
|
|
39
|
+
): Promise<AccountMachines> {
|
|
40
|
+
const view = await accountView(refresh ? 0 : undefined);
|
|
41
|
+
const pins = new Map(
|
|
42
|
+
(
|
|
43
|
+
getDb()
|
|
44
|
+
.prepare(
|
|
45
|
+
`SELECT p.machine_id, p.host_id, p.state, p.suspended_reason
|
|
46
|
+
FROM peer_links p
|
|
47
|
+
JOIN hosts h ON h.id = p.host_id WHERE h.kind = 'connect'`
|
|
48
|
+
)
|
|
49
|
+
.all() as {
|
|
50
|
+
machine_id: string;
|
|
51
|
+
host_id: string;
|
|
52
|
+
state: string;
|
|
53
|
+
suspended_reason: string | null;
|
|
54
|
+
}[]
|
|
55
|
+
).map((r) => [r.machine_id, r])
|
|
56
|
+
);
|
|
57
|
+
return {
|
|
58
|
+
enrolled: view.enrolled,
|
|
59
|
+
entitlements: view.entitlements,
|
|
60
|
+
fetchedAt: view.fetchedAt,
|
|
61
|
+
error: view.error,
|
|
62
|
+
uncheckedSince: view.uncheckedSince,
|
|
63
|
+
machines: view.peers.map((p) => {
|
|
64
|
+
const pin = pins.get(p.id);
|
|
65
|
+
const seen = p.lastSeenAt ? Date.parse(p.lastSeenAt) : NaN;
|
|
66
|
+
return {
|
|
67
|
+
id: p.id,
|
|
68
|
+
name: p.name,
|
|
69
|
+
hostname: p.hostname,
|
|
70
|
+
online: Number.isFinite(seen) && now - seen < ONLINE_MS,
|
|
71
|
+
lastSeenAt: p.lastSeenAt,
|
|
72
|
+
version: p.version,
|
|
73
|
+
hostId: pin?.host_id ?? null,
|
|
74
|
+
linkState: pin
|
|
75
|
+
? pin.state === "active"
|
|
76
|
+
? "active"
|
|
77
|
+
: "suspended"
|
|
78
|
+
: null,
|
|
79
|
+
suspendedReason:
|
|
80
|
+
pin && pin.state !== "active" ? pin.suspended_reason : null,
|
|
81
|
+
};
|
|
82
|
+
}),
|
|
83
|
+
};
|
|
84
|
+
}
|
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
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What pairing tells the person: a request waiting for its code here, and a
|
|
3
|
+
* machine linked. Pushed to paired phones (or Telegram); never throws.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { notifyPhone } from "../notify";
|
|
7
|
+
|
|
8
|
+
const short = (name: string) => name.replace(/\s+/g, " ").slice(0, 60);
|
|
9
|
+
|
|
10
|
+
export function notifyLinkRequest(name: string) {
|
|
11
|
+
notifyPhone(
|
|
12
|
+
"peer-link",
|
|
13
|
+
`${short(name)} wants to link to this machine. Type the code it shows in Machines to allow it.`,
|
|
14
|
+
{
|
|
15
|
+
category: "system",
|
|
16
|
+
key: `peer-link:${Date.now()}`,
|
|
17
|
+
collapse: "peer-link",
|
|
18
|
+
}
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function notifyLinked(name: string) {
|
|
23
|
+
notifyPhone(
|
|
24
|
+
"peer-link",
|
|
25
|
+
`New machine linked: ${short(name)}. It can start agents and run code here; unlink it in Machines.`,
|
|
26
|
+
{
|
|
27
|
+
category: "system",
|
|
28
|
+
key: `peer-linked:${Date.now()}`,
|
|
29
|
+
collapse: "peer-link",
|
|
30
|
+
}
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pairing, on the machine that asks (A): send the hello to the machine the
|
|
3
|
+
* account lists, check its answer is signed by the key the account lists
|
|
4
|
+
* for it, reveal, show the code, then ask every couple of seconds until a
|
|
5
|
+
* person on the other machine has typed it. B then shows a second code, and
|
|
6
|
+
* only that code typed here pins B: the account list alone never does, so a
|
|
7
|
+
* control plane that lists a key of its own for B can't get itself pinned.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readCappedBytes } from "../hosts/read-capped-bytes";
|
|
11
|
+
import { peerHostAllowed } from "./client";
|
|
12
|
+
import { linkingAllowed, listedPeer, type AccountPeer } from "./account";
|
|
13
|
+
import {
|
|
14
|
+
HELLO_MAX_BYTES,
|
|
15
|
+
commitTo,
|
|
16
|
+
deriveCode,
|
|
17
|
+
parseAck,
|
|
18
|
+
parseAnswer,
|
|
19
|
+
randomNonce,
|
|
20
|
+
sameCode,
|
|
21
|
+
signHello,
|
|
22
|
+
signReveal,
|
|
23
|
+
signStatusAsk,
|
|
24
|
+
typedCode,
|
|
25
|
+
verifyAck,
|
|
26
|
+
verifyAnswer,
|
|
27
|
+
type LinkState,
|
|
28
|
+
} from "./hello";
|
|
29
|
+
import { currentIdentity, loadBoxKey, type Identity } from "./keys";
|
|
30
|
+
import { notifyLinked } from "./notify";
|
|
31
|
+
import { MAX_WRONG, REQUEST_TTL_MS } from "./pairing";
|
|
32
|
+
import { KEY_CHANGED, keysChanged, pinPeer } from "./pin";
|
|
33
|
+
|
|
34
|
+
/** `confirm`: B took the code and shows its own, to be typed here. */
|
|
35
|
+
export type OutState = LinkState | "confirm" | "failed";
|
|
36
|
+
|
|
37
|
+
export interface Outgoing {
|
|
38
|
+
id: string;
|
|
39
|
+
machineId: string;
|
|
40
|
+
name: string;
|
|
41
|
+
code: string;
|
|
42
|
+
state: OutState;
|
|
43
|
+
error: string | null;
|
|
44
|
+
hostId: string | null;
|
|
45
|
+
attemptsLeft: number;
|
|
46
|
+
startedAt: number;
|
|
47
|
+
expiresAt: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface Live extends Outgoing {
|
|
51
|
+
peer: AccountPeer;
|
|
52
|
+
requestId: string;
|
|
53
|
+
bBox: string;
|
|
54
|
+
confirmCode: string;
|
|
55
|
+
timer: NodeJS.Timeout | null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type Post = (
|
|
59
|
+
hostname: string,
|
|
60
|
+
path: string,
|
|
61
|
+
body: object
|
|
62
|
+
) => Promise<{ status: number; json: unknown }>;
|
|
63
|
+
|
|
64
|
+
export interface OutDeps {
|
|
65
|
+
identity: () => Identity | null;
|
|
66
|
+
boxPub: () => string;
|
|
67
|
+
listed: (machineId: string) => Promise<AccountPeer | null>;
|
|
68
|
+
allowed: () => Promise<boolean | null>;
|
|
69
|
+
post: Post;
|
|
70
|
+
pin: typeof pinPeer;
|
|
71
|
+
onLinked: (name: string) => void;
|
|
72
|
+
now: () => number;
|
|
73
|
+
pollMs: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** POSTs JSON to the other machine's Connect name: no redirects, small answers. */
|
|
77
|
+
export const httpsPost: Post = async (hostname, path, body) => {
|
|
78
|
+
if (!peerHostAllowed(hostname))
|
|
79
|
+
throw new Error("its address isn't a Connect machine's");
|
|
80
|
+
const res = await fetch(`https://${hostname}${path}`, {
|
|
81
|
+
method: "POST",
|
|
82
|
+
headers: { "Content-Type": "application/json" },
|
|
83
|
+
body: JSON.stringify(body),
|
|
84
|
+
redirect: "error",
|
|
85
|
+
signal: AbortSignal.timeout(15_000),
|
|
86
|
+
});
|
|
87
|
+
const raw = await readCappedBytes(res, HELLO_MAX_BYTES);
|
|
88
|
+
let json: unknown = null;
|
|
89
|
+
try {
|
|
90
|
+
json = JSON.parse(raw.toString("utf8"));
|
|
91
|
+
} catch {
|
|
92
|
+
// not JSON: only the status says anything
|
|
93
|
+
}
|
|
94
|
+
return { status: res.status, json };
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
let box: string | null = null;
|
|
98
|
+
export const realOutDeps = (): OutDeps => ({
|
|
99
|
+
identity: () => currentIdentity(),
|
|
100
|
+
boxPub: () => (box ??= loadBoxKey().publicRaw),
|
|
101
|
+
listed: listedPeer,
|
|
102
|
+
allowed: linkingAllowed,
|
|
103
|
+
post: httpsPost,
|
|
104
|
+
pin: pinPeer,
|
|
105
|
+
onLinked: notifyLinked,
|
|
106
|
+
now: Date.now,
|
|
107
|
+
pollMs: 2000,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const g = globalThis as unknown as { __agentosPairingOut?: Map<string, Live> };
|
|
111
|
+
const links = () => (g.__agentosPairingOut ??= new Map());
|
|
112
|
+
|
|
113
|
+
export const resetOutgoing = () => {
|
|
114
|
+
for (const l of links().values()) if (l.timer) clearTimeout(l.timer);
|
|
115
|
+
g.__agentosPairingOut = undefined;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const view = (l: Live): Outgoing => ({
|
|
119
|
+
id: l.id,
|
|
120
|
+
machineId: l.machineId,
|
|
121
|
+
name: l.name,
|
|
122
|
+
code: l.code,
|
|
123
|
+
state: l.state,
|
|
124
|
+
error: l.error,
|
|
125
|
+
hostId: l.hostId,
|
|
126
|
+
attemptsLeft: l.attemptsLeft,
|
|
127
|
+
startedAt: l.startedAt,
|
|
128
|
+
expiresAt: l.expiresAt,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
export class LinkError extends Error {
|
|
132
|
+
constructor(
|
|
133
|
+
message: string,
|
|
134
|
+
readonly status = 400
|
|
135
|
+
) {
|
|
136
|
+
super(message);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const theirError = (json: unknown, status: number) => {
|
|
141
|
+
const e = (json as { error?: unknown } | null)?.error;
|
|
142
|
+
return typeof e === "string"
|
|
143
|
+
? e.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").slice(0, 200)
|
|
144
|
+
: `HTTP ${status}`;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/** Starts a link to an account machine; the answer carries the code to show. */
|
|
148
|
+
export async function startLink(
|
|
149
|
+
machineId: string,
|
|
150
|
+
deps: OutDeps = realOutDeps()
|
|
151
|
+
): Promise<Outgoing> {
|
|
152
|
+
const self = deps.identity();
|
|
153
|
+
if (!self)
|
|
154
|
+
throw new LinkError(
|
|
155
|
+
"This machine isn't connected to AgentOS Connect.",
|
|
156
|
+
409
|
|
157
|
+
);
|
|
158
|
+
if ((await deps.allowed()) !== true)
|
|
159
|
+
throw new LinkError("Linking machines needs AgentOS Connect Pro.", 402);
|
|
160
|
+
const peer = await deps.listed(machineId);
|
|
161
|
+
if (!peer)
|
|
162
|
+
throw new LinkError("That machine isn't on your AgentOS account.", 404);
|
|
163
|
+
if (!peerHostAllowed(peer.hostname))
|
|
164
|
+
throw new LinkError("Its address isn't a Connect machine's.", 502);
|
|
165
|
+
if (keysChanged(peer.id, peer)) throw new LinkError(KEY_CHANGED, 409);
|
|
166
|
+
for (const l of links().values())
|
|
167
|
+
if (l.machineId === machineId && open(l))
|
|
168
|
+
stop(l, "declined", "replaced by a new request");
|
|
169
|
+
|
|
170
|
+
const aNonce = randomNonce(32);
|
|
171
|
+
const aBox = deps.boxPub();
|
|
172
|
+
const hello = signHello(self.signKey, {
|
|
173
|
+
from: self.machineId,
|
|
174
|
+
to: peer.id,
|
|
175
|
+
ts: deps.now(),
|
|
176
|
+
nonce: randomNonce(16),
|
|
177
|
+
sign_pub: self.signPub,
|
|
178
|
+
box_pub: aBox,
|
|
179
|
+
commit: commitTo(aBox, self.signPub, aNonce),
|
|
180
|
+
});
|
|
181
|
+
const send = async (path: string, body: object) => {
|
|
182
|
+
try {
|
|
183
|
+
return await deps.post(peer.hostname, path, body);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
throw new LinkError(
|
|
186
|
+
`Can't reach ${peer.name}: ${err instanceof Error ? err.message : String(err)}`,
|
|
187
|
+
502
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
const helloRes = await send("/api/peer/hello", hello);
|
|
192
|
+
if (helloRes.status !== 200)
|
|
193
|
+
throw new LinkError(
|
|
194
|
+
`${peer.name} said no: ${theirError(helloRes.json, helloRes.status)}`,
|
|
195
|
+
helloRes.status === 402 ? 402 : 502
|
|
196
|
+
);
|
|
197
|
+
const ack = parseAck(helloRes.json);
|
|
198
|
+
// Signed by the key the account lists for it, answering this hello.
|
|
199
|
+
if (!ack || !verifyAck(ack, hello, peer.signPub))
|
|
200
|
+
throw new LinkError(`${peer.name} answered with the wrong key.`, 502);
|
|
201
|
+
if (keysChanged(peer.id, { signPub: ack.sign_pub, boxPub: ack.box_pub }))
|
|
202
|
+
throw new LinkError(KEY_CHANGED, 409);
|
|
203
|
+
|
|
204
|
+
const reveal = signReveal(self.signKey, {
|
|
205
|
+
from: self.machineId,
|
|
206
|
+
to: peer.id,
|
|
207
|
+
ts: deps.now(),
|
|
208
|
+
nonce: randomNonce(16),
|
|
209
|
+
request_id: ack.request_id,
|
|
210
|
+
a_nonce: aNonce,
|
|
211
|
+
});
|
|
212
|
+
const revealRes = await send("/api/peer/hello/reveal", reveal);
|
|
213
|
+
const answer = parseAnswer(revealRes.json);
|
|
214
|
+
if (
|
|
215
|
+
revealRes.status !== 200 ||
|
|
216
|
+
!answer ||
|
|
217
|
+
!verifyAnswer(answer, reveal, peer.signPub) ||
|
|
218
|
+
answer.state !== "pending"
|
|
219
|
+
)
|
|
220
|
+
throw new LinkError(
|
|
221
|
+
`${peer.name} didn't take the request: ${theirError(revealRes.json, revealRes.status)}`,
|
|
222
|
+
502
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
const now = deps.now();
|
|
226
|
+
const transcript = {
|
|
227
|
+
from: self.machineId,
|
|
228
|
+
to: peer.id,
|
|
229
|
+
aSign: self.signPub,
|
|
230
|
+
aBox,
|
|
231
|
+
bSign: ack.sign_pub,
|
|
232
|
+
bBox: ack.box_pub,
|
|
233
|
+
aNonce,
|
|
234
|
+
bNonce: ack.b_nonce,
|
|
235
|
+
};
|
|
236
|
+
const live: Live = {
|
|
237
|
+
id: randomNonce(16),
|
|
238
|
+
machineId: peer.id,
|
|
239
|
+
name: peer.name,
|
|
240
|
+
code: deriveCode(transcript),
|
|
241
|
+
state: "pending",
|
|
242
|
+
error: null,
|
|
243
|
+
hostId: null,
|
|
244
|
+
attemptsLeft: MAX_WRONG,
|
|
245
|
+
startedAt: now,
|
|
246
|
+
expiresAt: now + REQUEST_TTL_MS,
|
|
247
|
+
peer,
|
|
248
|
+
requestId: ack.request_id,
|
|
249
|
+
bBox: ack.box_pub,
|
|
250
|
+
confirmCode: deriveCode(transcript, "confirm"),
|
|
251
|
+
timer: null,
|
|
252
|
+
};
|
|
253
|
+
links().set(live.id, live);
|
|
254
|
+
schedule(live, self, deps);
|
|
255
|
+
return view(live);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const open = (l: Live) => l.state === "pending" || l.state === "confirm";
|
|
259
|
+
|
|
260
|
+
function stop(l: Live, state: OutState, error: string | null = null) {
|
|
261
|
+
if (l.timer) clearTimeout(l.timer);
|
|
262
|
+
l.timer = null;
|
|
263
|
+
l.state = state;
|
|
264
|
+
l.error = error;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function schedule(l: Live, self: Identity, deps: OutDeps) {
|
|
268
|
+
l.timer = setTimeout(() => void poll(l, self, deps), deps.pollMs);
|
|
269
|
+
l.timer.unref?.();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** One status call; exported for tests that drive the clock. */
|
|
273
|
+
export async function poll(l: Live, self: Identity, deps: OutDeps) {
|
|
274
|
+
l.timer = null;
|
|
275
|
+
if (l.state !== "pending") return;
|
|
276
|
+
if (deps.now() > l.expiresAt) return stop(l, "expired");
|
|
277
|
+
const ask = signStatusAsk(self.signKey, {
|
|
278
|
+
from: self.machineId,
|
|
279
|
+
to: l.machineId,
|
|
280
|
+
ts: deps.now(),
|
|
281
|
+
nonce: randomNonce(16),
|
|
282
|
+
request_id: l.requestId,
|
|
283
|
+
});
|
|
284
|
+
let answer = null;
|
|
285
|
+
try {
|
|
286
|
+
const res = await deps.post(l.peer.hostname, "/api/peer/hello/status", ask);
|
|
287
|
+
if (res.status === 404) return stop(l, "expired");
|
|
288
|
+
const a = parseAnswer(res.json);
|
|
289
|
+
if (res.status === 200 && a && verifyAnswer(a, ask, l.peer.signPub))
|
|
290
|
+
answer = a;
|
|
291
|
+
else l.error = `unexpected answer (HTTP ${res.status})`;
|
|
292
|
+
} catch (err) {
|
|
293
|
+
// Offline for a moment: keep asking until the request expires.
|
|
294
|
+
l.error = err instanceof Error ? err.message : String(err);
|
|
295
|
+
}
|
|
296
|
+
if (l.state !== "pending") return;
|
|
297
|
+
if (answer?.state === "accepted") {
|
|
298
|
+
// Nothing is pinned yet: B's say-so is only as good as its key, and the
|
|
299
|
+
// key came from the account list. The person types B's code here next.
|
|
300
|
+
stop(l, "confirm");
|
|
301
|
+
l.expiresAt = deps.now() + REQUEST_TTL_MS;
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (answer && answer.state !== "pending") return stop(l, answer.state);
|
|
305
|
+
if (answer) l.error = null;
|
|
306
|
+
schedule(l, self, deps);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function outgoingLink(id: string): Outgoing | null {
|
|
310
|
+
const l = links().get(id);
|
|
311
|
+
return l ? view(l) : null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export type ConfirmResult =
|
|
315
|
+
| { ok: true; link: Outgoing }
|
|
316
|
+
| { ok: false; status: number; error: string; attemptsLeft?: number };
|
|
317
|
+
|
|
318
|
+
/** The code the other machine shows once it took ours, typed here. */
|
|
319
|
+
export function confirmLink(
|
|
320
|
+
id: string,
|
|
321
|
+
input: unknown,
|
|
322
|
+
deps: OutDeps = realOutDeps()
|
|
323
|
+
): ConfirmResult {
|
|
324
|
+
const l = links().get(id);
|
|
325
|
+
if (l?.state === "confirm" && deps.now() > l.expiresAt) stop(l, "expired");
|
|
326
|
+
if (!l || l.state !== "confirm")
|
|
327
|
+
return {
|
|
328
|
+
ok: false,
|
|
329
|
+
status: 404,
|
|
330
|
+
error: "That request has ended. Start again.",
|
|
331
|
+
};
|
|
332
|
+
const code = typedCode(input);
|
|
333
|
+
if (!code)
|
|
334
|
+
return {
|
|
335
|
+
ok: false,
|
|
336
|
+
status: 400,
|
|
337
|
+
error: `Type the 6 digits ${l.name} shows.`,
|
|
338
|
+
};
|
|
339
|
+
if (!sameCode(code, l.confirmCode)) {
|
|
340
|
+
l.attemptsLeft -= 1;
|
|
341
|
+
if (l.attemptsLeft <= 0) {
|
|
342
|
+
stop(l, "failed", "wrong code 3 times");
|
|
343
|
+
return {
|
|
344
|
+
ok: false,
|
|
345
|
+
status: 403,
|
|
346
|
+
error: "Wrong code 3 times: nothing was linked here. Start again.",
|
|
347
|
+
attemptsLeft: 0,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
return {
|
|
351
|
+
ok: false,
|
|
352
|
+
status: 403,
|
|
353
|
+
error: "That code doesn't match.",
|
|
354
|
+
attemptsLeft: l.attemptsLeft,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
try {
|
|
358
|
+
l.hostId = deps.pin({
|
|
359
|
+
id: l.peer.id,
|
|
360
|
+
name: l.peer.name,
|
|
361
|
+
hostname: l.peer.hostname,
|
|
362
|
+
signPub: l.peer.signPub,
|
|
363
|
+
boxPub: l.bBox,
|
|
364
|
+
});
|
|
365
|
+
} catch (err) {
|
|
366
|
+
stop(l, "failed", err instanceof Error ? err.message : String(err));
|
|
367
|
+
return { ok: false, status: 409, error: l.error! };
|
|
368
|
+
}
|
|
369
|
+
stop(l, "accepted");
|
|
370
|
+
deps.onLinked(l.name);
|
|
371
|
+
return { ok: true, link: view(l) };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export function cancelLink(id: string): boolean {
|
|
375
|
+
const l = links().get(id);
|
|
376
|
+
if (!l || !open(l)) return false;
|
|
377
|
+
stop(l, "declined", "cancelled here");
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Tests: the live record behind a view, to drive poll() by hand. */
|
|
382
|
+
export const _live = (id: string) => links().get(id) ?? null;
|