shadok-ai 0.13.13 → 0.13.14
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/context/agents-skill/pilotctl.mjs +36 -10
- package/dist/peers.d.ts +114 -22
- package/dist/peers.js +114 -28
- package/dist/peers.js.map +1 -1
- package/dist/server.js +40 -12
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
|
@@ -558,19 +558,45 @@ async function cmdScreen(id, flags) {
|
|
|
558
558
|
const HELP =
|
|
559
559
|
"usage: pilotctl <spawn|prompt|dialog|choose|toggle|confirm|freetext|list|diff|stop|screen|profile-prompt|peer> …\n" +
|
|
560
560
|
" add --peer <alias> to any command to run it against another instance's agents\n" +
|
|
561
|
-
" pilotctl peer <add <alias> <url> <token
|
|
561
|
+
" pilotctl peer <add <alias> <url> <ticket> [--token]|list|rm <alias>>";
|
|
562
562
|
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
563
|
+
/**
|
|
564
|
+
* Manage the outbound-peers file: aliases → { url, token }.
|
|
565
|
+
*
|
|
566
|
+
* `add` takes the single-use TICKET from the invitation and ENROLS with it: it
|
|
567
|
+
* posts the ticket to the remote `POST /peers/enrol`, which consumes it and
|
|
568
|
+
* returns the durable token stored here. So the credential we keep is one this
|
|
569
|
+
* side obtained itself — the invitation, which gets pasted into a session and
|
|
570
|
+
* lives on in a transcript, only ever held something that dies on first use.
|
|
571
|
+
*
|
|
572
|
+
* A ticket that has already been redeemed, expired or never existed is refused
|
|
573
|
+
* by the remote with one indistinguishable message, and we store NOTHING: a
|
|
574
|
+
* saved credential that does not authenticate is worse than a clear error.
|
|
575
|
+
*
|
|
576
|
+
* `--token` skips the exchange and stores the value as a durable token, for a
|
|
577
|
+
* credential issued some other way. `list` never prints either.
|
|
578
|
+
*/
|
|
579
|
+
export async function cmdPeer(pos, flags = {}) {
|
|
580
|
+
const [sub, alias, url, secret] = pos;
|
|
568
581
|
const map = loadPeersOut();
|
|
569
582
|
if (sub === "add") {
|
|
570
|
-
if (!alias || !url || !
|
|
571
|
-
|
|
583
|
+
if (!alias || !url || !secret)
|
|
584
|
+
throw new Error("usage: pilotctl peer add <alias> <url> <ticket> [--token]");
|
|
585
|
+
const base = String(url).replace(/\/+$/, "");
|
|
586
|
+
let token = secret;
|
|
587
|
+
if (!flags.token) {
|
|
588
|
+
const r = await fetch(`${base}/peers/enrol`, {
|
|
589
|
+
method: "POST",
|
|
590
|
+
headers: { "content-type": "application/json" },
|
|
591
|
+
body: JSON.stringify({ ticket: secret }),
|
|
592
|
+
});
|
|
593
|
+
const j = await r.json().catch(() => ({}));
|
|
594
|
+
if (!r.ok || !j?.token) throw new Error(j?.error || `enrolment refused (HTTP ${r.status})`);
|
|
595
|
+
token = j.token;
|
|
596
|
+
}
|
|
597
|
+
map[alias] = { url: base, token };
|
|
572
598
|
savePeersOut(map);
|
|
573
|
-
return { ok: true, alias, url };
|
|
599
|
+
return { ok: true, alias, url: base, enrolled: !flags.token };
|
|
574
600
|
}
|
|
575
601
|
if (sub === "rm") {
|
|
576
602
|
if (!alias) throw new Error("usage: pilotctl peer rm <alias>");
|
|
@@ -586,7 +612,7 @@ export function cmdPeer(pos) {
|
|
|
586
612
|
export async function run(argv) {
|
|
587
613
|
const { cmd, pos, flags } = parseArgs(argv);
|
|
588
614
|
// Manage the outbound-peers file (local, no server).
|
|
589
|
-
if (cmd === "peer") return cmdPeer(pos);
|
|
615
|
+
if (cmd === "peer") return cmdPeer(pos, flags);
|
|
590
616
|
// Target a REMOTE peer for this command — every call then goes to its URL with
|
|
591
617
|
// the peer token instead of the local session key. `--peer <alias>` resolves
|
|
592
618
|
// the outbound file; `--peer-url/--peer-token` is a one-off; SHADOK_PEER_URL/
|
package/dist/peers.d.ts
CHANGED
|
@@ -1,24 +1,58 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* PEERS — other instances (or harnesses) allowed to reach THIS one's agents.
|
|
3
3
|
*
|
|
4
|
-
* A peer is the cross-instance twin of a web account (`accounts.ts`)
|
|
5
|
-
* revocable, per launch dir
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
4
|
+
* A peer is the cross-instance twin of a web account (`accounts.ts`), and it
|
|
5
|
+
* enrols the same way: named, revocable, per launch dir, and reached through a
|
|
6
|
+
* **single-use ticket** that is CONSUMED on first use, exactly as a user's
|
|
7
|
+
* invitation link is. What the invitee keeps afterwards is a durable token it
|
|
8
|
+
* obtained itself — so the long-lived credential never travels through an
|
|
9
|
+
* invitation, a chat, or an invitee's transcript.
|
|
10
|
+
*
|
|
11
|
+
* Two properties are load-bearing, and the first one was missing at first.
|
|
12
|
+
*
|
|
13
|
+
* The token is salted PER PEER. It used to be `hmac(secret, "peer:"+name)`,
|
|
14
|
+
* which is deterministic: deleting a row revoked the token, but re-adding a peer
|
|
15
|
+
* under the SAME name minted the identical token again — handing a leaked
|
|
16
|
+
* credential straight back. Revocation was not rotation. The row now carries a
|
|
17
|
+
* random `salt` mixed into the MAC, so re-inviting the same name yields a
|
|
18
|
+
* different token, and the only way to rotate used to be renaming the peer or
|
|
19
|
+
* rotating the instance secret (which is shared with every user session).
|
|
20
|
+
*
|
|
21
|
+
* It stays **non-expiring** once enrolled — a cross-instance link is persistent
|
|
22
|
+
* and must not hit the 7-day cookie cliff — and **revocable by the registry**:
|
|
23
|
+
* verifying requires the name to still be listed here, so deleting the row
|
|
24
|
+
* kills every token for it. See docs/superpowers/specs/2026-09-26-cross-instance-
|
|
12
25
|
* agent-collaboration-design.md.
|
|
13
26
|
*
|
|
14
27
|
* A peer that connects is a DISTINCT principal, scoped by the caller to the
|
|
15
28
|
* agents it created (`Channel.createdByPeer`) — never B's whole fleet.
|
|
16
29
|
*/
|
|
30
|
+
/** A pending enrolment: the single-use ticket, and when it stops being usable. */
|
|
31
|
+
export interface PeerInvite {
|
|
32
|
+
ticket: string;
|
|
33
|
+
expiresAt: number;
|
|
34
|
+
}
|
|
17
35
|
export interface Peer {
|
|
18
36
|
name: string;
|
|
19
37
|
createdAt: number;
|
|
20
38
|
note?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Random, per peer, mixed into the token's MAC. Without it the token is a
|
|
41
|
+
* pure function of the name, so revoke-then-re-invite returns the SAME
|
|
42
|
+
* credential — see the note above. Absent on a row written before this
|
|
43
|
+
* existed; `withSalts` backfills one, which invalidates that row's old token
|
|
44
|
+
* (deliberately: a deterministic token is the thing being removed).
|
|
45
|
+
*/
|
|
46
|
+
salt?: string;
|
|
47
|
+
/** Present until the ticket is redeemed. While it is here the peer has NOT
|
|
48
|
+
* enrolled, and its durable token does not authenticate anything. */
|
|
49
|
+
invite?: PeerInvite;
|
|
50
|
+
/** When the ticket was redeemed and the durable token handed over. */
|
|
51
|
+
enrolledAt?: number;
|
|
21
52
|
}
|
|
53
|
+
/** How long a ticket stays redeemable. Long enough to reach an agent that is
|
|
54
|
+
* mid-task, short enough that a forgotten invitation is not a standing door. */
|
|
55
|
+
export declare const PEER_INVITE_TTL_MS: number;
|
|
22
56
|
/** INBOUND registry, per launch dir, 600 — peers allowed to reach THIS instance. */
|
|
23
57
|
export declare function peerFileFor(cwd?: string): string;
|
|
24
58
|
/** OUTBOUND registry, per launch dir, 600 — peers THIS instance can reach
|
|
@@ -32,18 +66,71 @@ export declare function savePeers(file: string, peers: Peer[]): void;
|
|
|
32
66
|
/** A peer name is a slug: lowercased, safe chars, non-empty. null if nothing
|
|
33
67
|
* usable survives — an empty/invalid name must never become a live credential. */
|
|
34
68
|
export declare function normPeerName(name: unknown): string | null;
|
|
35
|
-
/**
|
|
36
|
-
|
|
69
|
+
/**
|
|
70
|
+
* Pure: add (or supersede) a peer by name, with a FRESH salt and a fresh
|
|
71
|
+
* single-use ticket.
|
|
72
|
+
*
|
|
73
|
+
* Superseding re-rolls the salt on purpose: re-inviting a name is exactly when
|
|
74
|
+
* the old credential must stop working. `salt`/`ticket` are injected so the
|
|
75
|
+
* result is testable; `newPeer` below supplies real randomness.
|
|
76
|
+
*/
|
|
77
|
+
export declare function addPeer(peers: Peer[], name: string, now: number, note: string | undefined, salt: string, ticket: string, ttlMs?: number): Peer[];
|
|
78
|
+
/** Impure twin of `addPeer`: draws the salt and the ticket. */
|
|
79
|
+
export declare function newPeer(peers: Peer[], name: string, now: number, note?: string): {
|
|
80
|
+
peers: Peer[];
|
|
81
|
+
ticket: string;
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* Backfill a salt on any row written before salts existed.
|
|
85
|
+
*
|
|
86
|
+
* Doing this INVALIDATES that row's existing token, and that is the point: the
|
|
87
|
+
* saltless token is the deterministic one being removed. A peer whose token
|
|
88
|
+
* stops working is re-invited; a peer whose leaked token kept working would not
|
|
89
|
+
* know to.
|
|
90
|
+
*/
|
|
91
|
+
export declare function withSalts(peers: Peer[], mint?: () => string): Peer[];
|
|
92
|
+
export type RedeemVerdict = {
|
|
93
|
+
ok: true;
|
|
94
|
+
name: string;
|
|
95
|
+
} | {
|
|
96
|
+
ok: false;
|
|
97
|
+
error: string;
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* Pure: may this ticket be redeemed right now?
|
|
101
|
+
*
|
|
102
|
+
* Refuses an unknown, an expired and an ALREADY-REDEEMED ticket with the same
|
|
103
|
+
* wording on purpose. Redemption deletes the ticket, so a used one and an
|
|
104
|
+
* invented one are indistinguishable from here — and saying which would tell a
|
|
105
|
+
* guesser that a ticket once existed. Same reasoning as the user invitation's
|
|
106
|
+
* "this link is no longer valid".
|
|
107
|
+
*/
|
|
108
|
+
export declare function redeemVerdict(peers: readonly Peer[], ticket: unknown, now: number): RedeemVerdict;
|
|
109
|
+
/** Pure: consume the ticket — the row stays (that is the peer), the invitation
|
|
110
|
+
* goes. This is what makes the ticket single-use. */
|
|
111
|
+
export declare function redeemPeer(peers: readonly Peer[], name: string, now: number): Peer[];
|
|
37
112
|
/** Pure: drop a peer by name (revocation). */
|
|
38
113
|
export declare function removePeer(peers: Peer[], name: string): Peer[];
|
|
39
|
-
/**
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
export declare function
|
|
44
|
-
/**
|
|
45
|
-
*
|
|
46
|
-
export declare function
|
|
114
|
+
/**
|
|
115
|
+
* The peer token: `<b64url(name)>.<hmac("peer:"+b64+":"+salt)>`, twin of
|
|
116
|
+
* `signSessionKey` but SALTED per peer — see the note at the top of this file.
|
|
117
|
+
*/
|
|
118
|
+
export declare function signPeerToken(name: string, secret: Buffer, salt: string): string;
|
|
119
|
+
/** Pure: the name a token CLAIMS, from its first half. Proves nothing on its
|
|
120
|
+
* own — the MAC is checked in `peerFromToken`, which needs the row's salt. */
|
|
121
|
+
export declare function peerTokenName(token: unknown): string | null;
|
|
122
|
+
/**
|
|
123
|
+
* The peer a token authenticates, or null.
|
|
124
|
+
*
|
|
125
|
+
* Three conditions, and each one is a different refusal:
|
|
126
|
+
* - the name is still in the registry — deleting the row is the revocation;
|
|
127
|
+
* - that row has ENROLLED (no pending invite left). A durable token for a peer
|
|
128
|
+
* that never redeemed its ticket authenticates nothing, which is what keeps
|
|
129
|
+
* the ticket the only way in;
|
|
130
|
+
* - the MAC verifies against THAT ROW's salt, so a token minted before a
|
|
131
|
+
* re-invitation is dead.
|
|
132
|
+
*/
|
|
133
|
+
export declare function peerFromToken(token: string, secret: Buffer, peers: readonly Peer[]): string | null;
|
|
47
134
|
/**
|
|
48
135
|
* The ownership rule — a peer may only touch an agent IT created. The single
|
|
49
136
|
* named check behind every peer scope (resume, stop, /diff): a channel with a
|
|
@@ -54,13 +141,18 @@ export declare function ownedByPeer(channel: {
|
|
|
54
141
|
} | undefined, peer: string): boolean;
|
|
55
142
|
/**
|
|
56
143
|
* The copy-paste invitation for an AGENT — the peer equivalent of a user's
|
|
57
|
-
* single-use link. The invitee is an agent, not a
|
|
58
|
-
* where it picks a password it gets a PROMPT to paste
|
|
59
|
-
* cockpit URL,
|
|
144
|
+
* single-use link, and single-use in the same way. The invitee is an agent, not a
|
|
145
|
+
* human, so instead of a page where it picks a password it gets a PROMPT to paste
|
|
146
|
+
* into its own session: the cockpit URL, a TICKET, the brief, and exactly how to
|
|
147
|
+
* join and report back.
|
|
148
|
+
*
|
|
149
|
+
* It carries the ticket and never the durable token, which is the point: an
|
|
150
|
+
* invitation is copied, pasted and kept in a transcript, so what it holds must
|
|
151
|
+
* be worthless once used.
|
|
60
152
|
* `brief` is the human's task (empty allowed). Pure so the wording is tested.
|
|
61
153
|
*/
|
|
62
154
|
export declare function agentInvitePrompt(opts: {
|
|
63
155
|
url: string;
|
|
64
|
-
|
|
156
|
+
ticket: string;
|
|
65
157
|
brief?: string;
|
|
66
158
|
}): string;
|
package/dist/peers.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
1
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { instanceKey } from "./paths.js";
|
|
6
|
+
/** How long a ticket stays redeemable. Long enough to reach an agent that is
|
|
7
|
+
* mid-task, short enough that a forgotten invitation is not a standing door. */
|
|
8
|
+
export const PEER_INVITE_TTL_MS = 7 * 24 * 60 * 60_000;
|
|
6
9
|
/** INBOUND registry, per launch dir, 600 — peers allowed to reach THIS instance. */
|
|
7
10
|
export function peerFileFor(cwd = process.cwd()) {
|
|
8
11
|
return path.join(os.homedir(), ".shadok-ai", "peers", instanceKey(cwd) + ".json");
|
|
@@ -40,36 +43,92 @@ export function normPeerName(name) {
|
|
|
40
43
|
.replace(/^-+|-+$/g, "");
|
|
41
44
|
return s || null;
|
|
42
45
|
}
|
|
43
|
-
/**
|
|
44
|
-
|
|
45
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Pure: add (or supersede) a peer by name, with a FRESH salt and a fresh
|
|
48
|
+
* single-use ticket.
|
|
49
|
+
*
|
|
50
|
+
* Superseding re-rolls the salt on purpose: re-inviting a name is exactly when
|
|
51
|
+
* the old credential must stop working. `salt`/`ticket` are injected so the
|
|
52
|
+
* result is testable; `newPeer` below supplies real randomness.
|
|
53
|
+
*/
|
|
54
|
+
export function addPeer(peers, name, now, note, salt, ticket, ttlMs = PEER_INVITE_TTL_MS) {
|
|
55
|
+
const row = {
|
|
56
|
+
name,
|
|
57
|
+
createdAt: now,
|
|
58
|
+
...(note ? { note } : {}),
|
|
59
|
+
salt,
|
|
60
|
+
invite: { ticket, expiresAt: now + ttlMs },
|
|
61
|
+
};
|
|
46
62
|
return [...peers.filter((p) => p.name !== name), row];
|
|
47
63
|
}
|
|
64
|
+
/** Impure twin of `addPeer`: draws the salt and the ticket. */
|
|
65
|
+
export function newPeer(peers, name, now, note) {
|
|
66
|
+
const ticket = randomBytes(32).toString("base64url");
|
|
67
|
+
return { peers: addPeer(peers, name, now, note, randomBytes(16).toString("hex"), ticket), ticket };
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Backfill a salt on any row written before salts existed.
|
|
71
|
+
*
|
|
72
|
+
* Doing this INVALIDATES that row's existing token, and that is the point: the
|
|
73
|
+
* saltless token is the deterministic one being removed. A peer whose token
|
|
74
|
+
* stops working is re-invited; a peer whose leaked token kept working would not
|
|
75
|
+
* know to.
|
|
76
|
+
*/
|
|
77
|
+
export function withSalts(peers, mint = () => randomBytes(16).toString("hex")) {
|
|
78
|
+
return peers.map((p) => (p.salt ? p : { ...p, salt: mint() }));
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Pure: may this ticket be redeemed right now?
|
|
82
|
+
*
|
|
83
|
+
* Refuses an unknown, an expired and an ALREADY-REDEEMED ticket with the same
|
|
84
|
+
* wording on purpose. Redemption deletes the ticket, so a used one and an
|
|
85
|
+
* invented one are indistinguishable from here — and saying which would tell a
|
|
86
|
+
* guesser that a ticket once existed. Same reasoning as the user invitation's
|
|
87
|
+
* "this link is no longer valid".
|
|
88
|
+
*/
|
|
89
|
+
export function redeemVerdict(peers, ticket, now) {
|
|
90
|
+
const t = typeof ticket === "string" ? ticket.trim() : "";
|
|
91
|
+
const gone = { ok: false, error: "this invitation is no longer valid — it may already have been used" };
|
|
92
|
+
if (!t)
|
|
93
|
+
return { ok: false, error: "no invitation ticket" };
|
|
94
|
+
const row = peers.find((p) => p.invite && p.invite.ticket === t);
|
|
95
|
+
if (!row || !row.invite)
|
|
96
|
+
return gone;
|
|
97
|
+
if (row.invite.expiresAt <= now)
|
|
98
|
+
return gone;
|
|
99
|
+
return { ok: true, name: row.name };
|
|
100
|
+
}
|
|
101
|
+
/** Pure: consume the ticket — the row stays (that is the peer), the invitation
|
|
102
|
+
* goes. This is what makes the ticket single-use. */
|
|
103
|
+
export function redeemPeer(peers, name, now) {
|
|
104
|
+
return peers.map((p) => {
|
|
105
|
+
if (p.name !== name)
|
|
106
|
+
return p;
|
|
107
|
+
const { invite: _used, ...rest } = p;
|
|
108
|
+
return { ...rest, enrolledAt: now };
|
|
109
|
+
});
|
|
110
|
+
}
|
|
48
111
|
/** Pure: drop a peer by name (revocation). */
|
|
49
112
|
export function removePeer(peers, name) {
|
|
50
113
|
return peers.filter((p) => p.name !== name);
|
|
51
114
|
}
|
|
52
|
-
/**
|
|
53
|
-
|
|
115
|
+
/**
|
|
116
|
+
* The peer token: `<b64url(name)>.<hmac("peer:"+b64+":"+salt)>`, twin of
|
|
117
|
+
* `signSessionKey` but SALTED per peer — see the note at the top of this file.
|
|
118
|
+
*/
|
|
119
|
+
export function signPeerToken(name, secret, salt) {
|
|
54
120
|
const n = Buffer.from(name, "utf8").toString("base64url");
|
|
55
|
-
return `${n}.${createHmac("sha256", secret).update(`peer:${n}`).digest("hex")}`;
|
|
121
|
+
return `${n}.${createHmac("sha256", secret).update(`peer:${n}:${salt}`).digest("hex")}`;
|
|
56
122
|
}
|
|
57
|
-
/** Pure: the name a token
|
|
58
|
-
*
|
|
59
|
-
export function
|
|
123
|
+
/** Pure: the name a token CLAIMS, from its first half. Proves nothing on its
|
|
124
|
+
* own — the MAC is checked in `peerFromToken`, which needs the row's salt. */
|
|
125
|
+
export function peerTokenName(token) {
|
|
60
126
|
const parts = String(token ?? "").split(".");
|
|
61
127
|
if (parts.length !== 2)
|
|
62
128
|
return null;
|
|
63
129
|
const [n, mac] = parts;
|
|
64
130
|
if (!n || !mac)
|
|
65
131
|
return null;
|
|
66
|
-
const want = createHmac("sha256", secret).update(`peer:${n}`).digest("hex");
|
|
67
|
-
// Length first: timingSafeEqual THROWS on a length mismatch (invariant in
|
|
68
|
-
// readSessionKey), which would be a 500 instead of a clean refusal.
|
|
69
|
-
if (mac.length !== want.length)
|
|
70
|
-
return null;
|
|
71
|
-
if (!timingSafeEqual(Buffer.from(mac), Buffer.from(want)))
|
|
72
|
-
return null;
|
|
73
132
|
try {
|
|
74
133
|
return Buffer.from(n, "base64url").toString("utf8") || null;
|
|
75
134
|
}
|
|
@@ -77,13 +136,33 @@ export function readPeerToken(token, secret) {
|
|
|
77
136
|
return null;
|
|
78
137
|
}
|
|
79
138
|
}
|
|
80
|
-
/**
|
|
81
|
-
*
|
|
139
|
+
/**
|
|
140
|
+
* The peer a token authenticates, or null.
|
|
141
|
+
*
|
|
142
|
+
* Three conditions, and each one is a different refusal:
|
|
143
|
+
* - the name is still in the registry — deleting the row is the revocation;
|
|
144
|
+
* - that row has ENROLLED (no pending invite left). A durable token for a peer
|
|
145
|
+
* that never redeemed its ticket authenticates nothing, which is what keeps
|
|
146
|
+
* the ticket the only way in;
|
|
147
|
+
* - the MAC verifies against THAT ROW's salt, so a token minted before a
|
|
148
|
+
* re-invitation is dead.
|
|
149
|
+
*/
|
|
82
150
|
export function peerFromToken(token, secret, peers) {
|
|
83
|
-
const
|
|
84
|
-
if (!
|
|
151
|
+
const claimed = peerTokenName(token);
|
|
152
|
+
if (!claimed)
|
|
153
|
+
return null;
|
|
154
|
+
const row = peers.find((p) => p.name === claimed);
|
|
155
|
+
if (!row || !row.salt || row.invite)
|
|
156
|
+
return null;
|
|
157
|
+
const mac = String(token).split(".")[1] ?? "";
|
|
158
|
+
const want = createHmac("sha256", secret).update(`peer:${Buffer.from(claimed, "utf8").toString("base64url")}:${row.salt}`).digest("hex");
|
|
159
|
+
// Length first: timingSafeEqual THROWS on a length mismatch (invariant in
|
|
160
|
+
// readSessionKey), which would be a 500 instead of a clean refusal.
|
|
161
|
+
if (mac.length !== want.length)
|
|
162
|
+
return null;
|
|
163
|
+
if (!timingSafeEqual(Buffer.from(mac), Buffer.from(want)))
|
|
85
164
|
return null;
|
|
86
|
-
return
|
|
165
|
+
return row.name;
|
|
87
166
|
}
|
|
88
167
|
/**
|
|
89
168
|
* The ownership rule — a peer may only touch an agent IT created. The single
|
|
@@ -95,9 +174,14 @@ export function ownedByPeer(channel, peer) {
|
|
|
95
174
|
}
|
|
96
175
|
/**
|
|
97
176
|
* The copy-paste invitation for an AGENT — the peer equivalent of a user's
|
|
98
|
-
* single-use link. The invitee is an agent, not a
|
|
99
|
-
* where it picks a password it gets a PROMPT to paste
|
|
100
|
-
* cockpit URL,
|
|
177
|
+
* single-use link, and single-use in the same way. The invitee is an agent, not a
|
|
178
|
+
* human, so instead of a page where it picks a password it gets a PROMPT to paste
|
|
179
|
+
* into its own session: the cockpit URL, a TICKET, the brief, and exactly how to
|
|
180
|
+
* join and report back.
|
|
181
|
+
*
|
|
182
|
+
* It carries the ticket and never the durable token, which is the point: an
|
|
183
|
+
* invitation is copied, pasted and kept in a transcript, so what it holds must
|
|
184
|
+
* be worthless once used.
|
|
101
185
|
* `brief` is the human's task (empty allowed). Pure so the wording is tested.
|
|
102
186
|
*/
|
|
103
187
|
export function agentInvitePrompt(opts) {
|
|
@@ -107,11 +191,13 @@ export function agentInvitePrompt(opts) {
|
|
|
107
191
|
"You've been invited to collaborate with a shadok-ai cockpit as a peer agent.",
|
|
108
192
|
"",
|
|
109
193
|
`Cockpit: ${url}`,
|
|
110
|
-
`Your
|
|
111
|
-
"(
|
|
194
|
+
`Your invitation ticket: ${opts.ticket}`,
|
|
195
|
+
"(Single use: `peer add` exchanges it for your own durable key, and the ticket",
|
|
196
|
+
" dies at that moment. Keep the key you get back secret — it authenticates you",
|
|
197
|
+
" as this peer, and it can be revoked at any time.)",
|
|
112
198
|
"",
|
|
113
199
|
"To join, use the shadok-ai-agents skill (pilotctl):",
|
|
114
|
-
` pilotctl peer add host ${url} ${opts.
|
|
200
|
+
` pilotctl peer add host ${url} ${opts.ticket}`,
|
|
115
201
|
" pilotctl spawn --peer host --cwd /workspace # start an agent on the cockpit",
|
|
116
202
|
' pilotctl prompt <id> "<your work>" --peer host # drive it',
|
|
117
203
|
" pilotctl diff <id> --peer host # read what it changed",
|
package/dist/peers.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"peers.js","sourceRoot":"","sources":["../src/peers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"peers.js","sourceRoot":"","sources":["../src/peers.ts"],"names":[],"mappings":"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"}
|
package/dist/server.js
CHANGED
|
@@ -37,7 +37,7 @@ import { deliverWithRetry } from "./reload.js";
|
|
|
37
37
|
import { startTelegram, renameTelegramTopic, closeTelegramTopic, probeToken, isStalePreface, announceLoggedOut, resetLoggedOutNotice, attachmentPrompt, pasteFileName, MEDIA_DIR, } from "./telegram.js";
|
|
38
38
|
import { migrateTgBindings } from "./channels.js";
|
|
39
39
|
import { instanceKey } from "./paths.js";
|
|
40
|
-
import { peerFileFor, loadPeers, savePeers, peerFromToken, signPeerToken,
|
|
40
|
+
import { peerFileFor, loadPeers, savePeers, peerFromToken, signPeerToken, newPeer, withSalts, redeemVerdict, redeemPeer, normPeerName, removePeer, ownedByPeer, peersOutFileFor, agentInvitePrompt, } from "./peers.js";
|
|
41
41
|
import { loadCrons, saveCrons, upsertCron, removeCron, resolveCronId, nextRunFor, nextRunAfterFailure, isTransient, CRON_MAX_RETRIES, markCronPrompt, normalizeSchedule, onceAt, stateAfterFire, scheduleLabel, cronTimeZone, defaultTimeZone, systemTimeZone, isValidTimeZone, } from "./crons.js";
|
|
42
42
|
import { markPromptMeta, promptMetaHeader } from "./promptmeta.js";
|
|
43
43
|
import { runRefusal, isShellMode } from "./bang.js";
|
|
@@ -664,7 +664,9 @@ function peerName(req) {
|
|
|
664
664
|
const tok = req.headers["x-shadok-peer"];
|
|
665
665
|
if (typeof tok !== "string" || !tok)
|
|
666
666
|
return null;
|
|
667
|
-
|
|
667
|
+
// `withSalts` so a row written before salts existed is verified against a
|
|
668
|
+
// backfilled one — i.e. its old deterministic token is refused, not honoured.
|
|
669
|
+
return peerFromToken(tok, signingSecret(), withSalts(loadPeers(peerFileFor())));
|
|
668
670
|
}
|
|
669
671
|
function passwordMatches(input) {
|
|
670
672
|
const a = Buffer.from(input);
|
|
@@ -857,6 +859,30 @@ app.post("/logout", (_req, res) => {
|
|
|
857
859
|
res.setHeader("Set-Cookie", "sk_auth=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0");
|
|
858
860
|
res.json({ ok: true });
|
|
859
861
|
});
|
|
862
|
+
/**
|
|
863
|
+
* Redeem a peer invitation: a single-use ticket in, a durable token out.
|
|
864
|
+
*
|
|
865
|
+
* BEFORE the password gate on purpose, and it is the only peer route that is: an
|
|
866
|
+
* invitee has no credential yet, which is the whole point of enrolling. The
|
|
867
|
+
* surface is deliberately one field — the ticket authenticates the call and
|
|
868
|
+
* nothing else is read — and it is narrow in three more ways: the ticket is
|
|
869
|
+
* consumed on success (so a replay fails), it expires, and an unknown, used or
|
|
870
|
+
* expired ticket all get the SAME refusal, so a guesser learns nothing about
|
|
871
|
+
* which tickets ever existed.
|
|
872
|
+
*
|
|
873
|
+
* Twin of `POST /invite/:token` for humans.
|
|
874
|
+
*/
|
|
875
|
+
app.post("/peers/enrol", (req, res) => {
|
|
876
|
+
const file = peerFileFor();
|
|
877
|
+
const peers = withSalts(loadPeers(file));
|
|
878
|
+
const v = redeemVerdict(peers, req.body?.ticket, Date.now());
|
|
879
|
+
if (!v.ok)
|
|
880
|
+
return res.status(400).json({ error: v.error });
|
|
881
|
+
const row = peers.find((p) => p.name === v.name);
|
|
882
|
+
savePeers(file, redeemPeer(peers, v.name, Date.now()));
|
|
883
|
+
console.log(`peers: ${v.name} enrolled (ticket consumed)`);
|
|
884
|
+
res.json({ ok: true, name: v.name, token: signPeerToken(v.name, signingSecret(), row.salt) });
|
|
885
|
+
});
|
|
860
886
|
// Who am I? The client labels itself with this, and a tab whose session expired
|
|
861
887
|
// finds out here rather than by a silent failure.
|
|
862
888
|
app.get("/me", (req, res) => {
|
|
@@ -1435,17 +1461,19 @@ app.post("/peers", (req, res) => {
|
|
|
1435
1461
|
const note = typeof req.body?.note === "string" ? req.body.note.trim() || undefined : undefined;
|
|
1436
1462
|
const brief = typeof req.body?.brief === "string" ? req.body.brief : "";
|
|
1437
1463
|
const file = peerFileFor();
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
//
|
|
1442
|
-
// `
|
|
1443
|
-
//
|
|
1444
|
-
//
|
|
1445
|
-
//
|
|
1446
|
-
|
|
1464
|
+
const { peers, ticket } = newPeer(loadPeers(file), name, Date.now(), note);
|
|
1465
|
+
savePeers(file, peers);
|
|
1466
|
+
console.log(`peers: ${me.name} invited peer ${name}`);
|
|
1467
|
+
// What is handed out is a SINGLE-USE TICKET, not the credential — the peer
|
|
1468
|
+
// exchanges it at `POST /peers/enrol` for a durable token it then keeps. So the
|
|
1469
|
+
// long-lived credential never travels through this response, the invite prompt,
|
|
1470
|
+
// a chat, or the invitee's transcript; only a ticket that dies on first use
|
|
1471
|
+
// does. Exactly how a user's invitation link works (`/invite/:token`).
|
|
1472
|
+
// Re-inviting the same name re-rolls the salt, so any earlier token is dead.
|
|
1473
|
+
// The URL is the one the ADMIN is reaching us at (their browser origin), which
|
|
1474
|
+
// is the public URL an external agent would use too.
|
|
1447
1475
|
const url = (req.headers.origin?.trim() || `http://${req.headers.host ?? "localhost"}`).replace(/\/+$/, "");
|
|
1448
|
-
res.json({ ok: true, name,
|
|
1476
|
+
res.json({ ok: true, name, ticket, invitePrompt: agentInvitePrompt({ url, ticket, brief }) });
|
|
1449
1477
|
});
|
|
1450
1478
|
app.delete("/peers", (req, res) => {
|
|
1451
1479
|
const me = accountAdmin(req, res);
|