@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
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pairing messages between two machines, and the code both show.
|
|
3
|
+
* Everything here is public data, signed by its sender with its Connect
|
|
4
|
+
* machine key; each signature covers a domain string, both machine ids, a
|
|
5
|
+
* timestamp and a nonce, so no message stands in for another or for another
|
|
6
|
+
* pair of machines.
|
|
7
|
+
*
|
|
8
|
+
* A → B hello {from, to, ts, nonce, sign_pub, box_pub, commit}
|
|
9
|
+
* commit = SHA-256(a_box ‖ a_sign ‖ a_nonce)
|
|
10
|
+
* B → A ack {request_id, sign_pub, box_pub, b_nonce} (bound to the hello)
|
|
11
|
+
* A → B reveal {request_id, a_nonce}
|
|
12
|
+
* A → B status {request_id}
|
|
13
|
+
* B → A answer {state} (bound to that call's nonce)
|
|
14
|
+
*
|
|
15
|
+
* The code is HKDF over both machines' keys and both nonces. A has fixed
|
|
16
|
+
* its keys and nonce before it sees b_nonce, and B's nonce is fresh, so
|
|
17
|
+
* whoever substitutes keys gets one guess in a million per attempt.
|
|
18
|
+
*
|
|
19
|
+
* Two codes come from that transcript. A shows the first and a person types
|
|
20
|
+
* it on B, which proves to B that A's keys are the ones it holds. B then
|
|
21
|
+
* shows the second (a different HKDF info) and the person types it on A:
|
|
22
|
+
* that proves B's keys to A, so a control plane that lists a key of its own
|
|
23
|
+
* for B gets nothing pinned on either side.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import crypto, { type KeyObject } from "crypto";
|
|
27
|
+
import { MACHINE_ID } from "../connect/control-api";
|
|
28
|
+
import { SKEW_MS } from "./envelope";
|
|
29
|
+
import { signPublic } from "./keys";
|
|
30
|
+
|
|
31
|
+
export const HELLO_MAX_BYTES = 4096;
|
|
32
|
+
export { SKEW_MS };
|
|
33
|
+
|
|
34
|
+
const B64_16 = /^[A-Za-z0-9_-]{22}$/;
|
|
35
|
+
const B64_32 = /^[A-Za-z0-9_-]{43}$/;
|
|
36
|
+
const B64_SIG = /^[A-Za-z0-9_-]{86}$/;
|
|
37
|
+
|
|
38
|
+
export type LinkState = "pending" | "accepted" | "declined" | "expired";
|
|
39
|
+
const STATES: readonly LinkState[] = [
|
|
40
|
+
"pending",
|
|
41
|
+
"accepted",
|
|
42
|
+
"declined",
|
|
43
|
+
"expired",
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const D = {
|
|
47
|
+
hello: "agentos-peer-hello-v1",
|
|
48
|
+
ack: "agentos-peer-hello-ack-v1",
|
|
49
|
+
reveal: "agentos-peer-reveal-v1",
|
|
50
|
+
status: "agentos-peer-status-v1",
|
|
51
|
+
answer: "agentos-peer-answer-v1",
|
|
52
|
+
} as const;
|
|
53
|
+
|
|
54
|
+
const b64 = (b: Buffer) => b.toString("base64url");
|
|
55
|
+
const raw = (s: string) => Buffer.from(s, "base64url");
|
|
56
|
+
export const randomNonce = (bytes: 16 | 32) => b64(crypto.randomBytes(bytes));
|
|
57
|
+
|
|
58
|
+
// Every field is checked against a pattern with no newline before it's
|
|
59
|
+
// signed or verified, so joining with "\n" is unambiguous.
|
|
60
|
+
const message = (domain: string, fields: (string | number)[]) =>
|
|
61
|
+
Buffer.from([domain, ...fields.map(String)].join("\n"));
|
|
62
|
+
|
|
63
|
+
const signFields = (
|
|
64
|
+
key: KeyObject,
|
|
65
|
+
domain: string,
|
|
66
|
+
fields: (string | number)[]
|
|
67
|
+
) => b64(crypto.sign(null, message(domain, fields), key));
|
|
68
|
+
|
|
69
|
+
function verifyFields(
|
|
70
|
+
pub: string,
|
|
71
|
+
sig: string,
|
|
72
|
+
domain: string,
|
|
73
|
+
fields: (string | number)[]
|
|
74
|
+
): boolean {
|
|
75
|
+
if (!B64_SIG.test(sig)) return false;
|
|
76
|
+
try {
|
|
77
|
+
return crypto.verify(
|
|
78
|
+
null,
|
|
79
|
+
message(domain, fields),
|
|
80
|
+
signPublic(pub),
|
|
81
|
+
raw(sig)
|
|
82
|
+
);
|
|
83
|
+
} catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const commitTo = (boxPub: string, signPub: string, nonce: string) =>
|
|
89
|
+
b64(
|
|
90
|
+
crypto
|
|
91
|
+
.createHash("sha256")
|
|
92
|
+
.update(Buffer.concat([raw(boxPub), raw(signPub), raw(nonce)]))
|
|
93
|
+
.digest()
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
export interface Transcript {
|
|
97
|
+
from: string;
|
|
98
|
+
to: string;
|
|
99
|
+
aSign: string;
|
|
100
|
+
aBox: string;
|
|
101
|
+
bSign: string;
|
|
102
|
+
bBox: string;
|
|
103
|
+
aNonce: string;
|
|
104
|
+
bNonce: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const INFO = { sas: "agentos-peer-sas-v1", confirm: "agentos-peer-confirm-v1" };
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Six digits from both sides' keys and nonces: `sas` is shown on A and typed
|
|
111
|
+
* on B, `confirm` is shown on B once that matched and typed on A.
|
|
112
|
+
*/
|
|
113
|
+
export function deriveCode(
|
|
114
|
+
o: Transcript,
|
|
115
|
+
purpose: keyof typeof INFO = "sas"
|
|
116
|
+
): string {
|
|
117
|
+
const ikm = Buffer.concat(
|
|
118
|
+
[o.aSign, o.aBox, o.bSign, o.bBox, o.aNonce, o.bNonce].map(raw)
|
|
119
|
+
);
|
|
120
|
+
const okm = Buffer.from(
|
|
121
|
+
crypto.hkdfSync(
|
|
122
|
+
"sha256",
|
|
123
|
+
ikm,
|
|
124
|
+
Buffer.alloc(0),
|
|
125
|
+
`${INFO[purpose]}|${o.from}|${o.to}`,
|
|
126
|
+
8
|
|
127
|
+
)
|
|
128
|
+
);
|
|
129
|
+
return (okm.readBigUInt64BE(0) % BigInt(1_000_000))
|
|
130
|
+
.toString()
|
|
131
|
+
.padStart(6, "0");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export { showCode } from "./code";
|
|
135
|
+
|
|
136
|
+
/** What a person typed, as six digits, or null. */
|
|
137
|
+
export function typedCode(input: unknown): string | null {
|
|
138
|
+
if (typeof input !== "string" || input.length > 32) return null;
|
|
139
|
+
const digits = input.replace(/[\s-]/g, "");
|
|
140
|
+
return /^\d{6}$/.test(digits) ? digits : null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export const sameCode = (a: string, b: string) =>
|
|
144
|
+
a.length === b.length &&
|
|
145
|
+
crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
146
|
+
|
|
147
|
+
// ----- the messages -----
|
|
148
|
+
|
|
149
|
+
interface Signed {
|
|
150
|
+
from: string;
|
|
151
|
+
to: string;
|
|
152
|
+
ts: number;
|
|
153
|
+
nonce: string;
|
|
154
|
+
sig: string;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface Hello extends Signed {
|
|
158
|
+
v: 1;
|
|
159
|
+
sign_pub: string;
|
|
160
|
+
box_pub: string;
|
|
161
|
+
commit: string;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface Ack {
|
|
165
|
+
request_id: string;
|
|
166
|
+
sign_pub: string;
|
|
167
|
+
box_pub: string;
|
|
168
|
+
b_nonce: string;
|
|
169
|
+
sig: string;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface Reveal extends Signed {
|
|
173
|
+
v: 1;
|
|
174
|
+
request_id: string;
|
|
175
|
+
a_nonce: string;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface StatusAsk extends Signed {
|
|
179
|
+
v: 1;
|
|
180
|
+
request_id: string;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface Answer {
|
|
184
|
+
state: LinkState;
|
|
185
|
+
sig: string;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const obj = (v: unknown): Record<string, unknown> | null =>
|
|
189
|
+
v && typeof v === "object" && !Array.isArray(v)
|
|
190
|
+
? (v as Record<string, unknown>)
|
|
191
|
+
: null;
|
|
192
|
+
|
|
193
|
+
function parseSigned(v: unknown): (Record<string, unknown> & Signed) | null {
|
|
194
|
+
const o = obj(v);
|
|
195
|
+
if (!o || o.v !== 1) return null;
|
|
196
|
+
if (typeof o.from !== "string" || !MACHINE_ID.test(o.from)) return null;
|
|
197
|
+
if (typeof o.to !== "string" || !MACHINE_ID.test(o.to)) return null;
|
|
198
|
+
if (typeof o.ts !== "number" || !Number.isSafeInteger(o.ts)) return null;
|
|
199
|
+
if (typeof o.nonce !== "string" || !B64_16.test(o.nonce)) return null;
|
|
200
|
+
if (typeof o.sig !== "string" || !B64_SIG.test(o.sig)) return null;
|
|
201
|
+
return o as Record<string, unknown> & Signed;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const is32 = (v: unknown): v is string =>
|
|
205
|
+
typeof v === "string" && B64_32.test(v);
|
|
206
|
+
const is16 = (v: unknown): v is string =>
|
|
207
|
+
typeof v === "string" && B64_16.test(v);
|
|
208
|
+
|
|
209
|
+
export function parseHello(v: unknown): Hello | null {
|
|
210
|
+
const o = parseSigned(v);
|
|
211
|
+
if (!o || !is32(o.sign_pub) || !is32(o.box_pub) || !is32(o.commit))
|
|
212
|
+
return null;
|
|
213
|
+
return o as unknown as Hello;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function parseReveal(v: unknown): Reveal | null {
|
|
217
|
+
const o = parseSigned(v);
|
|
218
|
+
if (!o || !is16(o.request_id) || !is32(o.a_nonce)) return null;
|
|
219
|
+
return o as unknown as Reveal;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function parseStatusAsk(v: unknown): StatusAsk | null {
|
|
223
|
+
const o = parseSigned(v);
|
|
224
|
+
if (!o || !is16(o.request_id)) return null;
|
|
225
|
+
return o as unknown as StatusAsk;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function parseAck(v: unknown): Ack | null {
|
|
229
|
+
const o = obj(v);
|
|
230
|
+
if (!o || !is16(o.request_id) || !is32(o.sign_pub) || !is32(o.box_pub))
|
|
231
|
+
return null;
|
|
232
|
+
if (!is32(o.b_nonce) || typeof o.sig !== "string" || !B64_SIG.test(o.sig))
|
|
233
|
+
return null;
|
|
234
|
+
return o as unknown as Ack;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function parseAnswer(v: unknown): Answer | null {
|
|
238
|
+
const o = obj(v);
|
|
239
|
+
if (!o || !STATES.includes(o.state as LinkState)) return null;
|
|
240
|
+
if (typeof o.sig !== "string" || !B64_SIG.test(o.sig)) return null;
|
|
241
|
+
return o as unknown as Answer;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const helloFields = (h: Omit<Hello, "sig" | "v">) => [
|
|
245
|
+
h.from,
|
|
246
|
+
h.to,
|
|
247
|
+
h.ts,
|
|
248
|
+
h.nonce,
|
|
249
|
+
h.sign_pub,
|
|
250
|
+
h.box_pub,
|
|
251
|
+
h.commit,
|
|
252
|
+
];
|
|
253
|
+
|
|
254
|
+
export function signHello(key: KeyObject, h: Omit<Hello, "sig" | "v">): Hello {
|
|
255
|
+
return { v: 1, ...h, sig: signFields(key, D.hello, helloFields(h)) };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export const verifyHello = (h: Hello) =>
|
|
259
|
+
verifyFields(h.sign_pub, h.sig, D.hello, helloFields(h));
|
|
260
|
+
|
|
261
|
+
// The ack names both machines and the hello it answers.
|
|
262
|
+
const ackFields = (a: Omit<Ack, "sig">, hello: Hello) => [
|
|
263
|
+
hello.to,
|
|
264
|
+
hello.from,
|
|
265
|
+
hello.nonce,
|
|
266
|
+
a.request_id,
|
|
267
|
+
a.sign_pub,
|
|
268
|
+
a.box_pub,
|
|
269
|
+
a.b_nonce,
|
|
270
|
+
];
|
|
271
|
+
|
|
272
|
+
export function signAck(
|
|
273
|
+
key: KeyObject,
|
|
274
|
+
a: Omit<Ack, "sig">,
|
|
275
|
+
hello: Hello
|
|
276
|
+
): Ack {
|
|
277
|
+
return { ...a, sig: signFields(key, D.ack, ackFields(a, hello)) };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export const verifyAck = (a: Ack, hello: Hello, bSign: string) =>
|
|
281
|
+
a.sign_pub === bSign &&
|
|
282
|
+
verifyFields(bSign, a.sig, D.ack, ackFields(a, hello));
|
|
283
|
+
|
|
284
|
+
const revealFields = (r: Omit<Reveal, "sig" | "v">) => [
|
|
285
|
+
r.from,
|
|
286
|
+
r.to,
|
|
287
|
+
r.ts,
|
|
288
|
+
r.nonce,
|
|
289
|
+
r.request_id,
|
|
290
|
+
r.a_nonce,
|
|
291
|
+
];
|
|
292
|
+
|
|
293
|
+
export function signReveal(
|
|
294
|
+
key: KeyObject,
|
|
295
|
+
r: Omit<Reveal, "sig" | "v">
|
|
296
|
+
): Reveal {
|
|
297
|
+
return { v: 1, ...r, sig: signFields(key, D.reveal, revealFields(r)) };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export const verifyReveal = (r: Reveal, aSign: string) =>
|
|
301
|
+
verifyFields(aSign, r.sig, D.reveal, revealFields(r));
|
|
302
|
+
|
|
303
|
+
const statusFields = (s: Omit<StatusAsk, "sig" | "v">) => [
|
|
304
|
+
s.from,
|
|
305
|
+
s.to,
|
|
306
|
+
s.ts,
|
|
307
|
+
s.nonce,
|
|
308
|
+
s.request_id,
|
|
309
|
+
];
|
|
310
|
+
|
|
311
|
+
export function signStatusAsk(
|
|
312
|
+
key: KeyObject,
|
|
313
|
+
s: Omit<StatusAsk, "sig" | "v">
|
|
314
|
+
): StatusAsk {
|
|
315
|
+
return { v: 1, ...s, sig: signFields(key, D.status, statusFields(s)) };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export const verifyStatusAsk = (s: StatusAsk, aSign: string) =>
|
|
319
|
+
verifyFields(aSign, s.sig, D.status, statusFields(s));
|
|
320
|
+
|
|
321
|
+
/** B's answer to a reveal or a status call, bound to that call. */
|
|
322
|
+
const answerFields = (state: LinkState, ask: Reveal | StatusAsk) => [
|
|
323
|
+
ask.to,
|
|
324
|
+
ask.from,
|
|
325
|
+
ask.request_id,
|
|
326
|
+
ask.nonce,
|
|
327
|
+
state,
|
|
328
|
+
];
|
|
329
|
+
|
|
330
|
+
export const signAnswer = (
|
|
331
|
+
key: KeyObject,
|
|
332
|
+
state: LinkState,
|
|
333
|
+
ask: Reveal | StatusAsk
|
|
334
|
+
): Answer => ({
|
|
335
|
+
state,
|
|
336
|
+
sig: signFields(key, D.answer, answerFields(state, ask)),
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
export const verifyAnswer = (
|
|
340
|
+
a: Answer,
|
|
341
|
+
ask: Reveal | StatusAsk,
|
|
342
|
+
bSign: string
|
|
343
|
+
) => verifyFields(bSign, a.sig, D.answer, answerFields(a.state, ask));
|
|
344
|
+
|
|
345
|
+
export const withinSkew = (ts: number, now: number) =>
|
|
346
|
+
Math.abs(now - ts) <= SKEW_MS;
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-os link: link machines on your AgentOS Connect account from a
|
|
3
|
+
* terminal, for boxes with no browser. Talks to the AgentOS running here
|
|
4
|
+
* over loopback, which counts as this machine (it may accept a code).
|
|
5
|
+
*
|
|
6
|
+
* agent-os link the account's machines and requests waiting here
|
|
7
|
+
* agent-os link <machine> start a link; prints the code to type there,
|
|
8
|
+
* then asks for the one it shows back
|
|
9
|
+
* agent-os link accept <code> [--from <machine>] prints the code to type back
|
|
10
|
+
* agent-os link decline [<machine>]
|
|
11
|
+
* agent-os unlink <machine>
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { AccountMachines } from "./machines";
|
|
15
|
+
import type { PendingView } from "./pairing";
|
|
16
|
+
import type { Outgoing } from "./pairing-out";
|
|
17
|
+
import { showCode } from "./code";
|
|
18
|
+
|
|
19
|
+
export interface CliIo {
|
|
20
|
+
base: string;
|
|
21
|
+
fetch: typeof fetch;
|
|
22
|
+
out: (line: string) => void;
|
|
23
|
+
sleep: (ms: number) => Promise<void>;
|
|
24
|
+
/** One line typed in the terminal, or null when there's none to read. */
|
|
25
|
+
ask: (prompt: string) => Promise<string | null>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
class CliError extends Error {}
|
|
29
|
+
|
|
30
|
+
async function call<T>(
|
|
31
|
+
io: CliIo,
|
|
32
|
+
path: string,
|
|
33
|
+
init?: RequestInit
|
|
34
|
+
): Promise<T> {
|
|
35
|
+
let res: Response;
|
|
36
|
+
try {
|
|
37
|
+
res = await io.fetch(`${io.base}${path}`, {
|
|
38
|
+
...init,
|
|
39
|
+
headers: { "Content-Type": "application/json" },
|
|
40
|
+
});
|
|
41
|
+
} catch {
|
|
42
|
+
throw new CliError(
|
|
43
|
+
`Can't reach AgentOS at ${io.base}. Is it running? (agent-os status)`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
const data = (await res.json().catch(() => ({}))) as T & { error?: string };
|
|
47
|
+
if (!res.ok) throw new CliError(data.error ?? `HTTP ${res.status}`);
|
|
48
|
+
return data;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const same = (a: string, b: string) =>
|
|
52
|
+
a.trim().toLowerCase() === b.trim().toLowerCase();
|
|
53
|
+
|
|
54
|
+
function pick<T extends { name: string }>(
|
|
55
|
+
items: T[],
|
|
56
|
+
ref: string | undefined,
|
|
57
|
+
idOf: (t: T) => string,
|
|
58
|
+
what: string
|
|
59
|
+
): T {
|
|
60
|
+
if (ref) {
|
|
61
|
+
const hit = items.filter((i) => same(i.name, ref) || idOf(i) === ref);
|
|
62
|
+
if (hit.length === 1) return hit[0];
|
|
63
|
+
throw new CliError(
|
|
64
|
+
hit.length
|
|
65
|
+
? `More than one ${what} is called ${ref}; use its id.`
|
|
66
|
+
: `No ${what} called ${ref}.`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
if (items.length === 1) return items[0];
|
|
70
|
+
throw new CliError(
|
|
71
|
+
items.length
|
|
72
|
+
? `Several ${what}s: say which with --from (${items.map((i) => i.name).join(", ")}).`
|
|
73
|
+
: `No ${what} is waiting.`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function list(io: CliIo) {
|
|
78
|
+
const account = await call<AccountMachines>(
|
|
79
|
+
io,
|
|
80
|
+
"/api/peers/account?refresh=1"
|
|
81
|
+
);
|
|
82
|
+
const { requests } = await call<{ requests: PendingView[] }>(
|
|
83
|
+
io,
|
|
84
|
+
"/api/peers/pending"
|
|
85
|
+
);
|
|
86
|
+
if (!account.enrolled) {
|
|
87
|
+
io.out(
|
|
88
|
+
"This machine isn't on AgentOS Connect yet: run agent-os connect first."
|
|
89
|
+
);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (account.entitlements && !account.entitlements.linkMachines)
|
|
93
|
+
io.out("Linking machines needs Pro: upgrade at runagentos.com.\n");
|
|
94
|
+
if (account.error) io.out(`${account.error}\n`);
|
|
95
|
+
if (account.machines.length === 0)
|
|
96
|
+
io.out("No other machines on your account yet.");
|
|
97
|
+
for (const m of account.machines)
|
|
98
|
+
io.out(
|
|
99
|
+
` ${m.linkState === "active" ? "linked " : m.linkState === "suspended" ? "paused " : " "} ${m.name} (${m.online ? "online" : "offline"}, ${m.id})`
|
|
100
|
+
);
|
|
101
|
+
for (const r of requests)
|
|
102
|
+
io.out(
|
|
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>`
|
|
106
|
+
);
|
|
107
|
+
if (account.machines.some((m) => !m.linkState))
|
|
108
|
+
io.out("\nLink one with: agent-os link <name>");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function start(io: CliIo, ref: string) {
|
|
112
|
+
const account = await call<AccountMachines>(
|
|
113
|
+
io,
|
|
114
|
+
"/api/peers/account?refresh=1"
|
|
115
|
+
);
|
|
116
|
+
const target = pick(
|
|
117
|
+
account.machines,
|
|
118
|
+
ref,
|
|
119
|
+
(m) => m.id,
|
|
120
|
+
"machine on your account"
|
|
121
|
+
);
|
|
122
|
+
const { link } = await call<{ link: Outgoing }>(io, "/api/peers/link", {
|
|
123
|
+
method: "POST",
|
|
124
|
+
body: JSON.stringify({ machineId: target.id }),
|
|
125
|
+
});
|
|
126
|
+
io.out(
|
|
127
|
+
`\n Type this code on ${link.name}:\n\n ${showCode(link.code)}\n`
|
|
128
|
+
);
|
|
129
|
+
io.out(
|
|
130
|
+
` (its Machines screen, its banner, or agent-os link accept there)\n Waiting...`
|
|
131
|
+
);
|
|
132
|
+
let state: Outgoing = link;
|
|
133
|
+
while (state.state === "pending") {
|
|
134
|
+
await io.sleep(1500);
|
|
135
|
+
state = (await call<{ link: Outgoing }>(io, `/api/peers/link/${link.id}`))
|
|
136
|
+
.link;
|
|
137
|
+
}
|
|
138
|
+
while (state.state === "confirm") {
|
|
139
|
+
const typed = await io.ask(
|
|
140
|
+
`\n The code matched on ${state.name}. Type the code it shows now: `
|
|
141
|
+
);
|
|
142
|
+
if (typed === null)
|
|
143
|
+
throw new CliError("No code typed: nothing was linked.");
|
|
144
|
+
const res = await io.fetch(`${io.base}/api/peers/link/${link.id}/confirm`, {
|
|
145
|
+
method: "POST",
|
|
146
|
+
headers: { "Content-Type": "application/json" },
|
|
147
|
+
body: JSON.stringify({ code: typed }),
|
|
148
|
+
});
|
|
149
|
+
const data = (await res.json().catch(() => ({}))) as {
|
|
150
|
+
link?: Outgoing;
|
|
151
|
+
error?: string;
|
|
152
|
+
attemptsLeft?: number;
|
|
153
|
+
};
|
|
154
|
+
if (data.link) state = data.link;
|
|
155
|
+
else if (data.attemptsLeft)
|
|
156
|
+
io.out(` ${data.error} ${data.attemptsLeft} left.`);
|
|
157
|
+
else throw new CliError(data.error ?? `HTTP ${res.status}`);
|
|
158
|
+
}
|
|
159
|
+
if (state.state !== "accepted")
|
|
160
|
+
throw new CliError(
|
|
161
|
+
state.state === "expired"
|
|
162
|
+
? "The code expired. Run agent-os link again."
|
|
163
|
+
: `Not linked (${state.state}${state.error ? `: ${state.error}` : ""}).`
|
|
164
|
+
);
|
|
165
|
+
io.out(`\n Linked with ${state.name}.\n`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function answer(io: CliIo, verb: "accept" | "decline", rest: string[]) {
|
|
169
|
+
const i = rest.indexOf("--from");
|
|
170
|
+
const from = i > -1 ? rest[i + 1] : verb === "decline" ? rest[0] : undefined;
|
|
171
|
+
// "482 913" arrives as two words.
|
|
172
|
+
const code =
|
|
173
|
+
verb === "accept"
|
|
174
|
+
? rest.filter((_, j) => i < 0 || (j !== i && j !== i + 1)).join("")
|
|
175
|
+
: undefined;
|
|
176
|
+
if (verb === "accept" && !code)
|
|
177
|
+
throw new CliError("Usage: agent-os link accept <code> [--from <machine>]");
|
|
178
|
+
const { requests } = await call<{ requests: PendingView[] }>(
|
|
179
|
+
io,
|
|
180
|
+
"/api/peers/pending"
|
|
181
|
+
);
|
|
182
|
+
const req = pick(
|
|
183
|
+
requests.filter((r) => r.state !== "accepted"),
|
|
184
|
+
from,
|
|
185
|
+
(r) => r.machineId,
|
|
186
|
+
"link request"
|
|
187
|
+
);
|
|
188
|
+
if (verb === "decline") {
|
|
189
|
+
await call(io, `/api/peers/pending/${req.id}/decline`, { method: "POST" });
|
|
190
|
+
io.out(`Declined ${req.name}.`);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const r = await call<{ name: string; confirmCode: string }>(
|
|
194
|
+
io,
|
|
195
|
+
`/api/peers/pending/${req.id}/accept`,
|
|
196
|
+
{
|
|
197
|
+
method: "POST",
|
|
198
|
+
body: JSON.stringify({ code }),
|
|
199
|
+
}
|
|
200
|
+
);
|
|
201
|
+
io.out(
|
|
202
|
+
`Linked with ${r.name}. It can start agents and run code here; unlink with agent-os unlink "${r.name}".`
|
|
203
|
+
);
|
|
204
|
+
io.out(
|
|
205
|
+
`\n To finish, type this code on ${r.name}:\n\n ${showCode(r.confirmCode)}\n`
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function unlink(io: CliIo, ref: string | undefined) {
|
|
210
|
+
if (!ref) throw new CliError("Usage: agent-os unlink <machine>");
|
|
211
|
+
const { hosts } = await call<{
|
|
212
|
+
hosts: { id: string; name: string; kind?: string }[];
|
|
213
|
+
}>(io, "/api/hosts");
|
|
214
|
+
const host = pick(
|
|
215
|
+
hosts.filter((h) => h.kind === "connect"),
|
|
216
|
+
ref,
|
|
217
|
+
(h) => h.id,
|
|
218
|
+
"linked machine"
|
|
219
|
+
);
|
|
220
|
+
const r = await call<{ told: boolean; removed: boolean }>(
|
|
221
|
+
io,
|
|
222
|
+
`/api/peers/${host.id}`,
|
|
223
|
+
{ method: "DELETE" }
|
|
224
|
+
);
|
|
225
|
+
io.out(
|
|
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.`
|
|
229
|
+
);
|
|
230
|
+
if (!r.removed)
|
|
231
|
+
io.out(`It stays in Machines while it still has projects or sessions.`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Runs one command; returns the exit code. */
|
|
235
|
+
export async function runLinkCli(args: string[], io: CliIo): Promise<number> {
|
|
236
|
+
try {
|
|
237
|
+
const [first, ...rest] = args;
|
|
238
|
+
if (first === "--unlink") await unlink(io, rest[0]);
|
|
239
|
+
else if (!first) await list(io);
|
|
240
|
+
else if (first === "accept" || first === "decline")
|
|
241
|
+
await answer(io, first, rest);
|
|
242
|
+
else await start(io, args.join(" "));
|
|
243
|
+
return 0;
|
|
244
|
+
} catch (err) {
|
|
245
|
+
io.out(` link: ${err instanceof Error ? err.message : String(err)}`);
|
|
246
|
+
return 1;
|
|
247
|
+
}
|
|
248
|
+
}
|