@saadnvd1/agent-os 0.3.700 → 0.3.723
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 +2 -0
- package/app/api/agent-roles/route.test.ts +67 -0
- package/app/api/agent-roles/route.ts +61 -0
- package/app/api/analyst/[id]/recommendations/[recId]/again/route.ts +15 -0
- package/app/api/analyst/[id]/recommendations/[recId]/dismiss/route.ts +13 -0
- package/app/api/analyst/[id]/recommendations/[recId]/send/route.ts +13 -0
- package/app/api/analyst/[id]/review/route.ts +22 -0
- package/app/api/analyst/[id]/route.ts +44 -0
- package/app/api/analyst/route.ts +10 -0
- package/app/api/bus/groups/route.ts +44 -0
- package/app/api/bus/spawn/route.ts +34 -2
- 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/models/availability/route.ts +9 -0
- package/app/api/models/choices/route.ts +9 -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/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]/analyst/route.ts +11 -0
- package/app/api/sessions/[id]/crew/route.ts +38 -0
- package/app/api/sessions/[id]/export/estimate/route.ts +23 -0
- package/app/api/sessions/[id]/export/route.test.ts +125 -0
- package/app/api/sessions/[id]/export/route.ts +34 -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]/model/route.ts +49 -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 -14
- package/app/page.tsx +10 -0
- package/components/Chat/Messages.tsx +104 -10
- 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/DraftChips.tsx +6 -1
- package/components/Draft/DraftPanel.tsx +90 -3
- package/components/Draft/ModelUnavailableDialog.tsx +78 -0
- package/components/Draft/RoleChips.tsx +110 -0
- package/components/Export/ExportActions.tsx +136 -0
- package/components/Export/ExportSheet.tsx +217 -0
- package/components/Export/RangePicker.tsx +144 -0
- package/components/Export/Seg.tsx +38 -0
- package/components/Export/TurnSelectBar.tsx +45 -0
- 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/Models/AgentRoleCard.tsx +114 -0
- package/components/Models/AgentRolesSection.tsx +132 -0
- package/components/Models/ModelBadges.tsx +48 -0
- package/components/Models/ModelsSection.tsx +4 -2
- package/components/Models/RoleModelSelect.tsx +87 -0
- package/components/Pane/DesktopTabBar.tsx +2 -0
- package/components/Pane/MobileTabBar.tsx +12 -0
- package/components/Pane/PaneMenu.tsx +10 -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 +19 -7
- package/components/SessionList/SessionRowMenu.tsx +23 -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/Sessions/RolePill.tsx +196 -0
- package/components/Settings/SettingsDialog.tsx +9 -0
- package/components/Share/ShareDialog.tsx +13 -0
- package/components/ui/notice.tsx +46 -0
- package/components/ui/status-pill.tsx +39 -0
- package/data/analyst/index.ts +3 -0
- package/data/analyst/keys.ts +6 -0
- package/data/analyst/mutations.ts +116 -0
- package/data/analyst/queries.ts +35 -0
- package/data/claims/index.ts +2 -0
- package/data/claims/keys.ts +5 -0
- package/data/claims/queries.ts +47 -0
- package/data/exports/index.ts +2 -0
- package/data/exports/keys.ts +3 -0
- package/data/exports/queries.ts +51 -0
- package/data/models/index.ts +98 -0
- package/data/peers/keys.ts +1 -0
- package/data/peers/queries.ts +31 -0
- package/data/push/topics.ts +9 -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 +36 -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 +1 -0
- package/lib/agents/spawn.ts +4 -0
- package/lib/analyst/__fixtures__/items.ts +57 -0
- package/lib/analyst/__fixtures__/mocks.ts +49 -0
- package/lib/analyst/brief.ts +17 -0
- package/lib/analyst/buildDigest.ts +204 -0
- package/lib/analyst/createLink.ts +98 -0
- package/lib/analyst/endLink.ts +31 -0
- package/lib/analyst/forward.ts +109 -0
- package/lib/analyst/index.ts +16 -0
- package/lib/analyst/linkView.ts +38 -0
- package/lib/analyst/onTurnEnd.ts +119 -0
- package/lib/analyst/parseReview.ts +41 -0
- package/lib/analyst/pushDigest.ts +143 -0
- package/lib/analyst/resume.ts +35 -0
- package/lib/analyst/route.ts +50 -0
- package/lib/analyst/signals.ts +121 -0
- package/lib/analyst/store.ts +303 -0
- package/lib/analyst/types.ts +140 -0
- package/lib/bus/condense.ts +5 -1
- package/lib/bus/index.ts +13 -0
- package/lib/bus/read-scope.ts +12 -0
- package/lib/bus/role-alias.ts +70 -0
- package/lib/bus/targets.ts +3 -0
- package/lib/chat/drivers/opencode-mapper.ts +55 -1
- package/lib/chat/drivers/pi-approvals.ts +7 -4
- package/lib/chat/drivers/pi-mapper.ts +38 -0
- package/lib/chat/drivers/pi-text-tools.ts +104 -0
- package/lib/chat/drivers/pi.ts +8 -3
- package/lib/chat/drivers/turn-items.ts +36 -11
- package/lib/chat/drivers/turn-outcome.ts +108 -0
- package/lib/chat/events.ts +8 -0
- package/lib/chat/registry.ts +13 -0
- package/lib/chat/store.ts +19 -0
- package/lib/chat/turns.ts +1 -0
- package/lib/chat/worker/extras.ts +4 -2
- package/lib/claims/collectLocal.ts +123 -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 +12 -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/migrations/20261021T1400_session_agent_roles.ts +20 -0
- package/lib/db/migrations/20261021T1430_analyst_links.ts +52 -0
- package/lib/db/queries.ts +0 -40
- package/lib/db/session-parts/columns.ts +3 -0
- package/lib/db/types.ts +19 -8
- package/lib/doctor/checks.ts +64 -1
- package/lib/done/archive.ts +2 -0
- package/lib/drafts.ts +4 -0
- package/lib/export/build.ts +470 -0
- package/lib/export/compact.ts +156 -0
- package/lib/export/estimate.ts +83 -0
- package/lib/export/fixtures.ts +45 -0
- package/lib/export/html.tsx +195 -0
- package/lib/export/json.ts +39 -0
- package/lib/export/load.ts +64 -0
- package/lib/export/markdown.ts +127 -0
- package/lib/export/request.ts +36 -0
- package/lib/export/service.ts +142 -0
- package/lib/export/summary.ts +86 -0
- package/lib/export/turns.ts +63 -0
- package/lib/export/view.ts +173 -0
- package/lib/git-remote.ts +43 -0
- package/lib/hosts/index.ts +10 -1
- package/lib/models/agent-binding.ts +102 -0
- package/lib/models/agent-roles-view.ts +157 -0
- package/lib/models/agent-roles.ts +87 -0
- package/lib/models/availability-refs.ts +17 -0
- package/lib/models/availability.ts +91 -0
- package/lib/models/catalog.ts +156 -0
- package/lib/models/config.ts +27 -0
- package/lib/models/locality.ts +33 -0
- package/lib/models/providers.ts +11 -0
- package/lib/models/resolve.ts +50 -12
- package/lib/models/roles.ts +12 -0
- package/lib/needs-you.ts +1 -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 +12 -2
- package/lib/orchestrator/read.ts +5 -1
- 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/untrusted.ts +5 -0
- 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 +6 -2
- package/lib/security/demo.ts +7 -0
- package/lib/session-artifacts/agentos-files.ts +94 -0
- package/lib/session-artifacts/collect.ts +17 -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/crew.ts +72 -0
- package/lib/sessions/launch.ts +54 -11
- package/lib/settings-url.ts +1 -0
- package/lib/share/markdown.ts +2 -2
- package/lib/share/redact.ts +16 -10
- package/lib/share/render.tsx +1 -1
- package/lib/share/snapshot.ts +2 -2
- package/lib/sidebar/rollup.ts +1 -0
- package/lib/sidebar/row-menu.ts +8 -1
- 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 +11 -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-deps/caches.ts +3 -2
- package/lib/worktree-deps/clone.ts +3 -1
- package/lib/worktree-deps/paths.ts +26 -0
- 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 +12 -1
- package/stores/claimsUi.ts +22 -0
- package/stores/exportUi.ts +87 -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/account.ts
CHANGED
|
@@ -19,6 +19,9 @@ import {
|
|
|
19
19
|
} from "../connect/control-api";
|
|
20
20
|
import { peerHostAllowed } from "./client";
|
|
21
21
|
import { rawPublic } from "./keys";
|
|
22
|
+
import { cleanMachineName } from "./name";
|
|
23
|
+
import { reconcilePins, type AccountSays } from "./revoke";
|
|
24
|
+
import { getSetting, putSetting } from "../settings-store";
|
|
22
25
|
|
|
23
26
|
export interface AccountPeer {
|
|
24
27
|
id: string;
|
|
@@ -37,9 +40,15 @@ export interface AccountView {
|
|
|
37
40
|
/** When the list was last fetched, 0 if never. */
|
|
38
41
|
fetchedAt: number;
|
|
39
42
|
error: string | null;
|
|
43
|
+
/** Set when the service hasn't answered for a day: since when. */
|
|
44
|
+
uncheckedSince: number | null;
|
|
40
45
|
}
|
|
41
46
|
|
|
42
47
|
export const LIST_MAX_AGE_MS = 60_000;
|
|
48
|
+
export const UNCHECKED_NOTICE_MS = 24 * 3_600_000;
|
|
49
|
+
const OK_KEY = "peer.account_ok_at";
|
|
50
|
+
// The last answer is written down at most this often (it survives restarts).
|
|
51
|
+
const OK_SAVE_MS = 10 * 60_000;
|
|
43
52
|
// Never more often than this, whoever asks.
|
|
44
53
|
const MIN_GAP_MS = 5_000;
|
|
45
54
|
|
|
@@ -52,6 +61,8 @@ interface State {
|
|
|
52
61
|
stale: boolean;
|
|
53
62
|
error: string | null;
|
|
54
63
|
inflight: Promise<void> | null;
|
|
64
|
+
okAt: number;
|
|
65
|
+
okSavedAt: number;
|
|
55
66
|
}
|
|
56
67
|
|
|
57
68
|
export type Fetcher = () => Promise<PeerList | null>;
|
|
@@ -70,6 +81,8 @@ const fresh = (): State => ({
|
|
|
70
81
|
stale: false,
|
|
71
82
|
error: null,
|
|
72
83
|
inflight: null,
|
|
84
|
+
okAt: 0,
|
|
85
|
+
okSavedAt: 0,
|
|
73
86
|
});
|
|
74
87
|
|
|
75
88
|
const state = () => (g.__agentosPeerAccount ??= fresh());
|
|
@@ -97,6 +110,42 @@ export function rawFromSpki(pem: string): string | null {
|
|
|
97
110
|
}
|
|
98
111
|
}
|
|
99
112
|
|
|
113
|
+
/** The service answered: links can be checked against it again. */
|
|
114
|
+
export function noteAccountOk(now = Date.now()) {
|
|
115
|
+
const s = state();
|
|
116
|
+
s.okAt = now;
|
|
117
|
+
if (now - s.okSavedAt < OK_SAVE_MS) return;
|
|
118
|
+
s.okSavedAt = now;
|
|
119
|
+
try {
|
|
120
|
+
putSetting(OK_KEY, String(now));
|
|
121
|
+
} catch {
|
|
122
|
+
// Bookkeeping only.
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** When the service last answered; a first look starts the clock. */
|
|
127
|
+
function lastOk(now: number): number {
|
|
128
|
+
const s = state();
|
|
129
|
+
if (s.okAt) return s.okAt;
|
|
130
|
+
let saved = NaN;
|
|
131
|
+
try {
|
|
132
|
+
saved = Number(getSetting(OK_KEY));
|
|
133
|
+
} catch {
|
|
134
|
+
// No database: count from now.
|
|
135
|
+
}
|
|
136
|
+
if (Number.isFinite(saved) && saved > 0) return (s.okAt = saved);
|
|
137
|
+
noteAccountOk(now);
|
|
138
|
+
return now;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function applyToPins(says: AccountSays) {
|
|
142
|
+
try {
|
|
143
|
+
reconcilePins(says);
|
|
144
|
+
} catch (err) {
|
|
145
|
+
console.error("peer revoke:", err);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
100
149
|
function adopt(list: PeerList) {
|
|
101
150
|
const s = state();
|
|
102
151
|
const peers: AccountPeer[] = [];
|
|
@@ -107,7 +156,7 @@ function adopt(list: PeerList) {
|
|
|
107
156
|
if (!signPub || !peerHostAllowed(m.hostname)) continue;
|
|
108
157
|
peers.push({
|
|
109
158
|
id: m.id,
|
|
110
|
-
name: m.name,
|
|
159
|
+
name: cleanMachineName(m.name),
|
|
111
160
|
hostname: m.hostname,
|
|
112
161
|
signPub,
|
|
113
162
|
lastSeenAt: m.lastSeenAt,
|
|
@@ -120,6 +169,11 @@ function adopt(list: PeerList) {
|
|
|
120
169
|
s.fetchedAt = Date.now();
|
|
121
170
|
s.stale = false;
|
|
122
171
|
s.error = null;
|
|
172
|
+
noteAccountOk(s.fetchedAt);
|
|
173
|
+
applyToPins({
|
|
174
|
+
linkMachines: s.entitlements?.linkMachines ?? null,
|
|
175
|
+
listed: new Map(peers.map((p) => [p.id, p.signPub])),
|
|
176
|
+
});
|
|
123
177
|
}
|
|
124
178
|
|
|
125
179
|
async function refresh(): Promise<void> {
|
|
@@ -161,15 +215,19 @@ export async function accountView(
|
|
|
161
215
|
peers: [],
|
|
162
216
|
fetchedAt: 0,
|
|
163
217
|
error: null,
|
|
218
|
+
uncheckedSince: null,
|
|
164
219
|
};
|
|
165
220
|
const s = state();
|
|
166
221
|
if (s.stale || Date.now() - s.fetchedAt > maxAgeMs) await refresh();
|
|
222
|
+
const now = Date.now();
|
|
223
|
+
const ok = lastOk(now);
|
|
167
224
|
return {
|
|
168
225
|
enrolled: true,
|
|
169
226
|
entitlements: s.entitlements,
|
|
170
227
|
peers: s.peers,
|
|
171
228
|
fetchedAt: s.fetchedAt,
|
|
172
229
|
error: s.error,
|
|
230
|
+
uncheckedSince: now - ok > UNCHECKED_NOTICE_MS ? ok : null,
|
|
173
231
|
};
|
|
174
232
|
}
|
|
175
233
|
|
|
@@ -187,12 +245,26 @@ export async function linkingAllowed(): Promise<boolean | null> {
|
|
|
187
245
|
return entitlements ? entitlements.linkMachines : null;
|
|
188
246
|
}
|
|
189
247
|
|
|
190
|
-
/**
|
|
248
|
+
/**
|
|
249
|
+
* What a heartbeat says: the service is there, the plan now, and whether the
|
|
250
|
+
* list changed, which is fetched again at once so a machine removed from the
|
|
251
|
+
* account is suspended within a beat or so.
|
|
252
|
+
*/
|
|
191
253
|
export function noteHeartbeat(beat: Beat) {
|
|
192
254
|
const s = state();
|
|
255
|
+
noteAccountOk();
|
|
256
|
+
const plan = beat.entitlements?.linkMachines;
|
|
257
|
+
const planChanged =
|
|
258
|
+
plan !== undefined && plan !== s.entitlements?.linkMachines;
|
|
193
259
|
if (beat.entitlements) s.entitlements = beat.entitlements;
|
|
194
260
|
if (beat.peersEtag && beat.peersEtag !== s.etag) {
|
|
195
261
|
s.etag = beat.peersEtag;
|
|
196
262
|
s.stale = true;
|
|
197
263
|
}
|
|
264
|
+
// A downgrade suspends now. An upgrade waits for a fresh list, so a pin
|
|
265
|
+
// whose machine left the account meanwhile isn't resumed on the way.
|
|
266
|
+
if (planChanged && plan === false)
|
|
267
|
+
applyToPins({ linkMachines: false, listed: null });
|
|
268
|
+
else if (planChanged) s.stale = true;
|
|
269
|
+
if (s.stale && enrolled()) void refresh();
|
|
198
270
|
}
|
package/lib/peer/allowlist.ts
CHANGED
|
@@ -67,7 +67,7 @@ export const PEER_ROUTES: readonly PeerRoute[] = [
|
|
|
67
67
|
{ method: "POST", pattern: "/api/peer/unlink" },
|
|
68
68
|
{ method: "GET", pattern: "/api/peer/ping" },
|
|
69
69
|
|
|
70
|
-
// The work registry's exchange
|
|
70
|
+
// The work registry's exchange.
|
|
71
71
|
{ method: "POST", pattern: "/api/claims/exchange" },
|
|
72
72
|
];
|
|
73
73
|
|
package/lib/peer/client.ts
CHANGED
|
@@ -15,7 +15,8 @@ import {
|
|
|
15
15
|
sealRequest,
|
|
16
16
|
} from "./envelope";
|
|
17
17
|
import { boxPublic, currentIdentity, signPublic, type Identity } from "./keys";
|
|
18
|
-
import { notePinResult } from "./links";
|
|
18
|
+
import { notePinResult, unlinkedByPeer } from "./links";
|
|
19
|
+
import { UNLINKED_PROOF_HEADER, verifyUnlinked } from "./unlinked-proof";
|
|
19
20
|
import type { HostLink } from "../hosts/remote-api";
|
|
20
21
|
import { readCappedBytes } from "../hosts/read-capped-bytes";
|
|
21
22
|
|
|
@@ -67,12 +68,15 @@ export interface ClientDeps {
|
|
|
67
68
|
identity: () => Identity | null;
|
|
68
69
|
audit: (e: AuditEntry) => void;
|
|
69
70
|
noteResult: (hostId: string, error: string | null) => void;
|
|
71
|
+
/** It proved it unlinked this machine: drop the pin here too. */
|
|
72
|
+
unlinked: (hostId: string, name: string) => void;
|
|
70
73
|
}
|
|
71
74
|
|
|
72
75
|
const realDeps: ClientDeps = {
|
|
73
76
|
identity: () => currentIdentity(),
|
|
74
77
|
audit: recordPeerCall,
|
|
75
78
|
noteResult: notePinResult,
|
|
79
|
+
unlinked: unlinkedByPeer,
|
|
76
80
|
};
|
|
77
81
|
|
|
78
82
|
export async function peerCall(
|
|
@@ -155,6 +159,27 @@ export async function peerCall(
|
|
|
155
159
|
}
|
|
156
160
|
if (res.status !== 200) {
|
|
157
161
|
const text = raw.subarray(0, 64).toString("utf8").trim();
|
|
162
|
+
if (
|
|
163
|
+
res.status === 403 &&
|
|
164
|
+
text === "unknown peer" &&
|
|
165
|
+
verifyUnlinked(
|
|
166
|
+
signPublic(peer.signPub),
|
|
167
|
+
{
|
|
168
|
+
from: peer.machineId,
|
|
169
|
+
to: identity.machineId,
|
|
170
|
+
nonce: sealed.nonce.toString("base64url"),
|
|
171
|
+
},
|
|
172
|
+
res.headers.get(UNLINKED_PROOF_HEADER)
|
|
173
|
+
)
|
|
174
|
+
) {
|
|
175
|
+
audit("unknown_peer", 403, sealed.envelope.length);
|
|
176
|
+
deps.unlinked(link.hostId, link.hostName);
|
|
177
|
+
throw new PeerCallError(
|
|
178
|
+
`${link.hostName} unlinked this machine. Link it again in Machines.`,
|
|
179
|
+
true,
|
|
180
|
+
403
|
|
181
|
+
);
|
|
182
|
+
}
|
|
158
183
|
const outcome = REFUSALS[text];
|
|
159
184
|
if (outcome && res.status >= 400 && res.status < 500) {
|
|
160
185
|
audit(outcome, res.status, sealed.envelope.length);
|
package/lib/peer/link-cli.ts
CHANGED
|
@@ -100,7 +100,9 @@ async function list(io: CliIo) {
|
|
|
100
100
|
);
|
|
101
101
|
for (const r of requests)
|
|
102
102
|
io.out(
|
|
103
|
-
|
|
103
|
+
r.state === "accepted"
|
|
104
|
+
? `\n${r.name} is linked here. To finish, type this code on ${r.name}: ${r.confirmCode ? showCode(r.confirmCode) : "(see the browser)"}`
|
|
105
|
+
: `\n${r.name} wants to link here: agent-os link accept <the code it shows>`
|
|
104
106
|
);
|
|
105
107
|
if (account.machines.some((m) => !m.linkState))
|
|
106
108
|
io.out("\nLink one with: agent-os link <name>");
|
|
@@ -177,7 +179,12 @@ async function answer(io: CliIo, verb: "accept" | "decline", rest: string[]) {
|
|
|
177
179
|
io,
|
|
178
180
|
"/api/peers/pending"
|
|
179
181
|
);
|
|
180
|
-
const req = pick(
|
|
182
|
+
const req = pick(
|
|
183
|
+
requests.filter((r) => r.state !== "accepted"),
|
|
184
|
+
from,
|
|
185
|
+
(r) => r.machineId,
|
|
186
|
+
"link request"
|
|
187
|
+
);
|
|
181
188
|
if (verb === "decline") {
|
|
182
189
|
await call(io, `/api/peers/pending/${req.id}/decline`, { method: "POST" });
|
|
183
190
|
io.out(`Declined ${req.name}.`);
|
|
@@ -210,10 +217,18 @@ async function unlink(io: CliIo, ref: string | undefined) {
|
|
|
210
217
|
(h) => h.id,
|
|
211
218
|
"linked machine"
|
|
212
219
|
);
|
|
213
|
-
await call
|
|
220
|
+
const r = await call<{ told: boolean; removed: boolean }>(
|
|
221
|
+
io,
|
|
222
|
+
`/api/peers/${host.id}`,
|
|
223
|
+
{ method: "DELETE" }
|
|
224
|
+
);
|
|
214
225
|
io.out(
|
|
215
|
-
|
|
226
|
+
r.told
|
|
227
|
+
? `Unlinked ${host.name}, on both machines.`
|
|
228
|
+
: `Unlinked ${host.name} here. It couldn't be told now; its next call here is refused and it will show as unlinked.`
|
|
216
229
|
);
|
|
230
|
+
if (!r.removed)
|
|
231
|
+
io.out(`It stays in Machines while it still has projects or sessions.`);
|
|
217
232
|
}
|
|
218
233
|
|
|
219
234
|
/** Runs one command; returns the exit code. */
|
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
|
+
}
|