ccdeck 3.43.1 → 3.43.4
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 +7 -5
- package/bin/deck.js +44 -15
- package/dist/web/assets/{BrowserWatchModal-1On-pA6T.js → BrowserWatchModal-nOxhGOO4.js} +1 -1
- package/dist/web/assets/{RadioPanel-DkXAgxKR.js → RadioPanel-BLfpQ1Iw.js} +1 -1
- package/dist/web/assets/{TrafficRadar-DWilzdA3.js → TrafficRadar-BFrObpjW.js} +1 -1
- package/dist/web/assets/{UsageHistoryModal-BW9jTi6E.js → UsageHistoryModal-DCnFf1vi.js} +1 -1
- package/dist/web/assets/{app-DsHSufBt.css → app-BiJ8TfUl.css} +1 -1
- package/dist/web/assets/{app-ByCusSHX.js → app-DBsNB-2y.js} +38 -36
- package/dist/web/assets/{index-CzIBjYsB.js → index-Ch4ks76J.js} +1 -1
- package/dist/web/index.html +3 -3
- package/package.json +1 -1
- package/release-notes.json +8 -1
- package/src/server/account-choice.mjs +9 -5
- package/src/server/cswap-auto-choice.mjs +20 -27
- package/src/server/cswap-auto-loop.mjs +13 -1
- package/src/server/deck-prefs.mjs +14 -1
- package/src/server/lan-copies.mjs +43 -5
- package/src/server/lan-deck.mjs +3 -0
- package/src/server/lan-engine.mjs +5 -0
- package/src/server/lan-manifest.mjs +2 -2
- package/src/server/lan-serve.mjs +17 -2
- package/src/server/lan-sync.mjs +2 -2
- package/src/server/process-command.mjs +4 -1
- package/src/server/reports.mjs +7 -1
- package/src/server/traffic-radar-wire.mjs +4 -3
|
@@ -1 +1 @@
|
|
|
1
|
-
import"./app-
|
|
1
|
+
import"./app-DBsNB-2y.js";(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const e of document.querySelectorAll('link[rel="modulepreload"]'))i(e);new MutationObserver(e=>{for(const r of e)if(r.type==="childList")for(const o of r.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&i(o)}).observe(document,{childList:!0,subtree:!0});function s(e){const r={};return e.integrity&&(r.integrity=e.integrity),e.referrerPolicy&&(r.referrerPolicy=e.referrerPolicy),e.crossOrigin==="use-credentials"?r.credentials="include":e.crossOrigin==="anonymous"?r.credentials="omit":r.credentials="same-origin",r}function i(e){if(e.ep)return;e.ep=!0;const r=s(e);fetch(e.href,r)}})();
|
package/dist/web/index.html
CHANGED
|
@@ -77,9 +77,9 @@
|
|
|
77
77
|
document.documentElement.setAttribute("data-density", density === "tightest" || density === "tighter" || density === "compact" || density === "comfortable" ? density : "default");
|
|
78
78
|
})();
|
|
79
79
|
</script>
|
|
80
|
-
<script type="module" crossorigin src="/assets/index-
|
|
81
|
-
<link rel="modulepreload" crossorigin href="/assets/app-
|
|
82
|
-
<link rel="stylesheet" crossorigin href="/assets/app-
|
|
80
|
+
<script type="module" crossorigin src="/assets/index-Ch4ks76J.js"></script>
|
|
81
|
+
<link rel="modulepreload" crossorigin href="/assets/app-DBsNB-2y.js">
|
|
82
|
+
<link rel="stylesheet" crossorigin href="/assets/app-BiJ8TfUl.css">
|
|
83
83
|
</head>
|
|
84
84
|
<body>
|
|
85
85
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccdeck",
|
|
3
|
-
"version": "3.43.
|
|
3
|
+
"version": "3.43.4",
|
|
4
4
|
"description": "Local dashboard for Claude Code and Codex CLI sessions: which Claude Code session needs input, every tool call and Claude Code subagent live, token cost and quota, several Claude accounts. Run npx ccdeck.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/release-notes.json
CHANGED
|
@@ -77,7 +77,14 @@
|
|
|
77
77
|
"3.39.4",
|
|
78
78
|
"3.40.1",
|
|
79
79
|
"3.41.3",
|
|
80
|
-
"3.43.1"
|
|
80
|
+
"3.43.1",
|
|
81
|
+
"3.43.3"
|
|
82
|
+
],
|
|
83
|
+
"3.43.4": [
|
|
84
|
+
{
|
|
85
|
+
"title": "🔐 Local network no longer sends the login a deck is signed in to",
|
|
86
|
+
"body": "Two machines on one Claude login race to renew it, and the one that renews second is signed out with “Login expired”. So paired decks no longer receive the login a deck is signed in to right now. Every other login you tick is shared as before, and this one goes out once the deck switches to another account.\n\nUpdate every paired deck: an older deck still sends the login it is using, and the decks paired with it still take it. To send it anyway, as before, turn on Send the login this deck is using too in the Local network dialog. Not covered yet: a paired deck that already holds a copy of that login, from before this deck switched to it, can still renew it first and sign this deck out. Auto-switch can also still move a deck onto a login another paired deck is using, if the deck already holds a copy of it."
|
|
87
|
+
}
|
|
81
88
|
],
|
|
82
89
|
"3.43.0": [
|
|
83
90
|
{
|
|
@@ -95,7 +95,7 @@ export function readingCurrent(account, models, now) {
|
|
|
95
95
|
|
|
96
96
|
/**
|
|
97
97
|
* Whether Auto-switch may land on this account: signed in, in rotation, not
|
|
98
|
-
*
|
|
98
|
+
* reserved by a local `cswap run` session, not one claude-swap has
|
|
99
99
|
* quarantined, read recently, and comfortably under the threshold on every
|
|
100
100
|
* window that counts. An account with no 5-hour or weekly window (an API key)
|
|
101
101
|
* is never a target here; the engine still decides about those.
|
|
@@ -140,11 +140,12 @@ function soonestWeekly(accounts, ctx, before = null) {
|
|
|
140
140
|
* somebody just set a home and expects to be taken there.
|
|
141
141
|
* threshold the engine's, in percent
|
|
142
142
|
* models countedModels(...) of the engine's model setting
|
|
143
|
-
* busy keys of
|
|
144
|
-
*
|
|
143
|
+
* busy keys of accounts reserved by local `cswap run` sessions;
|
|
144
|
+
* activity reported by other paired devices does not exclude them
|
|
145
145
|
* quarantined keys of the accounts claude-swap's engine has quarantined
|
|
146
146
|
* sinceMove ms since the last move, the engine's or this one's
|
|
147
|
-
* cooldownMs the engine's cooldown
|
|
147
|
+
* cooldownMs the engine's cooldown for proactive moves; fully exhausted
|
|
148
|
+
* windows can be escaped immediately
|
|
148
149
|
* optional whether moves below the threshold may be made at all: only
|
|
149
150
|
* while the engine itself is on `best`
|
|
150
151
|
*
|
|
@@ -154,7 +155,7 @@ export function chooseAccount({ accounts, choice, threshold, models = [], busy =
|
|
|
154
155
|
const next = choice?.next === "weekly" ? "weekly" : "room";
|
|
155
156
|
const homeKey = choice?.home ?? null;
|
|
156
157
|
if (next === "room" && !homeKey) return null;
|
|
157
|
-
if (
|
|
158
|
+
if (!Number.isFinite(threshold)) return null;
|
|
158
159
|
|
|
159
160
|
const active = accounts.find(a => a.active);
|
|
160
161
|
// The engine reads the account it is on every minute or so; if the store
|
|
@@ -162,6 +163,9 @@ export function chooseAccount({ accounts, choice, threshold, models = [], busy =
|
|
|
162
163
|
if (!active || !readingCurrent(active, models, now)) return null;
|
|
163
164
|
const used = usedOf(active, models);
|
|
164
165
|
if (used == null) return null;
|
|
166
|
+
// Like the engine, an exhausted window must be escapable immediately.
|
|
167
|
+
// Ordinary threshold moves and optional returns still keep the cooldown.
|
|
168
|
+
if (sinceMove < cooldownMs && used < 100) return null;
|
|
165
169
|
|
|
166
170
|
const ctx = { threshold, models, busy, quarantined, now };
|
|
167
171
|
const home = homeKey ? accounts.find(a => a.key === homeKey) ?? null : null;
|
|
@@ -30,7 +30,7 @@ import { backupRoot, fetchClaudeAccounts, slotNumber, switchClaudeAccount } from
|
|
|
30
30
|
import { readCswapConfig } from "./cswap-auto-readers.mjs";
|
|
31
31
|
import { accountKey } from "./lan-copies.mjs";
|
|
32
32
|
import { isProcessAlive } from "./deck-probe.mjs";
|
|
33
|
-
import { chooseAccount, countedModels, NEXT_CHOICES } from "./account-choice.mjs";
|
|
33
|
+
import { chooseAccount, countedModels, readingCurrent, NEXT_CHOICES } from "./account-choice.mjs";
|
|
34
34
|
|
|
35
35
|
/** Where the choice is kept: beside prefs.json, in the deck's own data. */
|
|
36
36
|
export function choicePath(home = deckDataDir()) {
|
|
@@ -146,26 +146,6 @@ async function engineState(accounts) {
|
|
|
146
146
|
};
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
-
/** The accounts paired decks have recently said they are working on.
|
|
150
|
-
* One login live on two machines races for every renewal (README, "One login
|
|
151
|
-
* live in one place"), so a tick never moves onto one of them. The LAN table
|
|
152
|
-
* retains old manifests for display after a peer goes quiet; those are not
|
|
153
|
-
* current reservations. Use the authenticated manifest's receipt time, not a
|
|
154
|
-
* beacon or a failed round, which cannot confirm the account still in use. */
|
|
155
|
-
async function accountsOnOtherDecks(now) {
|
|
156
|
-
try {
|
|
157
|
-
const [{ lanEngine }, { PRESENT_MS }] = await Promise.all([
|
|
158
|
-
import("./lan-deck.mjs"), import("./lan-sync.mjs"),
|
|
159
|
-
]);
|
|
160
|
-
const peers = lanEngine.status()?.peers ?? [];
|
|
161
|
-
return new Set(peers
|
|
162
|
-
.filter(p => p?.paired === true && Number.isFinite(p.offers?.at) && p.offers.at > 0 && now - p.offers.at < PRESENT_MS)
|
|
163
|
-
.map(p => p.offers.current?.key).filter(k => typeof k === "string"));
|
|
164
|
-
} catch {
|
|
165
|
-
return new Set();
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
149
|
/** The name claude-swap gives an account's `cswap run` profile after its
|
|
170
150
|
* slot number: the email, NFC, with anything but ASCII letters, digits and
|
|
171
151
|
* `._-` turned into `_` (claude_swap/session.py, slugify_email). */
|
|
@@ -219,7 +199,7 @@ const refOf = a => (a ? { number: a.num, email: a.email ?? null } : null);
|
|
|
219
199
|
* roster it decided on, which noteEngineTick needs to name where the engine
|
|
220
200
|
* went, and null when nothing is set.
|
|
221
201
|
*/
|
|
222
|
-
export async function chooseBeforeEngine({ now = Date.now() } = {}) {
|
|
202
|
+
export async function chooseBeforeEngine({ now = Date.now(), refreshHome = true } = {}) {
|
|
223
203
|
const choice = await readChoice();
|
|
224
204
|
if (choice.next === "room" && !choice.home) return { result: null, accounts: null };
|
|
225
205
|
const roster = await fetchClaudeAccounts();
|
|
@@ -244,26 +224,39 @@ export async function chooseBeforeEngine({ now = Date.now() } = {}) {
|
|
|
244
224
|
const threshold = numberOr(cfg["autoswitch.threshold"]?.value, 90);
|
|
245
225
|
const models = countedModels(cfg["autoswitch.model"]?.value);
|
|
246
226
|
const engine = await engineState(accounts);
|
|
227
|
+
const lastMoveAt = Math.max(choice.lastMoveAt, engine.lastSwitchAt);
|
|
228
|
+
const cooldownMs = numberOr(cfg["autoswitch.cooldownSeconds"]?.value, 300) * 1000;
|
|
229
|
+
const cooldownUntil = lastMoveAt + cooldownMs;
|
|
247
230
|
const pick = chooseAccount({
|
|
248
231
|
accounts,
|
|
249
232
|
choice: { next: choice.next, home: choice.home?.key ?? null, manual, goHome },
|
|
250
233
|
threshold,
|
|
251
234
|
models,
|
|
252
|
-
busy:
|
|
235
|
+
busy: await accountsInCswapRun(accounts),
|
|
253
236
|
quarantined: engine.quarantined,
|
|
254
|
-
sinceMove: now -
|
|
255
|
-
cooldownMs
|
|
237
|
+
sinceMove: now - lastMoveAt,
|
|
238
|
+
cooldownMs,
|
|
256
239
|
optional: (cfg["autoswitch.strategy"]?.value ?? "best") === "best",
|
|
257
240
|
now,
|
|
258
241
|
});
|
|
259
242
|
if (!pick) {
|
|
260
243
|
await updateChoice(keep);
|
|
261
|
-
return { result: null, accounts };
|
|
244
|
+
return { result: null, accounts, cooldownUntil };
|
|
245
|
+
}
|
|
246
|
+
// Give Home one collection pass before a fresh weekly candidate can
|
|
247
|
+
// preempt it. The loop retries with refreshHome=false after its dry run:
|
|
248
|
+
// a failed/backed-off collection must not prevent leaving a full account.
|
|
249
|
+
const home = choice.home ? accounts.find(a => a.key === choice.home.key) : null;
|
|
250
|
+
if (refreshHome && pick.trigger !== "home" && home && !home.active &&
|
|
251
|
+
!home.disabled && home.alive !== false && !home.error && !home.reauth &&
|
|
252
|
+
!readingCurrent(home, models, now)) {
|
|
253
|
+
await updateChoice(keep);
|
|
254
|
+
return { result: null, accounts, cooldownUntil };
|
|
262
255
|
}
|
|
263
256
|
const moved = await switchClaudeAccount(pick.to.num);
|
|
264
257
|
if (!moved.ok) {
|
|
265
258
|
await updateChoice(keep);
|
|
266
|
-
return { result: null, accounts };
|
|
259
|
+
return { result: null, accounts, cooldownUntil };
|
|
267
260
|
}
|
|
268
261
|
// The move happened, so where it left the user and when are true whatever
|
|
269
262
|
// was set meanwhile; a home still to be gone to is the current choice's.
|
|
@@ -116,8 +116,20 @@ async function runAutoTick() {
|
|
|
116
116
|
if (!_enabled) return { skipped: "disabled" };
|
|
117
117
|
if (await externalAutoRunning()) return { skipped: "external-engine" };
|
|
118
118
|
invalidateClaudeAccountsCache();
|
|
119
|
-
chose = await chooseBeforeEngine().catch(() => ({ result: null, accounts: null }));
|
|
119
|
+
chose = await chooseBeforeEngine({ refreshHome: false }).catch(() => ({ result: null, accounts: null }));
|
|
120
120
|
if (chose.result) return { chose: chose.result };
|
|
121
|
+
// A deck-managed `cswap switch` does not advance the engine's own
|
|
122
|
+
// lastSwitchAt. Its fallback must keep our cooldown too, or a manual
|
|
123
|
+
// return to the full account can immediately escape the weekly rule
|
|
124
|
+
// through the engine's most-room choice. Still allow emergency exits.
|
|
125
|
+
const decision = summarise(preview.stdout);
|
|
126
|
+
if (Date.now() < chose.cooldownUntil && decision.trigger !== "at-limit" && decision.trigger !== "failover") {
|
|
127
|
+
return { chose: {
|
|
128
|
+
ok: true, ...decision, event: "no-switch", switched: false,
|
|
129
|
+
trigger: null, reason: "cooldown", from: null, to: null,
|
|
130
|
+
detail: "Waiting for the account-switch cooldown",
|
|
131
|
+
} };
|
|
132
|
+
}
|
|
121
133
|
}
|
|
122
134
|
const out = await run(await cswapBin(), ["auto", "--once", "--json"], { timeout: TICK_TIMEOUT_MS });
|
|
123
135
|
return { ...out, accounts: chose.accounts };
|
|
@@ -158,6 +158,16 @@ export const DEFAULTS = Object.freeze({
|
|
|
158
158
|
// of one person's machines show each other where they are working; off, and
|
|
159
159
|
// paired decks read "current account hidden" instead.
|
|
160
160
|
shareActive: true,
|
|
161
|
+
// THE LOGIN THIS DECK IS USING, SENT TO A PAIRED DECK, off unless somebody
|
|
162
|
+
// turns it on (#2192). A ticked account goes to every paired deck that
|
|
163
|
+
// lacks it or holds an expired copy — and when it is the one this deck is
|
|
164
|
+
// signed in to, the two machines then hold one login. A Claude login renews
|
|
165
|
+
// once: whichever copy renews it first keeps it, and the other machine is
|
|
166
|
+
// signed out. So while this deck is on an account, that account stays
|
|
167
|
+
// here, and goes out like any other the moment this deck switches away. A
|
|
168
|
+
// new field, so nobody's saved choice is changed: the ticks are untouched,
|
|
169
|
+
// and what they hand out is every login except the one in use.
|
|
170
|
+
sendInUse: false,
|
|
161
171
|
// FINDING THIS PERSON'S OTHER MACHINES OVER TAILSCALE, off until they turn
|
|
162
172
|
// it on: it is a new path off this machine, and the owner chooses it. Its
|
|
163
173
|
// two permissions ship on, like the local pair above, and they are narrower
|
|
@@ -298,6 +308,9 @@ function normaliseLan(raw) {
|
|
|
298
308
|
// Whether paired decks are told which shared account this one is on. Absent
|
|
299
309
|
// is on — the default above — and only a real boolean turns it off.
|
|
300
310
|
shareActive: flagOr(src.shareActive, DEFAULTS.lan.shareActive),
|
|
311
|
+
// Whether the login in use here may be sent. Off unless a real boolean
|
|
312
|
+
// says on.
|
|
313
|
+
sendInUse: flagOr(src.sendInUse, DEFAULTS.lan.sendInUse),
|
|
301
314
|
// The Tailscale switch and its two permissions. Only a real boolean
|
|
302
315
|
// overrides a default, as above.
|
|
303
316
|
tailscale: flagOr(src.tailscale, DEFAULTS.lan.tailscale),
|
|
@@ -707,7 +720,7 @@ export function publicPrefs(prefs) {
|
|
|
707
720
|
export const PAGE_FIELDS = Object.freeze({
|
|
708
721
|
top: Object.freeze(["notifications", "notifySwap", "notifyQuota", "notifyReset", "tourSeen", "autoUpdate", "recoverCodexSessions", "recoverClaudeSessions"]),
|
|
709
722
|
lan: Object.freeze([
|
|
710
|
-
"enabled", "name", "shared", "manual", "shareActive", "pairingMode",
|
|
723
|
+
"enabled", "name", "shared", "manual", "shareActive", "sendInUse", "pairingMode",
|
|
711
724
|
"autoAsk", "autoAccept", "tailscale", "tailscaleAsk", "tailscaleAccept",
|
|
712
725
|
]),
|
|
713
726
|
});
|
|
@@ -135,19 +135,52 @@ export function slotFor(accounts, key) {
|
|
|
135
135
|
* manifest entirely — not listed as withheld, which would tell the group that
|
|
136
136
|
* an account exists and is being kept back, and that is itself the fact being
|
|
137
137
|
* kept back.
|
|
138
|
+
*
|
|
139
|
+
* THE ONE THIS DECK IS ON IS LISTED AND NOT GIVEN, unless `sendInUse` says to
|
|
140
|
+
* (#2192). A Claude login renews once: the copy that renews it first keeps it,
|
|
141
|
+
* and every other copy is refused from then on — so a peer handed the login
|
|
142
|
+
* this deck is signed in to holds the same expiry, reaches for the same renewal,
|
|
143
|
+
* and one of the two machines is signed out ("Login expired · Please run
|
|
144
|
+
* /login"). The row stays in the list, because it is what `current` names and a
|
|
145
|
+
* paired deck keeps a `current` only for an account in the same list (see
|
|
146
|
+
* heardCurrent) — which is how the other deck knows this one is on it. It goes
|
|
147
|
+
* as `shareable: false`, which every peer since 3.29.0 already reads as "do not
|
|
148
|
+
* ask", and `inUse`, which a peer of this version reads as why. An older peer
|
|
149
|
+
* that asks anyway is refused by `serve` (lan-serve.mjs) with "in use".
|
|
150
|
+
*
|
|
151
|
+
* NOT LISTED AT ALL WHILE ITS OWNER HIDES WHICH ACCOUNT THIS DECK IS ON
|
|
152
|
+
* (`shareActive` false). Then `current` names nothing, so the row has no reason
|
|
153
|
+
* to be there, and it would say exactly what the switch keeps back: `inUse`
|
|
154
|
+
* names it, and even a bare `shareable: false` on the one ticked login that
|
|
155
|
+
* works here points at it. Left out, it reads as a login not ticked — the
|
|
156
|
+
* fact this list already keeps back (see `shared` above) — and it is still
|
|
157
|
+
* never sent: nobody asks for a login that is not listed, and `serve` refuses
|
|
158
|
+
* one that is asked for anyway, in the words it refuses an unticked one.
|
|
138
159
|
*/
|
|
139
|
-
export function manifestFor(accounts, shared) {
|
|
160
|
+
export function manifestFor(accounts, shared, { sendInUse = false, shareActive } = {}) {
|
|
140
161
|
const want = new Set(shared);
|
|
141
|
-
|
|
162
|
+
// Which identity is live here, whichever of its slots onePerKey picks: two
|
|
163
|
+
// slots for one login are one login.
|
|
164
|
+
const live = new Set(sendInUse ? [] : accounts.filter(a => a.active).map(a => a.key));
|
|
165
|
+
const hidden = shareActive === false;
|
|
166
|
+
return onePerKey(accounts.filter(a => want.has(a.key) && !(hidden && live.has(a.key))))
|
|
142
167
|
.map(a => ({
|
|
143
168
|
key: a.key,
|
|
144
169
|
email: a.email,
|
|
145
170
|
alive: !!a.alive,
|
|
146
|
-
...(a.readable === false ? { shareable: false } : {}),
|
|
171
|
+
...(a.readable === false || live.has(a.key) ? { shareable: false } : {}),
|
|
172
|
+
...(live.has(a.key) ? { inUse: true } : {}),
|
|
147
173
|
}))
|
|
148
174
|
.sort((a, b) => a.key.localeCompare(b.key));
|
|
149
175
|
}
|
|
150
176
|
|
|
177
|
+
/** Whether a peer that asks may be handed this identity now: every one but the
|
|
178
|
+
* one this deck is on, unless the owner chose to send that too — the same
|
|
179
|
+
* rule manifestFor lists by, checked again at the moment of sending. */
|
|
180
|
+
export function mayGiveInUse(accounts, key, sendInUse) {
|
|
181
|
+
return sendInUse === true || !accounts.some(a => a.key === key && a.active);
|
|
182
|
+
}
|
|
183
|
+
|
|
151
184
|
/**
|
|
152
185
|
* Which account this deck is on, as a frame field for the decks it is paired
|
|
153
186
|
* with — or nothing.
|
|
@@ -201,6 +234,9 @@ export function offered(list) {
|
|
|
201
234
|
alive: storedCopyAlive(a.alive, a.collector),
|
|
202
235
|
// Additive wire field: an older peer omitted it, which means shareable.
|
|
203
236
|
...(a.shareable === false ? { shareable: false } : {}),
|
|
237
|
+
// Additive too (#2192): that deck is on this login and keeps it there.
|
|
238
|
+
// Only beside `shareable: false`, which is what stops a round asking.
|
|
239
|
+
...(a.shareable === false && a.inUse === true ? { inUse: true } : {}),
|
|
204
240
|
}))
|
|
205
241
|
.filter(a => a.key && a.email))
|
|
206
242
|
// Fifty IDENTITIES, so the cap is spent after onePerKey rather than before
|
|
@@ -238,8 +274,10 @@ export const SENDER_UNREADABLE = "keychain_unavailable";
|
|
|
238
274
|
|
|
239
275
|
/** The reasons `serve` gives for a login it will not send. Its other answer to
|
|
240
276
|
* a `want`, "not paired", is not in the set, so a deck that gets it records
|
|
241
|
-
* "refused".
|
|
242
|
-
|
|
277
|
+
* "refused". "in use" is the sending deck being on that login itself (#2192);
|
|
278
|
+
* a deck older than that records it as "refused", which is all it proves. */
|
|
279
|
+
export const IN_USE = "in use";
|
|
280
|
+
const WIRE_REFUSALS = new Set(["proof", "not shared", "not mine to give", "export failed", SENDER_UNREADABLE, IN_USE, "error"]);
|
|
243
281
|
|
|
244
282
|
/**
|
|
245
283
|
* A peer's refusal, as this deck records it. The panel prints these, so a peer
|
package/src/server/lan-deck.mjs
CHANGED
|
@@ -352,6 +352,9 @@ export function lanApplyFields(prefs, { load = false, env = process.env } = {})
|
|
|
352
352
|
pairingMode: lan.pairingMode === "invite" ? "invite" : "automatic",
|
|
353
353
|
// Whether paired decks are told which shared account this one is on.
|
|
354
354
|
shareActive: lan.shareActive !== false,
|
|
355
|
+
// Whether the account this deck is using right now may be sent to a paired
|
|
356
|
+
// deck. Off unless somebody turned it on (#2192).
|
|
357
|
+
sendInUse: lan.sendInUse === true,
|
|
355
358
|
// Discovery over Tailscale, off unless somebody turned it on, and the two
|
|
356
359
|
// permissions that answer for the owner's own machines there.
|
|
357
360
|
tailscale: lan.tailscale === true,
|
|
@@ -307,6 +307,9 @@ export function createEngine({
|
|
|
307
307
|
autoAsk: true, autoAccept: true, pairingMode: "automatic", aliases: {},
|
|
308
308
|
// Tell paired decks which shared account this one is on — see currentFor.
|
|
309
309
|
shareActive: true,
|
|
310
|
+
// Send the account this deck is on to a paired deck that asks — off, so
|
|
311
|
+
// one login is live in one place (#2192). See manifestFor and lan-serve.mjs.
|
|
312
|
+
sendInUse: false,
|
|
310
313
|
// DISCOVERY OVER TAILSCALE, off until somebody turns it on, and its own
|
|
311
314
|
// pair of permissions. They are separate from the two above because the
|
|
312
315
|
// tailnet is a different audience: the local switches answer for whoever
|
|
@@ -1474,6 +1477,8 @@ export function createEngine({
|
|
|
1474
1477
|
pairingMode: cfg.pairingMode === "invite" ? "invite" : "automatic",
|
|
1475
1478
|
// Whether paired decks are told which shared account this one is on.
|
|
1476
1479
|
shareActive: cfg.shareActive !== false,
|
|
1480
|
+
// Whether the account this deck is on may be sent to one (#2192).
|
|
1481
|
+
sendInUse: cfg.sendInUse === true,
|
|
1477
1482
|
// Discovery over Tailscale: whether this machine has it at all, which
|
|
1478
1483
|
// decides whether the dialog shows the switch, and what it can see.
|
|
1479
1484
|
tailscale: tailnet ? tailnetStatus() : null,
|
|
@@ -12,7 +12,7 @@ import { openAbout, sealAbout } from "./lan-about.mjs";
|
|
|
12
12
|
* is the engine's clock. `myFp` answers this deck's fingerprint as it is at
|
|
13
13
|
* the moment of asking — a restart for a new key changes it — and `settings`
|
|
14
14
|
* the settings in force, of which a manifest reads `shared`, `onward`,
|
|
15
|
-
* `trusted` and `
|
|
15
|
+
* `trusted`, `shareActive` and `sendInUse`.
|
|
16
16
|
*/
|
|
17
17
|
export function createManifests({ about, now, myFp, settings }) {
|
|
18
18
|
/** What each paired deck said about itself — version, operating system,
|
|
@@ -43,7 +43,7 @@ export function createManifests({ about, now, myFp, settings }) {
|
|
|
43
43
|
const cfg = settings();
|
|
44
44
|
const shared = sharedWith(cfg, toFp);
|
|
45
45
|
return {
|
|
46
|
-
t: "manifest", accounts: manifestFor(accounts, shared),
|
|
46
|
+
t: "manifest", accounts: manifestFor(accounts, shared, { sendInUse: cfg.sendInUse === true, shareActive: cfg.shareActive }),
|
|
47
47
|
...currentFor(accounts, shared, cfg.shareActive),
|
|
48
48
|
...cardFor(key, toFp),
|
|
49
49
|
};
|
package/src/server/lan-serve.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// answered at all. It keeps nothing of its own: every check reads the
|
|
6
6
|
// engine's settings, listener and key at the moment it is made, because any
|
|
7
7
|
// of them can change while the store is read.
|
|
8
|
-
import { credentialAad, seal, sharedWith, slotFor, transferChallenge, trustedPeer } from "./lan-sync.mjs";
|
|
8
|
+
import { credentialAad, IN_USE, mayGiveInUse, seal, sharedWith, slotFor, transferChallenge, trustedPeer } from "./lan-sync.mjs";
|
|
9
9
|
import { liveLoginIs } from "./account-health.mjs";
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -62,7 +62,12 @@ export function createServe({
|
|
|
62
62
|
// paired is less than everything ticked — see sharedWith.
|
|
63
63
|
const maySend = () => settings().enabled && !!serverNow() && !!trustedPeer(settings().trusted, ctx.peerFp)
|
|
64
64
|
&& sharedWith(settings(), ctx.peerFp).includes(msg.key);
|
|
65
|
-
|
|
65
|
+
// ONE ORDER, WHATEVER IS ASKED FOR: the proof, then the store, then
|
|
66
|
+
// whether that deck may have it (#2192). While its owner hides which
|
|
67
|
+
// account this deck is on, the login it is on is refused "not shared",
|
|
68
|
+
// as one not ticked is; refused before the proof and before the store,
|
|
69
|
+
// an unticked one answered a bad proof differently and sooner, which
|
|
70
|
+
// told the two apart.
|
|
66
71
|
// A SECOND PROOF, for the one operation that moves a credential. The
|
|
67
72
|
// session says who connected; this says they are asking for this
|
|
68
73
|
// account, now. A long-lived connection authenticated an hour ago is
|
|
@@ -83,6 +88,16 @@ export function createServe({
|
|
|
83
88
|
// live one behind it.
|
|
84
89
|
const mine = slotFor(accounts, msg.key);
|
|
85
90
|
if (!mine) return ctx.send({ t: "no", why: "not mine to give" });
|
|
91
|
+
// NOT THE LOGIN THIS DECK IS ON, unless its owner said so (#2192). The
|
|
92
|
+
// manifest already marked it not to be asked for, and a peer older than
|
|
93
|
+
// that mark asks anyway; this is the answer that does not depend on the
|
|
94
|
+
// asker's version, read off the same store read as everything below.
|
|
95
|
+
// While its owner hides which account this deck is on, the manifest
|
|
96
|
+
// leaves that login out, and the refusal is the one an unticked login
|
|
97
|
+
// gets: "in use" would name it.
|
|
98
|
+
if (!mayGiveInUse(accounts, msg.key, settings().sendInUse)) {
|
|
99
|
+
return ctx.send({ t: "no", why: settings().shareActive === false ? "not shared" : IN_USE });
|
|
100
|
+
}
|
|
86
101
|
// A LOGIN THIS DECK CANNOT READ IS SAID SO, from state rather than from
|
|
87
102
|
// a failed export: no subprocess, and nothing the CLI printed. The
|
|
88
103
|
// asking deck prints it under the account, so the person learns which
|
package/src/server/lan-sync.mjs
CHANGED
|
@@ -123,8 +123,8 @@ export {
|
|
|
123
123
|
// wire — see lan-copies.mjs. Re-exported, so what imports them from here still
|
|
124
124
|
// does.
|
|
125
125
|
export {
|
|
126
|
-
accountKey, currentFor, heardCurrent, manifestFor, offered, onePerKey, peerWhy, plan, slotFor,
|
|
127
|
-
HERE, SENDER_UNREADABLE,
|
|
126
|
+
accountKey, currentFor, heardCurrent, manifestFor, mayGiveInUse, offered, onePerKey, peerWhy, plan, slotFor,
|
|
127
|
+
syncAction, HERE, IN_USE, SENDER_UNREADABLE,
|
|
128
128
|
} from "./lan-copies.mjs";
|
|
129
129
|
|
|
130
130
|
/** Marks a packet as ours before anything reads a field of it. Four bytes of
|
|
@@ -31,8 +31,11 @@ export const CMD_MAX = 180;
|
|
|
31
31
|
const SECRET_FLAG =
|
|
32
32
|
/(-{1,2}[\w.-]*(?:token|password|passwd|secret|api[-_]?key|apikey|auth|credential|bearer|cookie|session[-_]?id|private[-_]?key)[\w.-]*)(=|\s+)(\S+)/gi;
|
|
33
33
|
// The shapes that are a secret on their own, with no flag in front of them.
|
|
34
|
+
// GitHub's fine-grained tokens and GitLab's prefixed ones (personal, deploy,
|
|
35
|
+
// runner, trigger…) too: `gh`, `glab`, git credential helpers and scripts put
|
|
36
|
+
// them on a command line as readily as the classic `ghp_` ones.
|
|
34
37
|
const BARE_SECRET =
|
|
35
|
-
/\b(?:sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{16,}|xox[baprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{12,}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,})/g;
|
|
38
|
+
/\b(?:sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{20,}|gl(?:pat|dt|rt|cbt|ptt|ft|imt|agent|oas|soat)-[A-Za-z0-9_.-]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{12,}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,})/g;
|
|
36
39
|
// user:password@host, in any URL. The password goes; the rest is a location.
|
|
37
40
|
const URL_CREDENTIALS = /\b([a-z][a-z0-9+.-]*:\/\/[^\s:@/]+):[^\s@/]+@/gi;
|
|
38
41
|
|
package/src/server/reports.mjs
CHANGED
|
@@ -477,8 +477,14 @@ export function scrub(text, home = homedir(), root = PKG_ROOT) {
|
|
|
477
477
|
.replace(new RegExp(ADDRESS_PATTERN, "g"), "<host>")
|
|
478
478
|
.replace(new RegExp(HOST_PATTERN, "g"), "<host>")
|
|
479
479
|
.replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, "<email>")
|
|
480
|
+
// An Authorization header's value, whatever its scheme carries — a token
|
|
481
|
+
// of 44 characters of base64, say, is short of the generic pattern below
|
|
482
|
+
// (found with #2178) — and an Atlassian API token, which always starts
|
|
483
|
+
// ATATT, at any length.
|
|
484
|
+
.replace(/\b(Bearer|Basic)\s+[A-Za-z0-9+/._~-]{12,}=*/g, "$1 <secret>")
|
|
485
|
+
.replace(/\bATATT[A-Za-z0-9+/_=-]{8,}/g, "<secret>")
|
|
480
486
|
.replace(
|
|
481
|
-
/\b(?:sk-ant-[A-Za-z0-9_-]{8,}|sk-[A-Za-z0-9_-]{20,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[abpr]-[A-Za-z0-9-]{10,}|eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}|[A-Fa-f0-9]{40,}|[A-Za-z0-9+_=-]{48,})/g,
|
|
487
|
+
/\b(?:sk-ant-[A-Za-z0-9_-]{8,}|sk-[A-Za-z0-9_-]{20,}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|gl(?:pat|dt|rt|cbt|ptt|ft|imt|agent|oas|soat)-[A-Za-z0-9_.-]{20,}|xox[abpr]-[A-Za-z0-9-]{10,}|eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}|[A-Fa-f0-9]{40,}|[A-Za-z0-9+_=-]{48,})/g,
|
|
482
488
|
"<secret>",
|
|
483
489
|
);
|
|
484
490
|
}
|
|
@@ -151,16 +151,17 @@ export function createWireCapture({ host, port, onExport, onResponse, onIssue, o
|
|
|
151
151
|
const side = dst === host && dstPort === port ? 'out' : src === host && srcPort === port ? 'in' : null;
|
|
152
152
|
if (!side) return;
|
|
153
153
|
const key = side === 'out' ? `${src}:${srcPort}` : `${dst}:${dstPort}`;
|
|
154
|
-
for (const [k, f] of flows) if (at - f.at > 60_000) discard(k, 'idle_connection');
|
|
155
154
|
let flow = flows.get(key);
|
|
156
155
|
const syn = !!(t[13] & 2), ack = !!(t[13] & 16);
|
|
157
156
|
if (syn && !ack) flows.delete(key);
|
|
158
157
|
if (!flow || (syn && !ack)) {
|
|
159
158
|
if (flows.size >= 16) discard(flows.keys().next().value, 'connection_limit');
|
|
160
|
-
flow = { key,
|
|
159
|
+
flow = { key, ready: false, out: direction(), in: direction(), streams: new Map(), ignored: false };
|
|
161
160
|
flows.set(key, flow);
|
|
162
161
|
}
|
|
163
|
-
|
|
162
|
+
// Quiet HTTP/2 connections still need their preface and HPACK state for the
|
|
163
|
+
// next export. Bound retention by connection count, evicting the least used.
|
|
164
|
+
flows.delete(key); flows.set(key, flow);
|
|
164
165
|
const size = (t[12] >> 4) * 4;
|
|
165
166
|
if (size < 20 || size > t.length) { discard(key, 'capture_gap'); return; }
|
|
166
167
|
const body = t.subarray(size), d = flow[side];
|