ccdeck 3.43.3 → 3.44.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 +74 -20
- package/THIRD_PARTY_NOTICES.md +43 -7
- package/bin/cli/commands.js +613 -0
- package/bin/cli/help.js +26 -3
- package/bin/cli/one-shot.js +2 -2
- package/bin/cli/picker.js +86 -0
- package/bin/cli/startup.js +13 -0
- package/bin/deck.js +60 -16
- package/dist/web/assets/{BrowserWatchModal-DZTMfoGp.js → BrowserWatchModal-DMIUygWz.js} +1 -1
- package/dist/web/assets/{RadioPanel-DFqW-MUV.js → RadioPanel-DNB_AHSs.js} +1 -1
- package/dist/web/assets/TrafficRadar-Ch0LVdSl.js +8 -0
- package/dist/web/assets/UsageHistoryModal-Du0ZkIJ1.js +1 -0
- package/dist/web/assets/app-CQIstkMF.js +318 -0
- package/dist/web/assets/app-Ch-0CYlK.css +1 -0
- package/dist/web/assets/{index-DqxnkNs2.js → index-BQbOfxbL.js} +1 -1
- package/dist/web/index.html +3 -3
- package/package.json +2 -1
- package/release-notes.json +40 -0
- package/src/server/account-notify.mjs +206 -9
- package/src/server/account-pick.mjs +160 -0
- package/src/server/account-projects.mjs +55 -15
- package/src/server/account-routes.mjs +47 -4
- package/src/server/account-watch.mjs +48 -4
- package/src/server/activity.mjs +3 -2
- package/src/server/args.mjs +112 -2
- package/src/server/autoswitch-failover.mjs +138 -0
- package/src/server/claude-accounts.mjs +115 -14
- package/src/server/claude-dir.mjs +142 -5
- package/src/server/claude-identity.mjs +23 -0
- package/src/server/cli-lines.mjs +224 -0
- package/src/server/codex-cost-usage.mjs +67 -0
- package/src/server/codex-enrichment.mjs +53 -9
- package/src/server/codex-review-policy.d.mts +4 -0
- package/src/server/codex-review-policy.mjs +11 -0
- package/src/server/codex-skills.mjs +146 -0
- package/src/server/codex-watch.mjs +5 -2
- package/src/server/cswap-auto-choice.mjs +53 -0
- package/src/server/cswap-auto-loop.mjs +100 -9
- package/src/server/deck-client.mjs +112 -0
- package/src/server/deck-prefs.mjs +30 -6
- package/src/server/event-pipeline.mjs +19 -3
- package/src/server/event-routes.mjs +15 -0
- package/src/server/exec.mjs +6 -2
- package/src/server/index.mjs +17 -1
- package/src/server/installer.mjs +149 -8
- package/src/server/lan-copies.mjs +43 -5
- package/src/server/lan-deck.mjs +3 -0
- package/src/server/lan-engine.mjs +7 -2
- package/src/server/lan-manifest.mjs +2 -2
- package/src/server/lan-serve.mjs +17 -2
- package/src/server/lan-sync.mjs +5 -5
- package/src/server/lan-wire.mjs +3 -3
- package/src/server/login-guard.mjs +162 -0
- package/src/server/login-service.mjs +3 -0
- package/src/server/pinned-build.mjs +5 -0
- package/src/server/prefs-routes.mjs +4 -0
- package/src/server/process-command.mjs +4 -1
- package/src/server/reports.mjs +7 -1
- package/src/server/request-gates.mjs +7 -0
- package/src/server/session-read-gate.mjs +5 -3
- package/src/server/session-summary.mjs +281 -0
- package/src/server/skill-hook.mjs +162 -0
- package/src/server/stop-continue-gate.d.mts +8 -0
- package/src/server/stop-continue-gate.mjs +77 -0
- package/src/server/stop-continue.mjs +544 -0
- package/src/server/stop-failure.d.mts +10 -0
- package/src/server/stop-failure.mjs +97 -0
- package/src/server/swap-log.mjs +19 -10
- package/src/server/tool-audit-report.mjs +5 -3
- package/src/server/tool-audit-routes.mjs +5 -3
- package/src/server/tool-audit-skills.mjs +82 -0
- package/src/server/tool-audit.mjs +53 -11
- package/src/server/traffic-radar-capture.mjs +10 -1
- package/src/server/traffic-radar-monitor.mjs +4 -2
- package/src/server/traffic-radar-routes.mjs +13 -0
- package/src/server/traffic-radar-service.mjs +133 -0
- package/src/server/traffic-radar-wire.mjs +4 -3
- package/src/server/usage-day.mjs +1 -1
- package/dist/web/assets/TrafficRadar-CPzsjc2O.js +0 -1
- package/dist/web/assets/UsageHistoryModal-CfVS-N_Z.js +0 -1
- package/dist/web/assets/app-DsHSufBt.css +0 -1
- package/dist/web/assets/app-HpFVXK_g.js +0 -310
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,
|
|
@@ -107,7 +107,7 @@ export function defaultName() {
|
|
|
107
107
|
*
|
|
108
108
|
* It returned the first one, and that was wrong the first time somebody
|
|
109
109
|
* checked: on this machine the first is 192.168.1.82 and the deck it needs to
|
|
110
|
-
* reach is on Tailscale at 100.
|
|
110
|
+
* reach is on Tailscale at 100.64.0.100, so the panel would have offered an
|
|
111
111
|
* address that peer cannot route to and left them to work out why.
|
|
112
112
|
*
|
|
113
113
|
* WHICH ONE IS RIGHT DEPENDS ON WHERE THE PEER IS, which this side cannot
|
|
@@ -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
|
|
@@ -651,7 +654,7 @@ export function createEngine({
|
|
|
651
654
|
// WHO IS ACTUALLY THERE. A typed address is a row that says `192.168.1.5:54340`
|
|
652
655
|
// and nothing else until somebody answers it — and once one has, the deck
|
|
653
656
|
// on the other end has told us what it calls itself. The row says that
|
|
654
|
-
// from then on, because "
|
|
657
|
+
// from then on, because "Consultant-PC" is what the person who typed the
|
|
655
658
|
// address was trying to reach.
|
|
656
659
|
//
|
|
657
660
|
// A DIAL-BACK THAT ANSWERED IS AN ORDINARY PEER NOW. It was on trial only
|
|
@@ -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
|
|
@@ -288,8 +288,8 @@ function machineKey() {
|
|
|
288
288
|
* The part of a hostname that names the machine, rather than the network it is
|
|
289
289
|
* on at the moment.
|
|
290
290
|
*
|
|
291
|
-
* macOS answers `os.hostname()` with `
|
|
292
|
-
* and `
|
|
291
|
+
* macOS answers `os.hostname()` with `Oswald-MacBook-Pro.local` at one moment
|
|
292
|
+
* and `Oswald-MacBook-Pro` at another, and a DHCP server can hang its own
|
|
293
293
|
* domain on the end — `.lan`, `.home`, `.fritz.box`. hostId hashed whatever it
|
|
294
294
|
* was handed, so two decks on one computer started either side of such a change
|
|
295
295
|
* disagreed about which machine they were on. Sharing a key, each read the other
|
|
@@ -418,7 +418,7 @@ export function beaconVerdict(beacon, { selfFp, selfInstance, selfHost, formerHo
|
|
|
418
418
|
// taking a new key is the engine's to do.
|
|
419
419
|
//
|
|
420
420
|
// AND ONE MACHINE'S OWN TWIN IS NOT A CLASH, which is the whole of a defect
|
|
421
|
-
// reported as "
|
|
421
|
+
// reported as "Elliot pressed yes ten times and it keeps asking". Several
|
|
422
422
|
// decks on one computer share a config directory, so they start with one
|
|
423
423
|
// key; each read the others' beacons as somebody wearing its name, each
|
|
424
424
|
// took a fresh key, each wrote that key to the file the others read — and
|
package/src/server/lan-wire.mjs
CHANGED
|
@@ -397,9 +397,9 @@ export function credentialAad(fromFp, toFp, key) {
|
|
|
397
397
|
// two engines of the version before this, the round that healed one account
|
|
398
398
|
// and added another read, after `ok`:
|
|
399
399
|
//
|
|
400
|
-
// {"t":"manifest","accounts":[{"key":"
|
|
401
|
-
// {"t":"want","key":"
|
|
402
|
-
// {"t":"have","key":"
|
|
400
|
+
// {"t":"manifest","accounts":[{"key":"alice@example.net@@org-1","email":"alice@example.net","alive":true},…],"current":{"key":"alice@example.net@@org-1"}}
|
|
401
|
+
// {"t":"want","key":"bob@example.net@@org-2","nonce":"…","proof":"…"}
|
|
402
|
+
// {"t":"have","key":"bob@example.net@@org-2","sealed":{…}}
|
|
403
403
|
//
|
|
404
404
|
// So anybody on the same network segment could read every address a deck
|
|
405
405
|
// shares, which of its copies are broken, which one the machine is on, and
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Never put a dead login under every session on the machine (#2184).
|
|
2
|
+
//
|
|
3
|
+
// Every Claude Code session on a machine shares one login. `cswap switch N`
|
|
4
|
+
// writes slot N's stored copy over it, and when that copy's refresh token is
|
|
5
|
+
// already dead every running session fails within seconds with "Login expired ·
|
|
6
|
+
// Please run /login". On the owner's machine that was three of the seven login
|
|
7
|
+
// interruptions in the month before this was written.
|
|
8
|
+
//
|
|
9
|
+
// claude-swap's own `auto` engine refreshes a target before it activates it and
|
|
10
|
+
// quarantines one whose refresh is refused. A plain `cswap switch` does neither,
|
|
11
|
+
// and that is the command the deck runs. So the deck asks claude-swap's store
|
|
12
|
+
// first, and refuses only on evidence: a dead-login error on the slot's own
|
|
13
|
+
// usage row, newer than the last good read and the last sign-in. Anything it
|
|
14
|
+
// cannot tell — no row, a row for somebody else, a store it cannot read — lets
|
|
15
|
+
// the switch go ahead exactly as it did before.
|
|
16
|
+
//
|
|
17
|
+
// WHY NOT ASK CLAUDE-SWAP ABOUT ONE SLOT. 0.26.0 has no command that checks a
|
|
18
|
+
// single account. `cswap list --json` is the nearest: a collection over every
|
|
19
|
+
// account that is due, under claude-swap's own locks, which refreshes an
|
|
20
|
+
// expired stored token through its one-time-grant gate and strikes the slot
|
|
21
|
+
// when the refresh is refused. It never re-reads a slot it has already struck,
|
|
22
|
+
// and it skips one its plan says is not due. So the deck asks for one only when
|
|
23
|
+
// it would actually read the target (collectionWouldRead) — the 2026-10-12
|
|
24
|
+
// incident was exactly that case: slot 5 last read ten minutes earlier, its
|
|
25
|
+
// poll overdue, and the refusal that proved its login dead recorded one second
|
|
26
|
+
// AFTER the switch had put it live.
|
|
27
|
+
//
|
|
28
|
+
// Pure: no file, no clock, no subprocess. claude-accounts.mjs reads the store
|
|
29
|
+
// and asks these, and account-routes.mjs hands removeLeavingLiveLogin its three
|
|
30
|
+
// steps.
|
|
31
|
+
|
|
32
|
+
/** claude-swap's two words for a stored refresh token only a sign-in can
|
|
33
|
+
* replace — its PERMANENT_AUTH_ERRORS. The panel says "login expired". */
|
|
34
|
+
export const DEAD_LOGIN_ERRORS = new Set(["invalid_grant", "no_refresh_token"]);
|
|
35
|
+
|
|
36
|
+
/** claude-swap's SERVE_TTL_S: younger than this, an on-demand collection
|
|
37
|
+
* serves the row from the store and fetches nothing. */
|
|
38
|
+
const SERVE_TTL_MS = 180_000;
|
|
39
|
+
|
|
40
|
+
/** A claude-swap timestamp (epoch seconds) in ms, or null. */
|
|
41
|
+
function ms(seconds) {
|
|
42
|
+
const v = typeof seconds === "number" ? seconds * 1000 : NaN;
|
|
43
|
+
return Number.isFinite(v) && v > 0 ? v : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Whether a slot's stored login is KNOWN dead, from its usage row.
|
|
48
|
+
*
|
|
49
|
+
* `matches` is rowIsFor's answer — a row left by the slot's previous occupant
|
|
50
|
+
* says nothing about this one. `signedInAt` is when the deck last signed this
|
|
51
|
+
* account in, in ms, or null.
|
|
52
|
+
*
|
|
53
|
+
* Dead is claude-swap's strike (`authDeadStrikes`, set by an `invalid_grant` or
|
|
54
|
+
* `no_refresh_token` and lifted by a good read or by any write of a new
|
|
55
|
+
* credential — add, import, a sign-in) or that error on a failing row, from a
|
|
56
|
+
* store that predates the strike count. And only when the refusal is the newest
|
|
57
|
+
* thing known about the login: a good read or a sign-in after it is about a
|
|
58
|
+
* login that has since worked. Transient errors — a 429, a timeout, a network
|
|
59
|
+
* that is not there — are never evidence either way.
|
|
60
|
+
*
|
|
61
|
+
* Answers `{ error, since }`, `since` being when the refusal was recorded (ms,
|
|
62
|
+
* or null when the row does not say), or null.
|
|
63
|
+
*/
|
|
64
|
+
export function deadLoginOf(row, { matches = false, signedInAt = null } = {}) {
|
|
65
|
+
if (!matches || !row || typeof row !== "object") return null;
|
|
66
|
+
const struck = Number(row.authDeadStrikes) >= 1;
|
|
67
|
+
const refused = Number(row.consecutiveFailures) > 0 && DEAD_LOGIN_ERRORS.has(row.lastError);
|
|
68
|
+
if (!struck && !refused) return null;
|
|
69
|
+
const failedAt = ms(row.lastAttemptAt);
|
|
70
|
+
const goodAt = ms(row.fetchedAt);
|
|
71
|
+
if (failedAt != null && goodAt != null && goodAt > failedAt) return null;
|
|
72
|
+
if (failedAt != null && Number.isFinite(signedInAt) && signedInAt > failedAt) return null;
|
|
73
|
+
return { error: DEAD_LOGIN_ERRORS.has(row.lastError) ? row.lastError : "invalid_grant", since: failedAt };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Whether a `cswap list` asked for right now would read this row — claude-swap's
|
|
78
|
+
* own on-demand rule (usage_store._row_eligible with respect_plans), so the deck
|
|
79
|
+
* spends a collection on a switch only when it can change the answer.
|
|
80
|
+
*
|
|
81
|
+
* No row, or one for somebody else, is read on the first pass. A struck row is
|
|
82
|
+
* never read again, a row in backoff or claimed by another collector waits, and
|
|
83
|
+
* otherwise it must be both older than the serve TTL and past its planned poll.
|
|
84
|
+
*/
|
|
85
|
+
export function collectionWouldRead(row, { matches = false, now }) {
|
|
86
|
+
if (!matches || !row || typeof row !== "object") return true;
|
|
87
|
+
if (Number(row.authDeadStrikes) >= 1) return false;
|
|
88
|
+
const backoff = ms(row.backoffUntil);
|
|
89
|
+
if (backoff != null && backoff > now) return false;
|
|
90
|
+
const claim = ms(row.claimUntil);
|
|
91
|
+
if (claim != null && claim > now) return false;
|
|
92
|
+
const fetched = ms(row.fetchedAt);
|
|
93
|
+
const next = ms(row.nextPollAt);
|
|
94
|
+
return (fetched == null || now - fetched > SERVE_TTL_MS) && (next == null || now >= next);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── removing the active account ─────────────────────────────────────────────
|
|
98
|
+
//
|
|
99
|
+
// `cswap remove` deletes the slot's stored copy and its entry, and nothing else:
|
|
100
|
+
// it does not activate another slot, and Claude Code stays signed in as the
|
|
101
|
+
// account that was removed. When that login works, that is the right thing to
|
|
102
|
+
// leave alone — every session goes on running. When it is the expired one, the
|
|
103
|
+
// sessions are already failing and nothing would ever move them: auto-switch
|
|
104
|
+
// will not touch a login claude-swap no longer manages. So then, and only then,
|
|
105
|
+
// the deck moves Claude Code to a working account once the removal has landed.
|
|
106
|
+
// After it, the dead login belongs to no slot, so `cswap switch` activates the
|
|
107
|
+
// target directly instead of refusing to back up the empty login first.
|
|
108
|
+
|
|
109
|
+
/** The roster's active row when its login is one only a sign-in brings back —
|
|
110
|
+
* the panel's "Login expired" on the live account. Never a stale copy (#721):
|
|
111
|
+
* there the user IS signed in, and only claude-swap's copy is dead. */
|
|
112
|
+
export function activeLoginExpired(row) {
|
|
113
|
+
if (!row?.active || row.staleCopy) return false;
|
|
114
|
+
if (row.stopped) return row.collector === "relogin_required";
|
|
115
|
+
return DEAD_LOGIN_ERRORS.has(row.error);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The accounts Claude Code may be moved to, best first: working, not held
|
|
119
|
+
* out, and with nothing wrong on the row — the freshest reading first, then the
|
|
120
|
+
* most room. `held` is the slots no move the deck makes may land on: set aside
|
|
121
|
+
* by claude-swap's engine, or in use by a local `cswap run` session
|
|
122
|
+
* (cswap-auto-choice.mjs slotsHeldBack). */
|
|
123
|
+
export function liveTargets(accounts, leaving, held = new Set()) {
|
|
124
|
+
return (Array.isArray(accounts) ? accounts : [])
|
|
125
|
+
.filter(a => a && a.num !== leaving && !a.active && !a.disabled && a.alive === true
|
|
126
|
+
&& !a.staleCopy && !a.stopped && !a.error && !held.has(a.num))
|
|
127
|
+
.sort((a, b) => Number(a.stale === true) - Number(b.stale === true) || (b.headroom ?? -1) - (a.headroom ?? -1));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** How many accounts a removal tries before it says none would take over. Each
|
|
131
|
+
* try can wait on a collection, and a store full of dead logins is a sign-in's
|
|
132
|
+
* job, not a walk through every slot. */
|
|
133
|
+
const MOVE_TRIES = 3;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Remove an account, and when it was the active one with its login expired,
|
|
137
|
+
* move Claude Code to a working account afterwards.
|
|
138
|
+
*
|
|
139
|
+
* `roster()` is the roster as it stood before the removal, `remove(num)` is
|
|
140
|
+
* cswap-admin's removeAccount and `switchTo(num)` the switch route's own path,
|
|
141
|
+
* guard included. `heldBack(accounts)` names the slots the move may not land
|
|
142
|
+
* on (liveTargets); one it cannot read holds nothing back. Answers the
|
|
143
|
+
* removal's own answer, plus `movedTo` (the account now active) or
|
|
144
|
+
* `stillExpired` (nothing could take over).
|
|
145
|
+
*/
|
|
146
|
+
export async function removeLeavingLiveLogin(num, { roster, remove, switchTo, heldBack = async () => new Set() }) {
|
|
147
|
+
const before = await Promise.resolve().then(roster).catch(() => null);
|
|
148
|
+
const removed = await remove(num);
|
|
149
|
+
if (!removed?.ok) return removed;
|
|
150
|
+
const accounts = before?.ok && Array.isArray(before.accounts) ? before.accounts : [];
|
|
151
|
+
const row = accounts.find(a => a?.num === Number(num));
|
|
152
|
+
if (!activeLoginExpired(row)) return removed;
|
|
153
|
+
const held = await Promise.resolve().then(() => heldBack(accounts)).catch(() => null);
|
|
154
|
+
for (const target of liveTargets(accounts, row.num, held instanceof Set ? held : new Set()).slice(0, MOVE_TRIES)) {
|
|
155
|
+
const moved = await Promise.resolve().then(() => switchTo(target.num)).catch(() => null);
|
|
156
|
+
if (moved?.ok) return { ...removed, movedTo: { num: target.num, email: target.email ?? null, alias: target.alias ?? null } };
|
|
157
|
+
// Only a target refused for its own login is a reason to try the next one.
|
|
158
|
+
// Anything else — no claude-swap, a timeout — would refuse the next as well.
|
|
159
|
+
if (moved?.reason !== "login_expired") break;
|
|
160
|
+
}
|
|
161
|
+
return { ...removed, stillExpired: true };
|
|
162
|
+
}
|
|
@@ -403,6 +403,9 @@ export const SETTING_VARS = Object.freeze([
|
|
|
403
403
|
"AGENTS_DECK_NO_INSTALL", "AGENTS_DECK_NO_DOWNLOAD", "AGENTS_DECK_NO_UPDATE_CHECK", "AGENTS_DECK_NO_STATUS",
|
|
404
404
|
"AGENTS_DECK_NO_FRESHEN", "AGENTS_DECK_NO_NOTIFY", "AGENTS_DECK_NO_LAN", "AGENTS_DECK_NO_REPORTS",
|
|
405
405
|
"AGENTS_DECK_CSWAP", "AGENTS_DECK_CLAUDE", "AGENTS_DECK_CCUSAGE", "CLAUDE_SWAP_BACKUP", "AGENTS_DECK_LHM_PORT",
|
|
406
|
+
// The opt-in for Skill activity's typed-command hook (skill-hook.mjs). A deck
|
|
407
|
+
// started at login without it would take the entry back out.
|
|
408
|
+
"AGENTS_DECK_SKILL_HOOK",
|
|
406
409
|
]);
|
|
407
410
|
|
|
408
411
|
export function settingsEnv(env = process.env) {
|
|
@@ -98,6 +98,11 @@ const PINNED_MODULES = [
|
|
|
98
98
|
// (cswap-auto-choice.mjs). Already in memory through prefs-routes.mjs; named
|
|
99
99
|
// here because the tick reaches it through import().
|
|
100
100
|
"lan-deck.mjs",
|
|
101
|
+
// The accounts Auto-switch's own moves never land on (slotsHeldBack), which
|
|
102
|
+
// the move after removing an expired active login skips too. Already in
|
|
103
|
+
// memory through cswap-auto.mjs; named here because account-routes.mjs
|
|
104
|
+
// reaches it through import().
|
|
105
|
+
"cswap-auto-choice.mjs",
|
|
101
106
|
];
|
|
102
107
|
|
|
103
108
|
let _pinned = null;
|
|
@@ -13,6 +13,7 @@ import { heldPrefs } from "./prefs-state.mjs";
|
|
|
13
13
|
import { applyLanPrefs, forgetReach, resetLanLoaded } from "./lan-deck.mjs";
|
|
14
14
|
import { giveBackDeckFolders } from "./prefs-give-back.mjs";
|
|
15
15
|
import { readBody, send } from "./http-io.mjs";
|
|
16
|
+
import { noteContinueSwitchedOn } from "./stop-continue.mjs";
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* GET the deck's settings, and what the machine is allowing.
|
|
@@ -78,6 +79,9 @@ export async function handlePrefsWrite(req, res) {
|
|
|
78
79
|
// to one question: is anything going to turn up. Whatever the probe said
|
|
79
80
|
// before is about a deck whose sockets were down — see forgetReach.
|
|
80
81
|
if (body.lan?.enabled === true) forgetReach();
|
|
82
|
+
// A stopped background session already waiting for a working login is
|
|
83
|
+
// looked at now, not at the next pass (stop-continue.mjs).
|
|
84
|
+
if (patch.continueStopped === true) noteContinueSwitchedOn();
|
|
81
85
|
return send(res, 200, prefsPayload());
|
|
82
86
|
}
|
|
83
87
|
|
|
@@ -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
|
}
|
|
@@ -433,10 +433,14 @@ export const GUARDED_READS = new Set([
|
|
|
433
433
|
"/api/tool-audit",
|
|
434
434
|
"/api/tool-audit/storage",
|
|
435
435
|
"/api/system/traffic-radar",
|
|
436
|
+
"/api/system/traffic-radar/settings",
|
|
436
437
|
"/api/system/traffic-radar/capture",
|
|
437
438
|
"/api/system/traffic-radar/capture/event",
|
|
438
439
|
"/events",
|
|
439
440
|
"/api/events",
|
|
441
|
+
// The ring folded to one row per session (#2183): each project's path and a
|
|
442
|
+
// standing question's own words, which is the ring's secret in fewer bytes.
|
|
443
|
+
"/api/sessions",
|
|
440
444
|
"/api/claude-accounts",
|
|
441
445
|
"/api/claude-accounts/login",
|
|
442
446
|
"/api/browser-watch",
|
|
@@ -461,6 +465,9 @@ export const GUARDED_READS = new Set([
|
|
|
461
465
|
"/api/ccusage/session",
|
|
462
466
|
"/api/codex-usage",
|
|
463
467
|
"/api/cswap-auto",
|
|
468
|
+
// Which sessions stopped, and the active account's quota reading the
|
|
469
|
+
// Continue button is gated on (stop-continue.mjs) — the quota route's class.
|
|
470
|
+
"/api/stop-continue",
|
|
464
471
|
// Not a secret, and here for the other reason a read can be dangerous: it is
|
|
465
472
|
// the one route where the caller names what the deck goes and fetches
|
|
466
473
|
// (#1208). fm-station.mjs holds that to YouTube, but a page on another site
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
* A per-session read gate with a window of `ms`.
|
|
14
14
|
*
|
|
15
15
|
* `run(sid, read)` calls `read()` unless a read for `sid` is still in flight or
|
|
16
|
-
* the last one began less than `ms` ago.
|
|
16
|
+
* the last one began less than `ms` ago. Returns whether a read started, so
|
|
17
|
+
* a caller that needs a final snapshot can keep refused work pending. The stamp is taken as the read starts,
|
|
17
18
|
* so a read that fails or finds nothing still holds the next one back for the
|
|
18
19
|
* window — that is the throttle. `read` is called before `run` returns, as
|
|
19
20
|
* every copy called it, and returns a promise. Whatever that rejects with is
|
|
@@ -29,15 +30,16 @@ export function sessionReadGate(ms) {
|
|
|
29
30
|
const pending = new Set(); // sid currently being read
|
|
30
31
|
return {
|
|
31
32
|
run(sid, read) {
|
|
32
|
-
if (pending.has(sid)) return;
|
|
33
|
+
if (pending.has(sid)) return false;
|
|
33
34
|
const now = Date.now();
|
|
34
35
|
const last = lastReadAt.get(sid) ?? 0;
|
|
35
|
-
if (now - last < ms) return;
|
|
36
|
+
if (now - last < ms) return false;
|
|
36
37
|
lastReadAt.set(sid, now);
|
|
37
38
|
pending.add(sid);
|
|
38
39
|
read()
|
|
39
40
|
.catch(() => {})
|
|
40
41
|
.finally(() => pending.delete(sid));
|
|
42
|
+
return true;
|
|
41
43
|
},
|
|
42
44
|
forget(sid) {
|
|
43
45
|
lastReadAt.delete(sid);
|