ccdeck 3.29.11 → 3.31.0
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 +4 -3
- package/bin/agent-dag.js +67 -118
- package/bin/cli/help.js +67 -0
- package/bin/cli/login-item.js +182 -0
- package/bin/cli/one-shot.js +254 -0
- package/bin/cli/package.js +39 -0
- package/bin/cli/pulse.js +98 -0
- package/bin/cli/restart.js +179 -0
- package/bin/cli/screen.js +166 -0
- package/bin/cli/second-start.js +120 -0
- package/bin/cli/startup.js +459 -0
- package/bin/cli/uninstall.js +169 -0
- package/bin/deck.js +112 -1559
- package/dist/web/assets/BrowserWatchModal-n63GjLhv.js +1 -0
- package/dist/web/assets/UsageHistoryModal-DEwBX4Fk.js +1 -0
- package/dist/web/assets/index-CZJf1Cpp.js +299 -0
- package/dist/web/assets/index-D4q1ElXU.css +1 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +15 -14
- package/package.json +1 -1
- package/release-notes.json +40 -0
- package/src/server/account-projects.mjs +25 -11
- package/src/server/account-routes.mjs +254 -0
- package/src/server/activity.mjs +3 -3
- package/src/server/append-budget.mjs +185 -0
- package/src/server/append-failures.mjs +147 -0
- package/src/server/args.mjs +37 -5
- package/src/server/atomic-write.mjs +300 -0
- package/src/server/block-notify.mjs +0 -5
- package/src/server/boot-lock.mjs +1 -1
- package/src/server/brand.mjs +7 -6
- package/src/server/browser-react.mjs +10 -2
- package/src/server/browser-watch-decks.mjs +146 -0
- package/src/server/browser-watch-feed.mjs +69 -0
- package/src/server/browser-watch-log.mjs +173 -0
- package/src/server/browser-watch-mtime.mjs +18 -0
- package/src/server/browser-watch-relay.mjs +112 -0
- package/src/server/browser-watch-routes.mjs +172 -0
- package/src/server/browser-watch-seen.mjs +127 -0
- package/src/server/browser-watch-store.mjs +14 -143
- package/src/server/browser-watch.mjs +278 -626
- package/src/server/canonical-path.mjs +150 -0
- package/src/server/ccusage-failure.mjs +64 -0
- package/src/server/ccusage-install.mjs +459 -0
- package/src/server/ccusage-runner.mjs +271 -0
- package/src/server/ccusage.mjs +21 -756
- package/src/server/claude-accounts.mjs +220 -464
- package/src/server/claude-collector.mjs +189 -0
- package/src/server/claude-dir.mjs +7 -3
- package/src/server/claude-identity.mjs +5 -4
- package/src/server/claude-reset-credits.mjs +130 -0
- package/src/server/claude-verdicts.mjs +174 -0
- package/src/server/clear-route.mjs +191 -0
- package/src/server/codex-auth.mjs +4 -4
- package/src/server/codex-dir.mjs +52 -8
- package/src/server/codex-enrichment.mjs +310 -0
- package/src/server/codex-quota.mjs +5 -3
- package/src/server/codex-translate.mjs +407 -0
- package/src/server/codex-usage.mjs +8 -5
- package/src/server/codex-watch.mjs +496 -0
- package/src/server/cswap-auto-loop.mjs +342 -0
- package/src/server/cswap-auto-readers.mjs +303 -0
- package/src/server/cswap-auto.mjs +51 -609
- package/src/server/cswap-install.mjs +69 -265
- package/src/server/cswap-layout.mjs +242 -0
- package/src/server/deck-home.mjs +2 -2
- package/src/server/deck-prefs.mjs +58 -184
- package/src/server/deck-scope.mjs +52 -0
- package/src/server/desktop-update-routes.mjs +75 -0
- package/src/server/detach.mjs +78 -0
- package/src/server/discovery.mjs +298 -0
- package/src/server/event-log.mjs +328 -0
- package/src/server/event-pipeline.mjs +235 -0
- package/src/server/event-ring.mjs +214 -0
- package/src/server/event-routes.mjs +352 -0
- package/src/server/event-sink.mjs +26 -0
- package/src/server/exec-children.mjs +128 -0
- package/src/server/exec-not-found.mjs +213 -0
- package/src/server/exec-output.mjs +23 -0
- package/src/server/exec-spec.mjs +341 -0
- package/src/server/exec.mjs +74 -661
- package/src/server/health-route.mjs +42 -0
- package/src/server/hook-challenge.mjs +37 -0
- package/src/server/http-io.mjs +104 -0
- package/src/server/index.mjs +174 -7620
- package/src/server/install-layout.mjs +506 -0
- package/src/server/installer.mjs +79 -608
- package/src/server/jsonl-chunks.mjs +139 -0
- package/src/server/lan-accounts.mjs +27 -0
- package/src/server/lan-beacon.mjs +457 -0
- package/src/server/lan-call.mjs +239 -0
- package/src/server/lan-copies.mjs +270 -0
- package/src/server/lan-deck.mjs +583 -0
- package/src/server/lan-dials.mjs +161 -0
- package/src/server/lan-engine.mjs +475 -1024
- package/src/server/lan-hearing.mjs +49 -0
- package/src/server/lan-inbound.mjs +100 -0
- package/src/server/lan-invite-offer.mjs +77 -0
- package/src/server/lan-invite.mjs +207 -0
- package/src/server/lan-lines.mjs +73 -0
- package/src/server/lan-manifest.mjs +74 -0
- package/src/server/lan-reach.mjs +82 -69
- package/src/server/lan-requests.mjs +249 -0
- package/src/server/lan-round-record.mjs +45 -0
- package/src/server/lan-round-timer.mjs +62 -0
- package/src/server/lan-routes.mjs +143 -0
- package/src/server/lan-serve.mjs +112 -0
- package/src/server/lan-socket.mjs +243 -925
- package/src/server/lan-sync.mjs +58 -1026
- package/src/server/lan-tailnet-poll.mjs +57 -0
- package/src/server/lan-turns.mjs +80 -0
- package/src/server/lan-wire.mjs +620 -0
- package/src/server/lifecycle.mjs +421 -0
- package/src/server/listen.mjs +110 -0
- package/src/server/live-decks.mjs +138 -0
- package/src/server/load-average.mjs +32 -0
- package/src/server/log-election.mjs +235 -0
- package/src/server/log-replay.mjs +374 -0
- package/src/server/log-writer.mjs +72 -557
- package/src/server/login-service.mjs +3 -2
- package/src/server/macmon.mjs +1 -1
- package/src/server/memory-files.mjs +163 -0
- package/src/server/memory-metrics.mjs +178 -0
- package/src/server/metrics-history.mjs +94 -0
- package/src/server/metrics-run.mjs +100 -0
- package/src/server/music-routes.mjs +134 -0
- package/src/server/network-metrics.mjs +5 -3
- package/src/server/network-sampler.mjs +256 -0
- package/src/server/npm-latest.mjs +385 -0
- package/src/server/npm-upgrade.mjs +352 -0
- package/src/server/npx.mjs +41 -7
- package/src/server/output-watch.mjs +5 -4
- package/src/server/pinned-build.mjs +110 -0
- package/src/server/prefs-give-back.mjs +206 -0
- package/src/server/prefs-path.mjs +14 -0
- package/src/server/prefs-refusal.mjs +86 -0
- package/src/server/prefs-routes.mjs +99 -0
- package/src/server/prefs-set-aside.mjs +86 -0
- package/src/server/prefs-state.mjs +70 -0
- package/src/server/process-command.mjs +104 -0
- package/src/server/process-list.mjs +547 -0
- package/src/server/provider-status.mjs +307 -0
- package/src/server/pulse-line.mjs +132 -0
- package/src/server/purge-key.mjs +7 -7
- package/src/server/quota-cli.mjs +174 -0
- package/src/server/quota-oauth.mjs +190 -0
- package/src/server/quota-shape.mjs +229 -0
- package/src/server/quota-store-resets.mjs +206 -0
- package/src/server/quota.mjs +123 -509
- package/src/server/relay-guard.mjs +3 -3
- package/src/server/request-gates.mjs +441 -0
- package/src/server/restart-note.mjs +180 -0
- package/src/server/retire-sound-hook.mjs +3 -3
- package/src/server/ring-bounds.mjs +215 -0
- package/src/server/running-deck.mjs +1 -1
- package/src/server/running-version.mjs +24 -0
- package/src/server/self-update.mjs +39 -1371
- package/src/server/session-enrichment.mjs +622 -0
- package/src/server/session-read-gate.mjs +49 -0
- package/src/server/session-tracking.mjs +242 -0
- package/src/server/sse-clients.mjs +222 -0
- package/src/server/stale-discovery.mjs +30 -0
- package/src/server/static-serve.mjs +81 -0
- package/src/server/supervisor.mjs +73 -12
- package/src/server/system-metrics.mjs +169 -1935
- package/src/server/term.mjs +11 -249
- package/src/server/thermal-metrics.mjs +520 -0
- package/src/server/thermal-sampler.mjs +204 -0
- package/src/server/token-redact.mjs +123 -0
- package/src/server/transcript-gate.mjs +107 -0
- package/src/server/transcript-scan.mjs +563 -0
- package/src/server/usage-routes.mjs +108 -0
- package/src/server/uv-bootstrap.mjs +1 -1
- package/src/server/wordmark.mjs +127 -0
- package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
- package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
- package/dist/web/assets/index-Ck2RAla3.css +0 -1
- package/dist/web/assets/index-jmrcEsCj.js +0 -298
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// The calling half: dial a deck, prove this one, check that deck's proof back,
|
|
2
|
+
// and hand over the connection — with the sentence each refusal from the far
|
|
3
|
+
// end is read as. Lifted out of lan-socket.mjs, which re-exports it; the
|
|
4
|
+
// answering half stays there, and the line both halves speak is lan-lines.mjs's.
|
|
5
|
+
import net from "node:net";
|
|
6
|
+
import { cleanName } from "./lan-sync.mjs";
|
|
7
|
+
import {
|
|
8
|
+
challengeFor, ephemeralPair, frameChannel, handshakeTranscript, mixesEphemeral, proof, proofOk, readChallenge,
|
|
9
|
+
readEphemeral, readPub, sealsFrames, sessionKey,
|
|
10
|
+
} from "./lan-wire.mjs";
|
|
11
|
+
import { inviteProof, inviteProofBack } from "./lan-invite.mjs";
|
|
12
|
+
import { frameReader, sendFrame, HANDSHAKE_MS } from "./lan-lines.mjs";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* What each refusal reason means, in a sentence the LAN panel can print.
|
|
16
|
+
*
|
|
17
|
+
* At module scope so it is one object rather than one per frame, and named so
|
|
18
|
+
* the `Object.hasOwn` guard below reads as the rule it is rather than as
|
|
19
|
+
* punctuation. The keys are the protocol's, not a user's.
|
|
20
|
+
*/
|
|
21
|
+
const REFUSALS = Object.freeze({
|
|
22
|
+
pending: "waiting for the other deck to accept this one",
|
|
23
|
+
declined: "that deck said no",
|
|
24
|
+
"invite only": "that deck pairs only by invite",
|
|
25
|
+
// Sent back to a caller that said it was not asking, so the far end's answer
|
|
26
|
+
// is about the caller's own setting — the same words its own round uses.
|
|
27
|
+
"not asking": "this deck pairs only by invite",
|
|
28
|
+
impostor: "that deck has this one pinned under a different key",
|
|
29
|
+
"wrong invite": "that deck did not take this invite — ask them for a new one",
|
|
30
|
+
"bad proof": "the other deck refused this one's proof",
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The calling half: connect, prove, be proved to, then talk.
|
|
35
|
+
*
|
|
36
|
+
* BOTH SIDES PROVE, and the second half is the one that is easy to skip. A
|
|
37
|
+
* handshake where only the caller proves itself stops a stranger reading a
|
|
38
|
+
* manifest and stops nothing else — a stranger can still stand up a listener on
|
|
39
|
+
* the announced port, wait for a real deck to dial it, and be handed whatever
|
|
40
|
+
* that deck was going to say. So this checks the reply with the same care the
|
|
41
|
+
* server checks the hello, and gives up if it does not hold.
|
|
42
|
+
*/
|
|
43
|
+
export function connectToPeer({
|
|
44
|
+
host, port, fp, pub, secret, name, myPort = null, code = null, timeoutMs = HANDSHAKE_MS,
|
|
45
|
+
/** False when this deck is reaching an address it already had WITHOUT asking
|
|
46
|
+
* to pair — an invite-only deck's round. Sent only when false, so every other
|
|
47
|
+
* hello is byte-for-byte what it was, and a deck that predates the field
|
|
48
|
+
* reads past it the way it reads past `epk`. */
|
|
49
|
+
ask = true,
|
|
50
|
+
/** The public key we pinned for this deck the first time, or null for a deck
|
|
51
|
+
* we are meeting — an address somebody typed. */
|
|
52
|
+
expectPub = null,
|
|
53
|
+
/** Whether the deck that minted `code` said it will prove it holds one too.
|
|
54
|
+
* From the token's own `pb`, which is the only way to tell a deck that will
|
|
55
|
+
* not from a deck that cannot — see mintInvite. False leaves this path
|
|
56
|
+
* exactly as it shipped, for a token minted by an older deck. */
|
|
57
|
+
inviteProvesBack = false,
|
|
58
|
+
/** Whether this deck seals every frame after the handshake with a deck that
|
|
59
|
+
* says it does too — see frameChannel. False announces nothing and seals
|
|
60
|
+
* nothing, exactly as every deck before #810 dials; only the suite asks. */
|
|
61
|
+
sealFrames = true,
|
|
62
|
+
/** Whether this deck mixes a key pair made for the connection into its key,
|
|
63
|
+
* with a deck that says it does too — see sessionKey. False dials exactly
|
|
64
|
+
* as a deck of #810's version does; only the suite asks. */
|
|
65
|
+
ephemeral = true,
|
|
66
|
+
}) {
|
|
67
|
+
return new Promise((resolve, reject) => {
|
|
68
|
+
const myChallenge = challengeFor({ seals: sealFrames, ephemeral });
|
|
69
|
+
// Made before the other deck is heard from, because the public half goes
|
|
70
|
+
// in the hello. Let go of once the key is derived — unused, when the deck
|
|
71
|
+
// that answers turns out not to mix — and on every way out below.
|
|
72
|
+
let mine = mixesEphemeral(myChallenge) ? ephemeralPair() : null;
|
|
73
|
+
const myEpk = mine?.pub;
|
|
74
|
+
const sock = net.createConnection({ host, port });
|
|
75
|
+
sock.setEncoding("utf8");
|
|
76
|
+
let settled = false;
|
|
77
|
+
const fail = err => {
|
|
78
|
+
if (settled) return;
|
|
79
|
+
settled = true;
|
|
80
|
+
mine = null;
|
|
81
|
+
sock.destroy();
|
|
82
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
83
|
+
};
|
|
84
|
+
const timer = setTimeout(() => fail(new Error("handshake timed out")), timeoutMs);
|
|
85
|
+
timer.unref?.();
|
|
86
|
+
|
|
87
|
+
sock.on("error", fail);
|
|
88
|
+
sock.on("close", () => fail(new Error("peer closed the connection")));
|
|
89
|
+
sock.on("connect", () => {
|
|
90
|
+
// No proof in the hello: the caller cannot cover a challenge it has not
|
|
91
|
+
// been given, and a proof over an empty one would be a proof that means
|
|
92
|
+
// nothing. It goes in message three.
|
|
93
|
+
// `port` is where WE listen, which is not the port this socket came from
|
|
94
|
+
// — that one is ephemeral and useless to dial. Without it a deck can
|
|
95
|
+
// accept an incoming request and still have no way to reach back, so the
|
|
96
|
+
// pairing is mutual on paper and one-way in fact.
|
|
97
|
+
// `epk` is this connection's key pair, offered: a deck from before #1120
|
|
98
|
+
// reads past a field it does not know, and one of this version answers.
|
|
99
|
+
sendFrame(sock, {
|
|
100
|
+
t: "hello", fp, pub, name, port: myPort, challenge: myChallenge, ...(myEpk ? { epk: myEpk } : {}),
|
|
101
|
+
...(ask === false ? { ask: false } : {}),
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
let theirChallenge = null;
|
|
106
|
+
let theirFp = null;
|
|
107
|
+
let theirPub = null;
|
|
108
|
+
let key = null;
|
|
109
|
+
/** What the key was derived over; the invite proofs are made over it too. */
|
|
110
|
+
let transcript = null;
|
|
111
|
+
|
|
112
|
+
/** Step 2, answered: the listener's challenge, checked against the pin,
|
|
113
|
+
* and this deck's proof back — with the connection's key derived first. */
|
|
114
|
+
const challenge = msg => {
|
|
115
|
+
if (theirFp || !readChallenge(msg.challenge)) return fail(new Error("bad challenge"));
|
|
116
|
+
const them = readPub(msg.pub);
|
|
117
|
+
if (!them || them.fp !== msg.fp) return fail(new Error("bad challenge"));
|
|
118
|
+
// THE PIN, CHECKED BEFORE ANYTHING ELSE. A deck we have paired with is
|
|
119
|
+
// this key and no other; a key that does not match is not a peer whose
|
|
120
|
+
// details changed, it is a different machine at the same address.
|
|
121
|
+
if (expectPub && expectPub !== them.pub) {
|
|
122
|
+
return fail(new Error("a different deck is answering at that address"));
|
|
123
|
+
}
|
|
124
|
+
theirChallenge = msg.challenge;
|
|
125
|
+
theirFp = them.fp;
|
|
126
|
+
theirPub = them.pub;
|
|
127
|
+
// Mixed when both challenges say so, as at the listener, and then the
|
|
128
|
+
// listener's key is required: a deck of this version that says it
|
|
129
|
+
// mixes and sends no key is not an older deck, it is a deck whose key
|
|
130
|
+
// was taken out on the way. Refused, and never read the old way.
|
|
131
|
+
const mixing = mixesEphemeral(myChallenge) && mixesEphemeral(theirChallenge);
|
|
132
|
+
const theirEphemeral = mixing ? readEphemeral(msg.epk) : null;
|
|
133
|
+
if (mixing && !theirEphemeral) return fail(new Error("bad challenge"));
|
|
134
|
+
transcript = mixing
|
|
135
|
+
? handshakeTranscript(fp, theirFp, myChallenge, theirChallenge, myEpk, theirEphemeral)
|
|
136
|
+
: handshakeTranscript(fp, theirFp, myChallenge, theirChallenge);
|
|
137
|
+
try {
|
|
138
|
+
key = sessionKey(secret, theirPub, transcript,
|
|
139
|
+
mixing ? { role: "caller", priv: mine.priv, peer: theirEphemeral } : null);
|
|
140
|
+
} catch {
|
|
141
|
+
return fail(new Error("bad challenge"));
|
|
142
|
+
} finally {
|
|
143
|
+
// Used or not, the private half goes now.
|
|
144
|
+
mine = null;
|
|
145
|
+
}
|
|
146
|
+
sendFrame(sock, {
|
|
147
|
+
t: "auth",
|
|
148
|
+
proof: proof(key, {
|
|
149
|
+
challenge: myChallenge, peerChallenge: theirChallenge,
|
|
150
|
+
fromFp: fp, toFp: theirFp, direction: "hello",
|
|
151
|
+
}),
|
|
152
|
+
// Only when joining on an invite. Sent in the same frame as the
|
|
153
|
+
// session proof so a deck that holds a token is paired in one round
|
|
154
|
+
// trip rather than being queued behind somebody else's press.
|
|
155
|
+
...(code ? { invite: inviteProof(code, transcript) } : {}),
|
|
156
|
+
});
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/** Step 4: the listener's own proof — and the invite's, when this deck is
|
|
160
|
+
* joining on one — and then the connection, handed to whoever dialled. */
|
|
161
|
+
const ok = msg => {
|
|
162
|
+
// The same deck that gave us the challenge, or nothing: a reply naming a
|
|
163
|
+
// different fingerprint is a second party in the middle of this.
|
|
164
|
+
//
|
|
165
|
+
// AND A CHALLENGE FIRST. `theirFp` is null until one is accepted, so an
|
|
166
|
+
// `ok` sent as the very first frame with `fp: null` matched it — null is
|
|
167
|
+
// null — and walked into `proof` with no key, which throws. Before #1146
|
|
168
|
+
// that throw ended the deck, and this end dials whatever address a beacon
|
|
169
|
+
// announces; since #1146 the reader catches it as "bad frame". Neither is
|
|
170
|
+
// the answer: an `ok` before a challenge is out of order, and says so.
|
|
171
|
+
if (msg.t !== "ok" || !theirFp || !key || msg.fp !== theirFp) return fail(new Error("expected ok"));
|
|
172
|
+
const want = proof(key, {
|
|
173
|
+
challenge: theirChallenge, peerChallenge: myChallenge,
|
|
174
|
+
fromFp: theirFp, toFp: fp, direction: "reply",
|
|
175
|
+
});
|
|
176
|
+
if (!proofOk(want, msg.proof)) return fail(new Error("that deck could not prove its own key"));
|
|
177
|
+
// AND THAT IT IS THE DECK THE INVITE NAMED. The proof above is about a
|
|
178
|
+
// key the responder chose a moment ago; this one is about a code it had
|
|
179
|
+
// to have been given. `join` has no pin to pass, so `expectPub` above is
|
|
180
|
+
// null on this path and this is the only check that distinguishes the
|
|
181
|
+
// deck whose owner minted the token from whatever else is reachable at
|
|
182
|
+
// one of the ten addresses the token happens to carry.
|
|
183
|
+
//
|
|
184
|
+
// The caller giving up here is not the end of the attempt: `join` walks
|
|
185
|
+
// the rest of the list, so a deck answering at a stale or borrowed
|
|
186
|
+
// address costs one failed address instead of winning the whole token.
|
|
187
|
+
if (code && inviteProvesBack) {
|
|
188
|
+
const back = inviteProofBack(code, transcript);
|
|
189
|
+
if (!proofOk(back, msg.inviteProof)) {
|
|
190
|
+
return fail(new Error("that deck does not hold the invite"));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// Sealed from here when both challenges said so. The listener decided
|
|
194
|
+
// the same at the same moment from the same two strings, and the proof
|
|
195
|
+
// just checked is what says it saw the same two. See frameChannel.
|
|
196
|
+
const chan = sealsFrames(myChallenge) && sealsFrames(theirChallenge) ? frameChannel(key, "caller") : null;
|
|
197
|
+
settled = true;
|
|
198
|
+
clearTimeout(timer);
|
|
199
|
+
sock.removeAllListeners("close");
|
|
200
|
+
resolve({
|
|
201
|
+
sock, key, sealed: !!chan,
|
|
202
|
+
peerFp: theirFp, peerPub: theirPub, peerName: cleanName(msg.name, ""),
|
|
203
|
+
send: obj => sendFrame(sock, chan ? chan.wrap(obj) : obj),
|
|
204
|
+
/** What a frame from the other end says — or null on a sealed
|
|
205
|
+
* connection when it does not open, which ends the connection: every
|
|
206
|
+
* frame after it would fail too. On a connection to a deck from before
|
|
207
|
+
* #810, a plain frame passes through as it arrived. */
|
|
208
|
+
read: frame => (chan ? chan.unwrap(frame) : frame),
|
|
209
|
+
});
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
sock.on("data", frameReader(msg => {
|
|
213
|
+
if (settled) return;
|
|
214
|
+
// A deck that heard us and said no. Each reason is a different problem
|
|
215
|
+
// with a different fix, and until this frame existed they were all one
|
|
216
|
+
// silent close that read as a firewall. See REFUSALS.
|
|
217
|
+
if (msg.t === "no") {
|
|
218
|
+
// `Object.hasOwn`, and here more than anywhere: `msg.why` is a field in
|
|
219
|
+
// a frame written by the OTHER MACHINE, which is the case admin-failure
|
|
220
|
+
// states the rule for (#474). Every member of Object.prototype answers
|
|
221
|
+
// a plain bracket read with an inherited value that is neither nullish
|
|
222
|
+
// nor falsy, so `?? "the other deck refused this handshake"` never
|
|
223
|
+
// fired for one — `{...}["constructor"]` is the Object function itself,
|
|
224
|
+
// and `new Error(Object).message` is the string "function Object() {
|
|
225
|
+
// [native code] }". lan-engine files that as `lastRound.error` and the
|
|
226
|
+
// LAN panel prints it verbatim, so a peer chose what appeared in the
|
|
227
|
+
// user's interface. Asking whether the map has a ROW is the question
|
|
228
|
+
// this read was always trying to ask; no real reason moves.
|
|
229
|
+
return fail(new Error(Object.hasOwn(REFUSALS, msg.why) ? REFUSALS[msg.why]
|
|
230
|
+
: "the other deck refused this handshake"));
|
|
231
|
+
}
|
|
232
|
+
if (msg.t === "challenge") return challenge(msg);
|
|
233
|
+
return ok(msg);
|
|
234
|
+
// A refusal from the reader, with the thrown error as its cause when there
|
|
235
|
+
// was one: the message is the one it always was, and whatever logs the
|
|
236
|
+
// rejection can reach what actually threw.
|
|
237
|
+
}, (why, cause) => fail(cause ? new Error(why, { cause }) : new Error(why))));
|
|
238
|
+
});
|
|
239
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// Which copy of a login wins when two decks both hold one, what a deck tells
|
|
2
|
+
// the decks it is paired with about the logins it holds — the lists a manifest
|
|
3
|
+
// carries, as they go out and as they are read coming in — and the words for
|
|
4
|
+
// why a login did not move. Moved out of lan-sync.mjs, which re-exports all of
|
|
5
|
+
// it: none of it reads the beacon, the handshake or the seals, and the identity
|
|
6
|
+
// of an account is the one rule modules far from this feature ask for. Like
|
|
7
|
+
// everything there it is pure — no sockets, no timers, no filesystem.
|
|
8
|
+
import { storedCopyAlive } from "./account-health.mjs";
|
|
9
|
+
|
|
10
|
+
// ── which copy wins ─────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The identity of one account, which is NOT its slot number.
|
|
14
|
+
*
|
|
15
|
+
* claude-swap keys on `(email, organizationUuid)` — same email under two orgs
|
|
16
|
+
* is two accounts on purpose — and assigns slots as max+1 per store, so the
|
|
17
|
+
* account that is 4 here is 2 there. Anything keyed on the number would swap
|
|
18
|
+
* the wrong pair the first time two stores had grown in a different order.
|
|
19
|
+
*/
|
|
20
|
+
export function accountKey(email, orgUuid) {
|
|
21
|
+
return `${String(email ?? "").trim().toLowerCase()}@@${String(orgUuid ?? "")}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* What to do about one account, given what I have and what a peer has.
|
|
26
|
+
*
|
|
27
|
+
* TWO OUTCOMES, AND NEITHER OVERWRITES SOMETHING THAT WORKS.
|
|
28
|
+
*
|
|
29
|
+
* "add" I do not have this account at all.
|
|
30
|
+
* "heal" I have it and claude-swap has quarantined it — refresh token dead,
|
|
31
|
+
* verified by an invalid_grant from Anthropic rather than guessed
|
|
32
|
+
* from an expiry field — and the peer's copy is alive.
|
|
33
|
+
* null Anything else, which is most of the time.
|
|
34
|
+
*
|
|
35
|
+
* A third outcome was designed and dropped, and the reason is worth keeping
|
|
36
|
+
* because it looks like a feature being given up. It was "replace": take a
|
|
37
|
+
* peer's copy when it is strictly newer than mine, so the freshest copy wins
|
|
38
|
+
* everywhere. Measuring "newer" needs the OAuth payload's `expiresAt`, which
|
|
39
|
+
* means the deck opening a credential — something it does not do, claude-swap
|
|
40
|
+
* owns that — and on macOS that credential is in the Keychain, which a
|
|
41
|
+
* background process cannot reliably read at all.
|
|
42
|
+
*
|
|
43
|
+
* What settled it is not the obstacle. It is that a working credential replaced
|
|
44
|
+
* by a newer working credential changes nothing today. It would only matter if
|
|
45
|
+
* mine were about to die — and a login dies from not being used, so if I am not
|
|
46
|
+
* using it I do not care, and if I am using it the refresh keeps it alive. The
|
|
47
|
+
* whole value is in the account that is already dead.
|
|
48
|
+
*
|
|
49
|
+
* So this never returns an outcome that needs `cswap import --force`, which
|
|
50
|
+
* makes claude-swap's own rule the entire safety property: a plain import skips
|
|
51
|
+
* an account that is present and healthy, and replaces exactly one that is
|
|
52
|
+
* quarantined. A peer cannot overwrite a credential of mine that works, because
|
|
53
|
+
* nothing here ever asks for that.
|
|
54
|
+
*/
|
|
55
|
+
export function syncAction(mine, theirs) {
|
|
56
|
+
if (!theirs || !theirs.alive || theirs.shareable === false) return null;
|
|
57
|
+
if (!mine) return "add";
|
|
58
|
+
return mine.alive ? null : "heal";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Everything to do this round, over one peer's manifest.
|
|
63
|
+
*
|
|
64
|
+
* Sorted by account key rather than left in manifest order, so two decks
|
|
65
|
+
* reconciling the same pair of stores do the same work in the same sequence —
|
|
66
|
+
* which is what makes a failure halfway through repeatable rather than a
|
|
67
|
+
* different half each time.
|
|
68
|
+
*/
|
|
69
|
+
export function plan(local, remote) {
|
|
70
|
+
// Through onePerKey, not array order: a Map built straight from the rows
|
|
71
|
+
// keeps the LAST one for a key, so an expired second slot hid a live first
|
|
72
|
+
// one and every round "healed" a login that works here.
|
|
73
|
+
const mine = new Map(onePerKey(local).map(a => [a.key, a]));
|
|
74
|
+
const out = [];
|
|
75
|
+
for (const theirs of remote) {
|
|
76
|
+
const action = syncAction(mine.get(theirs.key), theirs);
|
|
77
|
+
if (action) out.push({ key: theirs.key, email: theirs.email, action });
|
|
78
|
+
}
|
|
79
|
+
return out.sort((a, b) => a.key.localeCompare(b.key));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* One row per identity, and THE rule for which one when claude-swap holds two
|
|
84
|
+
* slots for the same login: a live copy beats an expired one, then one this
|
|
85
|
+
* process can read beats one it cannot, then the earlier slot.
|
|
86
|
+
*
|
|
87
|
+
* ONE RULE, FOUR PLACES. The manifest this deck sends, the peer manifest it
|
|
88
|
+
* reads, the export it answers a `want` with, and plan's view of this deck's
|
|
89
|
+
* own copy all pick through this, so the slot a peer was told about is the
|
|
90
|
+
* slot it is then handed, and the copy this deck advertises as working is the
|
|
91
|
+
* one it plans from. Each used to take array order on its own and agreed only
|
|
92
|
+
* by coincidence — and two slots for one identity became two imports of it in
|
|
93
|
+
* a single round.
|
|
94
|
+
*/
|
|
95
|
+
export function onePerKey(rows) {
|
|
96
|
+
const rank = a => (a.alive ? 2 : 0) + (a.readable === false ? 0 : 1);
|
|
97
|
+
const best = new Map();
|
|
98
|
+
for (const a of rows) {
|
|
99
|
+
const had = best.get(a.key);
|
|
100
|
+
if (!had || rank(a) > rank(had)) best.set(a.key, a);
|
|
101
|
+
}
|
|
102
|
+
return [...best.values()];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** This deck's own slot for one identity, by onePerKey's rule, or null. */
|
|
106
|
+
export function slotFor(accounts, key) {
|
|
107
|
+
return onePerKey(accounts.filter(a => a.key === key))[0] ?? null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* What this deck publishes about its own accounts — to the group, and only to
|
|
112
|
+
* the group.
|
|
113
|
+
*
|
|
114
|
+
* `alive` says whether the stored copy is still valid. `shareable: false` is
|
|
115
|
+
* the separate, temporary condition where this process cannot read that valid
|
|
116
|
+
* copy (for example a locked macOS Keychain). Keeping those facts separate is
|
|
117
|
+
* what stops another deck treating a healthy-but-inaccessible copy as expired
|
|
118
|
+
* and repeatedly trying to heal it.
|
|
119
|
+
*
|
|
120
|
+
* Emails are in it, and the manifest is sealed on its way. This said "in the
|
|
121
|
+
* clear inside the encrypted channel" until #810, and there was no such
|
|
122
|
+
* channel: the handshake derived a key and sealed a credential and a version
|
|
123
|
+
* card with it, and nothing else — so every email in this list crossed the
|
|
124
|
+
* network readable by anybody on it. Between two decks that both seal, every
|
|
125
|
+
* frame after the handshake now is (see frameChannel). Toward a deck from
|
|
126
|
+
* before that it still travels plain, because that deck cannot open anything
|
|
127
|
+
* else, until it updates.
|
|
128
|
+
*
|
|
129
|
+
* Plain emails INSIDE the seal are a deliberate line: a manifest only ever
|
|
130
|
+
* reaches a deck that proved the key somebody here accepted, and the panel has
|
|
131
|
+
* to name the account it is offering to heal. Hashing the email would buy
|
|
132
|
+
* nothing against that reader and would cost the one thing the row needs to say.
|
|
133
|
+
*
|
|
134
|
+
* `shared` is the user's list. An account absent from it is absent from the
|
|
135
|
+
* manifest entirely — not listed as withheld, which would tell the group that
|
|
136
|
+
* an account exists and is being kept back, and that is itself the fact being
|
|
137
|
+
* kept back.
|
|
138
|
+
*/
|
|
139
|
+
export function manifestFor(accounts, shared) {
|
|
140
|
+
const want = new Set(shared);
|
|
141
|
+
return onePerKey(accounts.filter(a => want.has(a.key)))
|
|
142
|
+
.map(a => ({
|
|
143
|
+
key: a.key,
|
|
144
|
+
email: a.email,
|
|
145
|
+
alive: !!a.alive,
|
|
146
|
+
...(a.readable === false ? { shareable: false } : {}),
|
|
147
|
+
}))
|
|
148
|
+
.sort((a, b) => a.key.localeCompare(b.key));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Which account this deck is on, as a frame field for the decks it is paired
|
|
153
|
+
* with — or nothing.
|
|
154
|
+
*
|
|
155
|
+
* ONLY ONE IT SHARES IS EVER NAMED. The account a deck works on is named only
|
|
156
|
+
* when it is also in the list the owner ticked, so the promise the share list
|
|
157
|
+
* makes — an unticked account is never told to anybody — holds for this too.
|
|
158
|
+
* On one it does not share, it says `other` and nothing more: a paired deck
|
|
159
|
+
* reads "on another account", which says this machine is working without
|
|
160
|
+
* saying on what.
|
|
161
|
+
*
|
|
162
|
+
* `hidden` is the owner's switch turned off, and that IS said, on purpose: a
|
|
163
|
+
* paired deck then reads "current account hidden" rather than nothing, so the
|
|
164
|
+
* person over there knows this machine is working and chose not to say where.
|
|
165
|
+
* A deck with no account at all says nothing.
|
|
166
|
+
*/
|
|
167
|
+
export function currentFor(accounts, shared, shareActive) {
|
|
168
|
+
if (shareActive === false) return { current: { hidden: true } };
|
|
169
|
+
const on = accounts.find(a => a.active);
|
|
170
|
+
if (!on) return {};
|
|
171
|
+
return new Set(shared).has(on.key) ? { current: { key: on.key } } : { current: { other: true } };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** One peer-supplied string as the panel may draw it: no control or format
|
|
175
|
+
* characters, whitespace collapsed, bounded. The rule cleanName applies to a
|
|
176
|
+
* deck's name and lan-about's `field` to a card, applied to the two strings
|
|
177
|
+
* that sit next to the fingerprint in the import dialog. */
|
|
178
|
+
function flatten(v, max) {
|
|
179
|
+
if (typeof v !== "string") return "";
|
|
180
|
+
const flat = v.replace(/\p{Cc}/gu, " ").replace(/\p{Cf}/gu, "").replace(/\s+/g, " ").trim();
|
|
181
|
+
return [...flat].slice(0, max).join("");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The accounts a peer's manifest listed, as the panel may keep them.
|
|
186
|
+
*
|
|
187
|
+
* It arrived from another machine, so it is read rather than trusted: strings
|
|
188
|
+
* where strings belong, a boolean for the verdict, and no more rows than a
|
|
189
|
+
* manifest may carry. What is kept is only what the deck's dialog draws.
|
|
190
|
+
*/
|
|
191
|
+
export function offered(list) {
|
|
192
|
+
return onePerKey((Array.isArray(list) ? list : [])
|
|
193
|
+
.filter(a => a && typeof a.key === "string" && typeof a.email === "string")
|
|
194
|
+
// Character-filtered, not merely cut. These two are drawn beside the
|
|
195
|
+
// fingerprint at the moment the operator picks which of a peer's logins to
|
|
196
|
+
// import (LanPeerModal.tsx:497), and a bare slice let a format character
|
|
197
|
+
// through — the same class cleanName strips from the name one frame over.
|
|
198
|
+
.map(a => ({
|
|
199
|
+
key: flatten(a.key, 320),
|
|
200
|
+
email: flatten(a.email, 254),
|
|
201
|
+
alive: storedCopyAlive(a.alive, a.collector),
|
|
202
|
+
// Additive wire field: an older peer omitted it, which means shareable.
|
|
203
|
+
...(a.shareable === false ? { shareable: false } : {}),
|
|
204
|
+
}))
|
|
205
|
+
.filter(a => a.key && a.email))
|
|
206
|
+
// Fifty IDENTITIES, so the cap is spent after onePerKey rather than before
|
|
207
|
+
// it: a peer with duplicate slots could otherwise push a live copy past
|
|
208
|
+
// row fifty and out of the list while offering far fewer than fifty
|
|
209
|
+
// logins. The raw array is already bounded by MAX_FRAME_BYTES.
|
|
210
|
+
.slice(0, 50);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Which account a peer said it is on, as the panel may keep it: the key of one
|
|
215
|
+
* of the accounts it listed in the same frame, that its owner is hiding it, or
|
|
216
|
+
* that it is on one it does not share — and nothing for anything else. A key
|
|
217
|
+
* that is not in its own list is dropped rather than drawn: a deck only ever
|
|
218
|
+
* names an account it shares, and one that names another is saying something
|
|
219
|
+
* this deck will not show.
|
|
220
|
+
*/
|
|
221
|
+
export function heardCurrent(raw, list) {
|
|
222
|
+
if (!raw || typeof raw !== "object") return null;
|
|
223
|
+
if (raw.hidden === true) return { hidden: true };
|
|
224
|
+
if (raw.other === true) return { other: true };
|
|
225
|
+
if (typeof raw.key !== "string") return null;
|
|
226
|
+
return list.some(a => a.key === raw.key) ? { key: raw.key } : null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── why a login did not move ────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The reason a sending deck gives for a login it holds and cannot read: on a
|
|
233
|
+
* Mac, claude-swap could not open the Keychain from the session this deck runs
|
|
234
|
+
* in. A fixed code, never the CLI's words — export's stdout IS the credential,
|
|
235
|
+
* and nothing it printed is any business of the deck asking.
|
|
236
|
+
*/
|
|
237
|
+
export const SENDER_UNREADABLE = "keychain_unavailable";
|
|
238
|
+
|
|
239
|
+
/** The reasons `serve` gives for a login it will not send. Its other answer to
|
|
240
|
+
* a `want`, "not paired", is not in the set, so a deck that gets it records
|
|
241
|
+
* "refused". */
|
|
242
|
+
const WIRE_REFUSALS = new Set(["proof", "not shared", "not mine to give", "export failed", SENDER_UNREADABLE, "error"]);
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* A peer's refusal, as this deck records it. The panel prints these, so a peer
|
|
246
|
+
* must not be able to put words in its mouth — least of all one of the HERE
|
|
247
|
+
* codes, which are sentences about this machine. Anything outside the closed
|
|
248
|
+
* set is "refused", which is all it ever proved.
|
|
249
|
+
*/
|
|
250
|
+
export function peerWhy(why) {
|
|
251
|
+
return typeof why === "string" && WIRE_REFUSALS.has(why) ? why : "refused";
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* What this deck found wrong with a login it received. Produced by the local
|
|
256
|
+
* adapters only and never read off a frame — see peerWhy — so a code here is
|
|
257
|
+
* always about THIS machine, and on an arrived row it is a warning rather than
|
|
258
|
+
* a failure: the credential landed and something about this machine stops it
|
|
259
|
+
* being used.
|
|
260
|
+
*/
|
|
261
|
+
export const HERE = Object.freeze({
|
|
262
|
+
// claude-swap cannot open this Mac's Keychain from the deck's session.
|
|
263
|
+
unreadable: "unreadable_here",
|
|
264
|
+
// It landed, and claude-swap still holds no login for it.
|
|
265
|
+
noLogin: "no_credentials_here",
|
|
266
|
+
// It landed, and the login it carried was rejected.
|
|
267
|
+
expired: "relogin_required_here",
|
|
268
|
+
// It landed, and claude-swap could not be asked whether it is readable.
|
|
269
|
+
unverified: "unverified_here",
|
|
270
|
+
});
|