agent-dag 1.46.3 → 1.48.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 +6 -2
- package/dist/web/assets/index-DT1bdZn0.css +1 -0
- package/dist/web/assets/index-PPcCF-io.js +111 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +20 -0
- package/src/server/agent-activity.mjs +506 -0
- package/src/server/browser-history.mjs +467 -0
- package/src/server/browser-presence.mjs +157 -0
- package/src/server/browser-profiles.mjs +267 -0
- package/src/server/browser-react.mjs +204 -0
- package/src/server/browser-watch-store.mjs +288 -0
- package/src/server/browser-watch.mjs +717 -0
- package/src/server/claude-accounts.mjs +77 -7
- package/src/server/cswap-admin.mjs +419 -31
- package/src/server/index.mjs +152 -2
- package/src/server/installer.mjs +13 -0
- package/src/server/relay-guard.mjs +502 -0
- package/dist/web/assets/index-BQgkpz9j.css +0 -1
- package/dist/web/assets/index-CifRQYMu.js +0 -97
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
17
17
|
import { cswapBin, cswapVersion, installHint } from "./cswap-install.mjs";
|
|
18
18
|
import { run, runDetached } from "./exec.mjs";
|
|
19
|
+
// The CLI identity oracle, already written and already trusted by the account
|
|
20
|
+
// admin routes. #721 needs the same answer, so it reuses the same function
|
|
21
|
+
// rather than shelling out a second way to ask one question.
|
|
22
|
+
import { currentIdentity } from "./cswap-admin.mjs";
|
|
19
23
|
import { dirname, join } from "node:path";
|
|
20
24
|
import { existsSync } from "node:fs";
|
|
21
25
|
import { homedir, platform } from "node:os";
|
|
@@ -398,6 +402,17 @@ async function readRoster(now, gen) {
|
|
|
398
402
|
// fetched at all.
|
|
399
403
|
nudgeCollector(rows, Object.keys(seq.accounts), now, seq.activeAccountNumber);
|
|
400
404
|
|
|
405
|
+
// Who the CLI says is signed in, asked ONLY when the store claims the active
|
|
406
|
+
// account is in trouble — see authTrouble. That is the one case where the
|
|
407
|
+
// stored verdict and the live truth can disagree, and it is rare: a healthy
|
|
408
|
+
// machine never spends this subprocess. Never fatal, because a CLI that
|
|
409
|
+
// cannot be reached is not evidence either way.
|
|
410
|
+
const activeNum = seq.activeAccountNumber != null ? String(seq.activeAccountNumber) : null;
|
|
411
|
+
const activeRow = activeNum ? rows[activeNum] : null;
|
|
412
|
+
const identity = (activeRow?.consecutiveFailures ?? 0) > 0
|
|
413
|
+
? await currentIdentity().catch(() => null)
|
|
414
|
+
: null;
|
|
415
|
+
|
|
401
416
|
const order = Array.isArray(seq.sequence) && seq.sequence.length
|
|
402
417
|
? seq.sequence.map(String)
|
|
403
418
|
: Object.keys(seq.accounts).sort((a, b) => Number(a) - Number(b));
|
|
@@ -417,6 +432,8 @@ async function readRoster(now, gen) {
|
|
|
417
432
|
const good = matches ? row.lastGood : null;
|
|
418
433
|
|
|
419
434
|
const fetchedAtMs = matches && typeof row.fetchedAt === "number" ? row.fetchedAt * 1000 : null;
|
|
435
|
+
const isActive = String(seq.activeAccountNumber) === num;
|
|
436
|
+
const trouble = authTrouble(row, { matches, isActive, identity, email: acct.email });
|
|
420
437
|
|
|
421
438
|
const lanes = [
|
|
422
439
|
lane("five_hour", "5h", good?.five_hour),
|
|
@@ -442,23 +459,76 @@ async function readRoster(now, gen) {
|
|
|
442
459
|
// plan and, for a healthy active account, the deck's freshen tick. The
|
|
443
460
|
// plan alone would promise "next in 15m" while the panel actually
|
|
444
461
|
// updates in three.
|
|
445
|
-
nextAt: nextReadAt(row, matches, fetchedAtMs,
|
|
462
|
+
nextAt: nextReadAt(row, matches, fetchedAtMs, isActive, now),
|
|
446
463
|
stale: fetchedAtMs == null || now - fetchedAtMs > STALE_AFTER_MS,
|
|
447
464
|
// Surfaced rather than hidden: a rate-limited or re-login-needed account
|
|
448
465
|
// is exactly the one the user is about to try switching to.
|
|
449
466
|
//
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
// account
|
|
453
|
-
//
|
|
454
|
-
|
|
455
|
-
|
|
467
|
+
// Through authTrouble rather than read straight off the row: see #721.
|
|
468
|
+
// consecutiveFailures says the COLLECTOR is failing, which for the active
|
|
469
|
+
// account is not the same claim as the user being signed out — and the
|
|
470
|
+
// CLI can settle that.
|
|
471
|
+
error: trouble?.error ?? null,
|
|
472
|
+
// True when the collector cannot read this account but the user is signed
|
|
473
|
+
// in as it anyway. The panel says so quietly instead of offering to log
|
|
474
|
+
// them in again.
|
|
475
|
+
staleCopy: trouble?.kind === "stale-copy",
|
|
456
476
|
});
|
|
457
477
|
}
|
|
458
478
|
|
|
459
479
|
return finish({ ok: true, accounts, activeNum: seq.activeAccountNumber ?? null, fetchedAt: now });
|
|
460
480
|
}
|
|
461
481
|
|
|
482
|
+
/**
|
|
483
|
+
* What to say about an account whose collector is failing — which is not the
|
|
484
|
+
* same question as whether the user is signed out.
|
|
485
|
+
*
|
|
486
|
+
* TWO FACTS, AND #721 SHIPPED THEM AS ONE. claude-swap keeps its own copy of
|
|
487
|
+
* each account's credentials, taken when `cswap add` captured the slot. When
|
|
488
|
+
* that copy's refresh token dies, claude-swap can no longer collect usage for
|
|
489
|
+
* the row and says `relogin_required`. That is true, and it is about the COPY.
|
|
490
|
+
*
|
|
491
|
+
* It says nothing about whether the user is signed in. Measured on the machine
|
|
492
|
+
* that reported this, at the same instant:
|
|
493
|
+
*
|
|
494
|
+
* claude auth status --json -> loggedIn: true, claude3@sapec.md
|
|
495
|
+
* cswap list --json -> claude3@sapec.md: relogin_required
|
|
496
|
+
* GET /api/quota -> source: cli, 5h 33%, 7d 37%
|
|
497
|
+
*
|
|
498
|
+
* The user had signed in again in a terminal, which refreshes the LIVE
|
|
499
|
+
* credentials and leaves claude-swap's stored copy exactly as dead as it was.
|
|
500
|
+
* The deck held live quota numbers for that account and printed "login expired"
|
|
501
|
+
* beside them, and the button it offered ran `claude auth login` — a full
|
|
502
|
+
* re-login of the account the user was mid-session in, to fix a problem they
|
|
503
|
+
* did not have.
|
|
504
|
+
*
|
|
505
|
+
* So: for the ACTIVE account the CLI is the authority, because it is the one
|
|
506
|
+
* thing that can answer about the live credentials rather than about a copy.
|
|
507
|
+
* When it says the user is signed in as this account, there is no login
|
|
508
|
+
* failure to report — only a collector that cannot see it, which is quieter,
|
|
509
|
+
* true, and fixed by re-capturing the slot rather than by signing in again.
|
|
510
|
+
*
|
|
511
|
+
* `identity` is null when the CLI could not be asked at all. That is not
|
|
512
|
+
* evidence of anything, so the stored verdict stands: refusing to show a real
|
|
513
|
+
* expiry because a subprocess failed is the opposite mistake.
|
|
514
|
+
*/
|
|
515
|
+
export function authTrouble(row, { matches, isActive, identity, email } = {}) {
|
|
516
|
+
if (!matches || !((row?.consecutiveFailures ?? 0) > 0)) return null;
|
|
517
|
+
|
|
518
|
+
const signedInHere = isActive
|
|
519
|
+
&& identity
|
|
520
|
+
&& typeof identity.email === "string"
|
|
521
|
+
&& email
|
|
522
|
+
&& identity.email.toLowerCase() === String(email).toLowerCase();
|
|
523
|
+
|
|
524
|
+
if (signedInHere) {
|
|
525
|
+
// Deliberately not the `error` field: this is not the user's problem to
|
|
526
|
+
// fix under a red badge, and it must not offer to sign them in again.
|
|
527
|
+
return { kind: "stale-copy", error: null };
|
|
528
|
+
}
|
|
529
|
+
return { kind: "auth", error: row.lastError ?? "error" };
|
|
530
|
+
}
|
|
531
|
+
|
|
462
532
|
/**
|
|
463
533
|
* claude-swap's last good usage for whichever account is active right now.
|
|
464
534
|
*
|
|
@@ -37,6 +37,8 @@ const CODE_VERDICT_MS = 60_000;
|
|
|
37
37
|
// How long a shared account stays importable. Long enough to walk to the other
|
|
38
38
|
// machine, short enough that a copy left in clipboard history goes stale.
|
|
39
39
|
export const SHARE_TTL_MS = 10 * 60_000;
|
|
40
|
+
// How many accounts one bundle may carry. See shareAccounts.
|
|
41
|
+
const MAX_SHARE_ACCOUNTS = 50;
|
|
40
42
|
const SHARE_PREFIX = "ccdeck1:";
|
|
41
43
|
|
|
42
44
|
// ── serialization ────────────────────────────────────────────────────────────
|
|
@@ -163,10 +165,14 @@ export async function readStore() {
|
|
|
163
165
|
return {
|
|
164
166
|
slots: Object.keys(accounts),
|
|
165
167
|
emails: Object.fromEntries(Object.entries(accounts).map(([k, v]) => [k, v?.email ?? ""])),
|
|
168
|
+
// The other half of the identity claude-swap keys an account by. One
|
|
169
|
+
// address under two organizations is two accounts on purpose, and a
|
|
170
|
+
// bundle carrying both must not report them as one - see identityKey.
|
|
171
|
+
orgs: Object.fromEntries(Object.entries(accounts).map(([k, v]) => [k, v?.organizationUuid ?? ""])),
|
|
166
172
|
activeNum: seq?.activeAccountNumber ?? null,
|
|
167
173
|
};
|
|
168
174
|
} catch {
|
|
169
|
-
return { slots: [], emails: {}, activeNum: null };
|
|
175
|
+
return { slots: [], emails: {}, orgs: {}, activeNum: null };
|
|
170
176
|
}
|
|
171
177
|
}
|
|
172
178
|
|
|
@@ -738,39 +744,352 @@ export function unwrapShare(blob, now = Date.now()) {
|
|
|
738
744
|
return { ok: true, payload: env.payload };
|
|
739
745
|
}
|
|
740
746
|
|
|
747
|
+
/**
|
|
748
|
+
* claude-swap's own identity for an account, as one comparable string.
|
|
749
|
+
*
|
|
750
|
+
* `(email, organizationUuid)`, which is the composite `transfer.py` keys its
|
|
751
|
+
* duplicate check and its already-here check on. Matching on the address alone
|
|
752
|
+
* would fold one address's two organizations into a single row, and the whole
|
|
753
|
+
* reason cswap carries the org is that they are two accounts.
|
|
754
|
+
*
|
|
755
|
+
* The address is lower-cased because both sides of every comparison here come
|
|
756
|
+
* from the same store or the same bundle, so folding case can only join what a
|
|
757
|
+
* human would call the same account, never split one.
|
|
758
|
+
*/
|
|
759
|
+
export function identityKey(email, org) {
|
|
760
|
+
return `${String(email ?? "").trim().toLowerCase()} ${String(org ?? "")}`;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* N single-account envelopes, folded into the one bundle cswap will take back.
|
|
765
|
+
*
|
|
766
|
+
* `cswap export --account` names ONE account, so a chosen subset cannot come
|
|
767
|
+
* out of a single call and the deck has to do the folding. It is deliberately
|
|
768
|
+
* not a new format: the head envelope is spread whole and only `accounts` and
|
|
769
|
+
* `activeAccountNumber` are replaced, so `version`, `exportedFrom`,
|
|
770
|
+
* `swapVersion` and any field a later claude-swap adds arrive on the far side
|
|
771
|
+
* exactly as that claude-swap wrote them. Nothing here hard-codes its
|
|
772
|
+
* FORMAT_VERSION, because a constant copied out of another project's source is
|
|
773
|
+
* a constant that drifts.
|
|
774
|
+
*
|
|
775
|
+
* `activeAccountNumber` is re-guarded rather than carried: cswap only records
|
|
776
|
+
* it when that slot is in the payload, and a subset can drop the slot the head
|
|
777
|
+
* envelope pointed at. An import that referenced a missing account would be
|
|
778
|
+
* seeding an active slot that never arrived.
|
|
779
|
+
*
|
|
780
|
+
* A duplicate identity is DROPPED rather than passed on. `import_accounts`
|
|
781
|
+
* refuses a whole envelope over one repeated `(email, org)` pair, so carrying
|
|
782
|
+
* it would trade five shared accounts for a bundle that imports none.
|
|
783
|
+
*/
|
|
784
|
+
export function mergeExports(texts) {
|
|
785
|
+
const envelopes = [];
|
|
786
|
+
for (const text of texts) {
|
|
787
|
+
let env;
|
|
788
|
+
try { env = JSON.parse(text); } catch { return { ok: false, reason: "unreadable_export" }; }
|
|
789
|
+
if (!env || typeof env !== "object" || !Array.isArray(env.accounts)) {
|
|
790
|
+
return { ok: false, reason: "unreadable_export" };
|
|
791
|
+
}
|
|
792
|
+
envelopes.push(env);
|
|
793
|
+
}
|
|
794
|
+
if (!envelopes.length) return { ok: false, reason: "nothing_to_share" };
|
|
795
|
+
|
|
796
|
+
const head = envelopes[0];
|
|
797
|
+
// Every part came out of one binary in one pass, so a disagreement here is
|
|
798
|
+
// not a version to reconcile - it is a sign the parts are not what we think.
|
|
799
|
+
if (envelopes.some(e => e.version !== head.version)) return { ok: false, reason: "mixed_versions" };
|
|
800
|
+
|
|
801
|
+
const accounts = [];
|
|
802
|
+
const dropped = [];
|
|
803
|
+
const seen = new Set();
|
|
804
|
+
for (const env of envelopes) {
|
|
805
|
+
for (const a of env.accounts) {
|
|
806
|
+
const key = identityKey(a?.email, a?.organizationUuid);
|
|
807
|
+
if (seen.has(key)) { dropped.push({ num: String(a?.number ?? ""), email: a?.email ?? "" }); continue; }
|
|
808
|
+
seen.add(key);
|
|
809
|
+
accounts.push(a);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
if (!accounts.length) return { ok: false, reason: "nothing_to_share" };
|
|
813
|
+
|
|
814
|
+
const nums = new Set(accounts.map(a => String(a?.number ?? "")));
|
|
815
|
+
const active = envelopes
|
|
816
|
+
.map(e => e.activeAccountNumber)
|
|
817
|
+
.find(n => n != null && nums.has(String(n)));
|
|
818
|
+
return {
|
|
819
|
+
ok: true,
|
|
820
|
+
dropped,
|
|
821
|
+
envelope: { ...head, activeAccountNumber: active ?? null, accounts },
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* One account, or several, packaged for another deck.
|
|
827
|
+
*
|
|
828
|
+
* claude-swap's envelope carries each account's OAuth token in the clear - its
|
|
829
|
+
* own module header says so ("No encryption is built in"). The wrapper adds an
|
|
830
|
+
* expiry so a copy left behind in clipboard history stops working, and nothing
|
|
831
|
+
* more: it is not encryption and is not presented as any. A bundle makes that
|
|
832
|
+
* larger, not different - five accounts is five tokens on the clipboard -
|
|
833
|
+
* which is why the dialog states the count before the copy rather than after.
|
|
834
|
+
*
|
|
835
|
+
* Say the limit of the expiry out loud, because the UI used to imply the
|
|
836
|
+
* opposite. `exp` is a plain number inside plain base64'd JSON with NO key, MAC
|
|
837
|
+
* or signature over it, so anyone holding the text can decode it, write a later
|
|
838
|
+
* `exp`, re-encode, and import it. unwrapShare's check is therefore a check
|
|
839
|
+
* against staleness, not against an adversary - and it cannot be made into one
|
|
840
|
+
* here. A MAC needs a secret both decks hold, and two decks that already shared
|
|
841
|
+
* a secret would not need this function; and even a perfect signature would
|
|
842
|
+
* only stop THIS import path, since the payload it wraps is the credential
|
|
843
|
+
* itself and `cswap import` accepts it unwrapped. The honest fix for a share
|
|
844
|
+
* that got away is to sign the account out and back in. See share-expiry-
|
|
845
|
+
* forgeable.test.ts, which pins the forgery rather than leaving it implied.
|
|
846
|
+
*
|
|
847
|
+
* Only the accounts asked for are exported. Reading the whole store and then
|
|
848
|
+
* dropping the unwanted rows would be one spawn instead of several, and it
|
|
849
|
+
* would pull a refresh token this deck was never asked to move into this
|
|
850
|
+
* process; it would also lose the failure, because a whole-store export skips a
|
|
851
|
+
* slot with no backup credentials in silence while `--account` on that slot is
|
|
852
|
+
* a hard error naming it. A count that is quietly short is the one outcome a
|
|
853
|
+
* share must never have.
|
|
854
|
+
*
|
|
855
|
+
* The default export shape is used deliberately, never --full, which would
|
|
856
|
+
* embed the entire ~/.claude.json including every project and MCP server.
|
|
857
|
+
*/
|
|
858
|
+
export async function shareAccounts(nums) {
|
|
859
|
+
const asked = Array.isArray(nums) ? nums : [nums];
|
|
860
|
+
// One spawn per account, so the length of this list is a length of time the
|
|
861
|
+
// request holds. A store never has fifty accounts; a caller that sends nine
|
|
862
|
+
// hundred numbers is not a person picking from a panel, and the ceiling
|
|
863
|
+
// costs nothing to the one who is.
|
|
864
|
+
if (asked.length > MAX_SHARE_ACCOUNTS) return { ok: false, reason: "too_many" };
|
|
865
|
+
const wanted = [];
|
|
866
|
+
for (const raw of asked) {
|
|
867
|
+
const n = Number(raw);
|
|
868
|
+
if (!Number.isInteger(n) || n < 1 || n > 999) return { ok: false, reason: "bad_account" };
|
|
869
|
+
if (!wanted.includes(n)) wanted.push(n);
|
|
870
|
+
}
|
|
871
|
+
if (!wanted.length) return { ok: false, reason: "bad_account" };
|
|
872
|
+
|
|
873
|
+
// Names for the accounts that fail, read from the store before anything is
|
|
874
|
+
// spawned - because the failure sentence must never be built from the
|
|
875
|
+
// export's own output.
|
|
876
|
+
const store = await readStore();
|
|
877
|
+
const texts = [];
|
|
878
|
+
const failed = [];
|
|
879
|
+
for (const n of wanted) {
|
|
880
|
+
const r = await run(await cswapBin(), ["export", "-", "--account", String(n)], { timeout: CSWAP_TIMEOUT_MS });
|
|
881
|
+
if (!r.ok || !r.stdout.trim()) {
|
|
882
|
+
// The failure sentence is built from stderr ALONE for this one command,
|
|
883
|
+
// because its stdout is the credential. `failureText` concatenates
|
|
884
|
+
// `${stderr}\n${stdout}` and `firstUseful` takes the LAST non-empty line -
|
|
885
|
+
// right for every other cswap command, and here it means any stdout at all
|
|
886
|
+
// outranks the real error. claude-swap writes its diagnostics to stderr
|
|
887
|
+
// specifically so stdout stays pure JSON in pipe mode, and it writes the
|
|
888
|
+
// envelope as its last act; a non-zero exit after a partial write would
|
|
889
|
+
// therefore put the tail of `json.dumps(envelope, indent=2)` in front of the
|
|
890
|
+
// user, and one of those lines is the refresh token on its own.
|
|
891
|
+
//
|
|
892
|
+
// Nothing is lost by dropping it: the ENOENT branch keys off `r.code`, which
|
|
893
|
+
// `run` sets, and cmd.exe's "is not recognized" is stderr's.
|
|
894
|
+
failed.push({
|
|
895
|
+
num: String(n),
|
|
896
|
+
email: store.emails?.[String(n)] || "",
|
|
897
|
+
detail: failureText({ ...r, stdout: "" }, "cswap export"),
|
|
898
|
+
});
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
texts.push(r.stdout);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// Nothing came out at all. There is no partial bundle to hand over, so this
|
|
905
|
+
// is the plain failure the single-account share has always reported.
|
|
906
|
+
if (!texts.length) {
|
|
907
|
+
return { ok: false, reason: "export_failed", detail: failed[0]?.detail ?? "", failed };
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const merged = mergeExports(texts);
|
|
911
|
+
if (!merged.ok) return { ok: false, reason: merged.reason, failed };
|
|
912
|
+
for (const d of merged.dropped) {
|
|
913
|
+
failed.push({ ...d, detail: "another slot already holds this address in this organization" });
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
const shared = merged.envelope.accounts.map(a => ({ num: String(a?.number ?? ""), email: a?.email ?? "" }));
|
|
917
|
+
return {
|
|
918
|
+
ok: true,
|
|
919
|
+
blob: wrapShare(JSON.stringify(merged.envelope)),
|
|
920
|
+
expiresAt: Date.now() + SHARE_TTL_MS,
|
|
921
|
+
// What the bundle CARRIES, never what was asked for. The copy row counts
|
|
922
|
+
// this list, so a bundle that came up short says so.
|
|
923
|
+
shared,
|
|
924
|
+
failed,
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* The one-account case, which is a bundle of one and takes the same path.
|
|
930
|
+
*
|
|
931
|
+
* That path PARSES what cswap wrote, where this used to hand its stdout on
|
|
932
|
+
* opaquely — the cost of `shared` being able to promise that the count on the
|
|
933
|
+
* copy button is the count in the blob. So an export shape the fold cannot read
|
|
934
|
+
* now fails the single share too, not just the bundle. Deliberate: one path
|
|
935
|
+
* means the two cannot drift into two envelope shapes, and a share that
|
|
936
|
+
* silently carried something this deck could not account for is the failure
|
|
937
|
+
* the count exists to prevent.
|
|
938
|
+
*/
|
|
741
939
|
export async function shareAccount(num) {
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
940
|
+
return shareAccounts([num]);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* The identities a bundle carries, or `[]` when it cannot be read.
|
|
945
|
+
*
|
|
946
|
+
* The payload is the credential, so this takes the two fields it needs and
|
|
947
|
+
* nothing else: no caller ever receives the parsed envelope, and a bundle that
|
|
948
|
+
* will not parse degrades to an unnamed import rather than to an error, since
|
|
949
|
+
* cswap is the one entitled to refuse it.
|
|
950
|
+
*/
|
|
951
|
+
export function bundleAccounts(payload) {
|
|
952
|
+
let env;
|
|
953
|
+
try { env = JSON.parse(payload); } catch { return []; }
|
|
954
|
+
if (!env || typeof env !== "object" || !Array.isArray(env.accounts)) return [];
|
|
955
|
+
const out = [];
|
|
956
|
+
for (const a of env.accounts) {
|
|
957
|
+
if (!a || typeof a !== "object") continue;
|
|
958
|
+
const email = typeof a.email === "string" ? a.email.trim() : "";
|
|
959
|
+
if (!email) continue;
|
|
960
|
+
out.push({ email, org: typeof a.organizationUuid === "string" ? a.organizationUuid : "" });
|
|
759
961
|
}
|
|
760
|
-
|
|
962
|
+
// All of them or none. `wanted` is what the result list counts against, so a
|
|
963
|
+
// bundle read as three when it holds four reports "1 of 3 imported" about a
|
|
964
|
+
// paste of four - the missing one arrives and is never named, and a non-empty
|
|
965
|
+
// list keeps the store-diff fallback from running to catch it. claude-swap
|
|
966
|
+
// itself refuses an envelope whose entry has no address, so this is a guard
|
|
967
|
+
// against a shape neither project has today rather than a live case.
|
|
968
|
+
return out.length === env.accounts.length ? out : [];
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* The same bundle, cut down to one account.
|
|
973
|
+
*
|
|
974
|
+
* What "update anyway" sends. `--force` overwrites every account it matches, so
|
|
975
|
+
* a forced import of the whole bundle would rewrite credentials the user never
|
|
976
|
+
* pointed at; narrowing first is what keeps an overwrite a named act. Written
|
|
977
|
+
* as a filter over the original envelope rather than a fresh one, for the same
|
|
978
|
+
* reason mergeExports spreads its head: those fields belong to claude-swap.
|
|
979
|
+
*/
|
|
980
|
+
export function narrowBundle(payload, key) {
|
|
981
|
+
let env;
|
|
982
|
+
try { env = JSON.parse(payload); } catch { return { ok: false, reason: "corrupt" }; }
|
|
983
|
+
if (!env || typeof env !== "object" || !Array.isArray(env.accounts)) return { ok: false, reason: "corrupt" };
|
|
984
|
+
const accounts = env.accounts.filter(a => identityKey(a?.email, a?.organizationUuid) === key);
|
|
985
|
+
if (!accounts.length) return { ok: false, reason: "not_in_bundle" };
|
|
986
|
+
const nums = new Set(accounts.map(a => String(a?.number ?? "")));
|
|
987
|
+
const active = env.activeAccountNumber != null && nums.has(String(env.activeAccountNumber))
|
|
988
|
+
? env.activeAccountNumber
|
|
989
|
+
: null;
|
|
990
|
+
return { ok: true, payload: JSON.stringify({ ...env, activeAccountNumber: active, accounts }) };
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/**
|
|
994
|
+
* What happened to each account in the bundle, decided by the store.
|
|
995
|
+
*
|
|
996
|
+
* The store is the fact. `cswap import` narrates itself per account on stderr,
|
|
997
|
+
* and parsing that as the primary answer would make a reworded release report
|
|
998
|
+
* imports that did not happen - the failure mode `newSlot` already refuses for
|
|
999
|
+
* the same reason. So the slot map before and after the run decides the two
|
|
1000
|
+
* outcomes that matter: an identity holding a slot it did not hold arrived, and
|
|
1001
|
+
* one absent from both never came.
|
|
1002
|
+
*
|
|
1003
|
+
* stderr is then read for one thing only, and one no store diff can show: an
|
|
1004
|
+
* account already present whose credentials were REWRITTEN in place, which
|
|
1005
|
+
* moves no slot. `Replaced` is claude-swap's dead-token auto-heal (its #136),
|
|
1006
|
+
* `Overwrote` is a `--force`. If either line is ever reworded the nuance is
|
|
1007
|
+
* lost and the row reads "already here", which is still true - the degradation
|
|
1008
|
+
* is a less specific report, never a wrong one.
|
|
1009
|
+
*
|
|
1010
|
+
* And it is applied ONLY where the address names exactly one row in the bundle.
|
|
1011
|
+
* cswap's line carries no organization, so with one address held under two of
|
|
1012
|
+
* them a single `Replaced me@x.com` would mark both rows healed and one of
|
|
1013
|
+
* those would be false - which is the thing the paragraph above promises this
|
|
1014
|
+
* never does.
|
|
1015
|
+
*/
|
|
1016
|
+
export function importOutcomes(before, after, wanted, stderr = "") {
|
|
1017
|
+
const slotsBy = (store) => new Map(
|
|
1018
|
+
(store?.slots ?? []).map(s => [identityKey(store?.emails?.[s], store?.orgs?.[s]), s]),
|
|
1019
|
+
);
|
|
1020
|
+
const had = slotsBy(before);
|
|
1021
|
+
const now = slotsBy(after);
|
|
1022
|
+
|
|
1023
|
+
// How many rows in this bundle share each address. An address held twice is
|
|
1024
|
+
// an address cswap's own narration cannot resolve.
|
|
1025
|
+
const byAddress = new Map();
|
|
1026
|
+
for (const w of wanted) {
|
|
1027
|
+
const a = String(w.email ?? "").trim().toLowerCase();
|
|
1028
|
+
byAddress.set(a, (byAddress.get(a) ?? 0) + 1);
|
|
1029
|
+
}
|
|
1030
|
+
const rewritten = new Map();
|
|
1031
|
+
for (const line of String(stderr ?? "").split(/\r?\n/)) {
|
|
1032
|
+
const m = /^\s*(Replaced|Overwrote)\s+(\S+)/.exec(line);
|
|
1033
|
+
if (!m) continue;
|
|
1034
|
+
const addr = String(m[2]).trim().toLowerCase();
|
|
1035
|
+
if ((byAddress.get(addr) ?? 0) !== 1) continue;
|
|
1036
|
+
rewritten.set(addr, m[1] === "Replaced" ? "healed" : "updated");
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
return wanted.map(w => {
|
|
1040
|
+
const key = identityKey(w.email, w.org);
|
|
1041
|
+
const wasHere = had.has(key);
|
|
1042
|
+
const slot = now.get(key) ?? null;
|
|
1043
|
+
if (!wasHere && slot != null) return { email: w.email, org: w.org, num: slot, state: "imported" };
|
|
1044
|
+
if (wasHere) {
|
|
1045
|
+
return {
|
|
1046
|
+
email: w.email,
|
|
1047
|
+
org: w.org,
|
|
1048
|
+
num: had.get(key),
|
|
1049
|
+
state: rewritten.get(String(w.email ?? "").trim().toLowerCase()) ?? "present",
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
return { email: w.email, org: w.org, num: null, state: "failed" };
|
|
1053
|
+
});
|
|
761
1054
|
}
|
|
762
1055
|
|
|
763
|
-
|
|
1056
|
+
/**
|
|
1057
|
+
* A share, or a bundle of them, taken into this deck's store.
|
|
1058
|
+
*
|
|
1059
|
+
* Non-destructive by default and deliberately so: without `--force` claude-swap
|
|
1060
|
+
* adds what is missing, leaves a healthy account exactly as it is, and replaces
|
|
1061
|
+
* only a slot its own usage row has quarantined as refresh-token-dead. That is
|
|
1062
|
+
* already the rule a person would ask for - leave what works, fix what does
|
|
1063
|
+
* not - so the default run never passes the flag.
|
|
1064
|
+
*
|
|
1065
|
+
* `force` is honoured ONLY together with `only`, which names a single account.
|
|
1066
|
+
* A forced import of a whole bundle would rewrite every matching credential on
|
|
1067
|
+
* this machine, and a fresh token replaced by a stale one is not recoverable
|
|
1068
|
+
* from here - the fix is a re-login. Requiring the pair is what makes the
|
|
1069
|
+
* clobber something a person chose while looking at the address.
|
|
1070
|
+
*/
|
|
1071
|
+
export async function importAccount(blob, { force = false, only = null } = {}) {
|
|
764
1072
|
const un = unwrapShare(blob);
|
|
765
1073
|
if (!un.ok) return { ok: false, reason: un.reason };
|
|
766
1074
|
|
|
1075
|
+
let payload = un.payload;
|
|
1076
|
+
const narrowing = only != null;
|
|
1077
|
+
if (narrowing) {
|
|
1078
|
+
const cut = narrowBundle(payload, identityKey(only?.email, only?.org));
|
|
1079
|
+
if (!cut.ok) return { ok: false, reason: cut.reason };
|
|
1080
|
+
payload = cut.payload;
|
|
1081
|
+
}
|
|
1082
|
+
const overwrite = force === true && narrowing;
|
|
1083
|
+
const wanted = bundleAccounts(payload);
|
|
1084
|
+
|
|
767
1085
|
return withStoreLock(async () => {
|
|
768
1086
|
const before = await readStore();
|
|
769
|
-
const
|
|
770
|
-
child
|
|
1087
|
+
const args = overwrite ? ["import", "-", "--force"] : ["import", "-"];
|
|
1088
|
+
const child = runInteractive(await cswapBin(), args, { timeout: CSWAP_TIMEOUT_MS });
|
|
1089
|
+
child.write(payload);
|
|
771
1090
|
// cswap reads stdin to EOF, so the pipe has to close for it to proceed.
|
|
772
1091
|
//
|
|
773
|
-
// This used to write a raw EOT byte and then call `endStdin(child)`
|
|
1092
|
+
// This used to write a raw EOT byte and then call `endStdin(child)` - a
|
|
774
1093
|
// helper that was never written. EOT only means end-of-file on a TTY, so
|
|
775
1094
|
// the byte did nothing to a pipe, and the call threw ReferenceError before
|
|
776
1095
|
// cswap ever saw the payload: the route answered 500 and the dialog fell
|
|
@@ -782,15 +1101,32 @@ export async function importAccount(blob) {
|
|
|
782
1101
|
if (!r.ok) return { ok: false, reason: "import_failed", detail: failureText(r, "cswap import") };
|
|
783
1102
|
|
|
784
1103
|
const after = await readStore();
|
|
785
|
-
const slot = newSlot(before, after);
|
|
786
1104
|
invalidateClaudeAccountsCache();
|
|
787
|
-
|
|
788
|
-
//
|
|
789
|
-
|
|
790
|
-
//
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
1105
|
+
|
|
1106
|
+
// An unreadable envelope was still imported, or still refused, by cswap -
|
|
1107
|
+
// only the naming is lost. Fall back to the store's own new slots so the
|
|
1108
|
+
// dialog can say what arrived even then.
|
|
1109
|
+
const results = wanted.length
|
|
1110
|
+
? importOutcomes(before, after, wanted, r.stderr)
|
|
1111
|
+
: (() => {
|
|
1112
|
+
const had = new Set(before.slots ?? []);
|
|
1113
|
+
return (after.slots ?? []).filter(s => !had.has(s))
|
|
1114
|
+
.map(s => ({ email: after.emails?.[s] || "", org: after.orgs?.[s] || "", num: s, state: "imported" }));
|
|
1115
|
+
})();
|
|
1116
|
+
|
|
1117
|
+
const arrived = results.filter(x => x.state === "imported");
|
|
1118
|
+
if (arrived.length) runDetached(await cswapBin(), ["list"]);
|
|
1119
|
+
return {
|
|
1120
|
+
ok: true,
|
|
1121
|
+
results,
|
|
1122
|
+
// Nothing new is not an error: without --force, cswap skips an account it
|
|
1123
|
+
// already holds. Saying which happened is the difference between "it
|
|
1124
|
+
// worked" and "why is nothing different".
|
|
1125
|
+
added: arrived.length > 0,
|
|
1126
|
+
num: arrived.length === 1 ? arrived[0].num : null,
|
|
1127
|
+
email: arrived.length === 1 ? arrived[0].email : null,
|
|
1128
|
+
output: firstUseful(r.stdout),
|
|
1129
|
+
};
|
|
794
1130
|
});
|
|
795
1131
|
}
|
|
796
1132
|
|
|
@@ -807,6 +1143,58 @@ export function removePromptMatches(line, num) {
|
|
|
807
1143
|
return Boolean(m) && m[1] === String(num);
|
|
808
1144
|
}
|
|
809
1145
|
|
|
1146
|
+
/**
|
|
1147
|
+
* Re-capture the active slot's credentials, which is what unfreezes a row whose
|
|
1148
|
+
* stored copy died.
|
|
1149
|
+
*
|
|
1150
|
+
* WHY THIS EXISTS AND WHY IT IS NOT A LOGIN. claude-swap keeps its own copy of
|
|
1151
|
+
* each account's credentials, taken when the slot was added. When that copy's
|
|
1152
|
+
* refresh token dies it quarantines the row: no further collection is
|
|
1153
|
+
* attempted, so the numbers freeze and every Refresh re-reads a store that
|
|
1154
|
+
* cannot change. #721 fixed the sentence the panel said about that state; this
|
|
1155
|
+
* is the button that ends it.
|
|
1156
|
+
*
|
|
1157
|
+
* `cswap add` on an account already in the store is an idempotent credential
|
|
1158
|
+
* refresh — registerSignedIn above says so in its own words: "No new slot means
|
|
1159
|
+
* the account was already managed and cswap refreshed its credentials in
|
|
1160
|
+
* place." It captures whatever is signed in RIGHT NOW, which for the active
|
|
1161
|
+
* slot is the account the row belongs to, with the working credentials the user
|
|
1162
|
+
* already has. Nobody is signed in or out, no browser opens, and the active
|
|
1163
|
+
* account does not change.
|
|
1164
|
+
*
|
|
1165
|
+
* It only ever repairs the ACTIVE slot, because "what is signed in right now"
|
|
1166
|
+
* is the only thing `cswap add` can see. The panel offers it nowhere else.
|
|
1167
|
+
*/
|
|
1168
|
+
export async function recaptureActive() {
|
|
1169
|
+
return withStoreLock(async () => {
|
|
1170
|
+
// Who is actually signed in, asked before and after, because `cswap add`
|
|
1171
|
+
// captures the live credentials and this is the one check that the thing it
|
|
1172
|
+
// captured is the thing the user meant.
|
|
1173
|
+
const before = await currentIdentity();
|
|
1174
|
+
if (!before?.email) return { ok: false, reason: "not_signed_in" };
|
|
1175
|
+
|
|
1176
|
+
const add = await run(await cswapBin(), ["add"], { timeout: CSWAP_TIMEOUT_MS });
|
|
1177
|
+
if (!add.ok) return { ok: false, reason: "add_failed", error: addFailureText(add) };
|
|
1178
|
+
|
|
1179
|
+
invalidateClaudeAccountsCache();
|
|
1180
|
+
// AWAITED, NOT DETACHED, AND THAT IS THE WHOLE DIFFERENCE THE PRESS MAKES.
|
|
1181
|
+
// `cswap add` clears the strike instantly, so a detached collection left a
|
|
1182
|
+
// window where the badge was gone but the numbers were still twenty hours
|
|
1183
|
+
// old and the row still said "due" in amber — a press that looked like it
|
|
1184
|
+
// had done nothing, which is the complaint this button exists to answer.
|
|
1185
|
+
// Waiting costs a few seconds and returns a row that has actually moved.
|
|
1186
|
+
//
|
|
1187
|
+
// Its failure is not the press's failure: the credentials are captured
|
|
1188
|
+
// either way, and claude-swap's own schedule will collect within minutes.
|
|
1189
|
+
// So a timeout here still reports success, with `collected: false` for a
|
|
1190
|
+
// caller that wants to say so.
|
|
1191
|
+
const collect = await run(await cswapBin(), ["list"], { timeout: CSWAP_TIMEOUT_MS })
|
|
1192
|
+
.catch(() => null);
|
|
1193
|
+
invalidateClaudeAccountsCache();
|
|
1194
|
+
return { ok: true, email: before.email, collected: collect?.ok === true };
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
1197
|
+
|
|
810
1198
|
export async function removeAccount(num) {
|
|
811
1199
|
const n = Number(num);
|
|
812
1200
|
if (!Number.isInteger(n) || n < 1 || n > 999) return { ok: false, reason: "bad_account" };
|