agent-dag 1.47.0 → 3.0.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-6cAJDiBs.js +125 -0
- package/dist/web/assets/index-Bnn7d7u8.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +28 -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/codex-usage.mjs +79 -1
- package/src/server/cswap-admin.mjs +485 -36
- package/src/server/index.mjs +172 -4
- package/src/server/installer.mjs +13 -0
- package/src/server/relay-guard.mjs +502 -0
- package/src/server/system-metrics.mjs +715 -32
- package/dist/web/assets/index-BgcITzCV.css +0 -1
- package/dist/web/assets/index-ChVeFKhn.js +0 -101
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
// goes through one mutex.
|
|
19
19
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
20
20
|
import { existsSync } from "node:fs";
|
|
21
|
+
import { brotliCompressSync, brotliDecompressSync, constants as zlibConstants } from "node:zlib";
|
|
21
22
|
import { readFile } from "node:fs/promises";
|
|
22
23
|
import { homedir } from "node:os";
|
|
23
24
|
import { join } from "node:path";
|
|
@@ -37,7 +38,48 @@ const CODE_VERDICT_MS = 60_000;
|
|
|
37
38
|
// How long a shared account stays importable. Long enough to walk to the other
|
|
38
39
|
// machine, short enough that a copy left in clipboard history goes stale.
|
|
39
40
|
export const SHARE_TTL_MS = 10 * 60_000;
|
|
40
|
-
|
|
41
|
+
// How many accounts one bundle may carry. See shareAccounts.
|
|
42
|
+
const MAX_SHARE_ACCOUNTS = 50;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The prefix a share is written with, and the one it used to be written with.
|
|
46
|
+
*
|
|
47
|
+
* `ccdeck1:` is base64 of the envelope JSON. `ccdeck2:` is base64 of the same
|
|
48
|
+
* JSON compressed, and the difference is not cosmetic — it was reported from
|
|
49
|
+
* the panel as "the text is very large", and on a real store it is:
|
|
50
|
+
*
|
|
51
|
+
* 1 account 2200 characters -> 1024
|
|
52
|
+
* 3 accounts 6168 characters -> 1816
|
|
53
|
+
*
|
|
54
|
+
* Because what a bundle mostly contains is not credentials. An account's two
|
|
55
|
+
* OAuth tokens are 216 characters between them; the envelope around them is
|
|
56
|
+
* two thousand, and every one of its key names — `refreshTokenExpiresAt`,
|
|
57
|
+
* `organizationRateLimitTier`, `claudeCodeTrialDurationDays` — repeats
|
|
58
|
+
* verbatim for every account added. That is exactly what a compressor is for,
|
|
59
|
+
* which is why the saving grows with the number of accounts rather than
|
|
60
|
+
* shrinking.
|
|
61
|
+
*
|
|
62
|
+
* Brotli rather than gzip: 10% smaller here, in node:zlib since v11, no
|
|
63
|
+
* dependency either way.
|
|
64
|
+
*
|
|
65
|
+
* BOTH prefixes are read, so a blob copied before this change still imports.
|
|
66
|
+
* Only `ccdeck2:` is written, which does mean a deck older than this cannot
|
|
67
|
+
* read a new share — it will say the text does not look like a shared account.
|
|
68
|
+
* That cost is paid once and it is smallest now: the feature shipped in
|
|
69
|
+
* 1.48.0 and the format has had no time to spread.
|
|
70
|
+
*/
|
|
71
|
+
const SHARE_PREFIX = "ccdeck2:";
|
|
72
|
+
const SHARE_PREFIX_V1 = "ccdeck1:";
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The most an imported blob may decompress to.
|
|
76
|
+
*
|
|
77
|
+
* A few hundred bytes of brotli can name gigabytes of output, and this input
|
|
78
|
+
* arrives by paste from wherever the user found it. 50 accounts at ~1.6 KB of
|
|
79
|
+
* envelope each is under 100 KB, so 2 MB is twenty times the largest bundle
|
|
80
|
+
* this deck will ever produce and still nothing to allocate by accident.
|
|
81
|
+
*/
|
|
82
|
+
const SHARE_MAX_BYTES = 2 << 20;
|
|
41
83
|
|
|
42
84
|
// ── serialization ────────────────────────────────────────────────────────────
|
|
43
85
|
|
|
@@ -163,10 +205,14 @@ export async function readStore() {
|
|
|
163
205
|
return {
|
|
164
206
|
slots: Object.keys(accounts),
|
|
165
207
|
emails: Object.fromEntries(Object.entries(accounts).map(([k, v]) => [k, v?.email ?? ""])),
|
|
208
|
+
// The other half of the identity claude-swap keys an account by. One
|
|
209
|
+
// address under two organizations is two accounts on purpose, and a
|
|
210
|
+
// bundle carrying both must not report them as one - see identityKey.
|
|
211
|
+
orgs: Object.fromEntries(Object.entries(accounts).map(([k, v]) => [k, v?.organizationUuid ?? ""])),
|
|
166
212
|
activeNum: seq?.activeAccountNumber ?? null,
|
|
167
213
|
};
|
|
168
214
|
} catch {
|
|
169
|
-
return { slots: [], emails: {}, activeNum: null };
|
|
215
|
+
return { slots: [], emails: {}, orgs: {}, activeNum: null };
|
|
170
216
|
}
|
|
171
217
|
}
|
|
172
218
|
|
|
@@ -718,16 +764,37 @@ async function restoreActive(num) {
|
|
|
718
764
|
*/
|
|
719
765
|
export function wrapShare(payload, now = Date.now(), ttlMs = SHARE_TTL_MS) {
|
|
720
766
|
const body = JSON.stringify({ v: 1, exp: now + ttlMs, payload });
|
|
721
|
-
|
|
767
|
+
const packed = brotliCompressSync(Buffer.from(body, "utf8"), {
|
|
768
|
+
params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 11 },
|
|
769
|
+
});
|
|
770
|
+
return SHARE_PREFIX + packed.toString("base64");
|
|
722
771
|
}
|
|
723
772
|
|
|
724
|
-
/**
|
|
773
|
+
/**
|
|
774
|
+
* The inverse, for either prefix. Returns `{ok:true, payload}` or
|
|
775
|
+
* `{ok:false, reason}`.
|
|
776
|
+
*
|
|
777
|
+
* `v` inside the envelope is unchanged at 1 and deliberately so: it versions
|
|
778
|
+
* the SHAPE of the envelope, and that shape did not change. The prefix versions
|
|
779
|
+
* the encoding. Folding the two would have made an old blob unreadable for no
|
|
780
|
+
* reason, since its contents are exactly what this still expects.
|
|
781
|
+
*/
|
|
725
782
|
export function unwrapShare(blob, now = Date.now()) {
|
|
726
783
|
const text = String(blob ?? "").trim();
|
|
727
|
-
|
|
784
|
+
const v2 = text.startsWith(SHARE_PREFIX);
|
|
785
|
+
if (!v2 && !text.startsWith(SHARE_PREFIX_V1)) return { ok: false, reason: "not_a_share" };
|
|
728
786
|
let env;
|
|
729
787
|
try {
|
|
730
|
-
|
|
788
|
+
// Sliced by the prefix that actually matched. The two are the same length
|
|
789
|
+
// today and writing it this way is what keeps that from being load-bearing.
|
|
790
|
+
const bytes = Buffer.from(text.slice((v2 ? SHARE_PREFIX : SHARE_PREFIX_V1).length), "base64");
|
|
791
|
+
// maxOutputLength is the whole reason a bounded decompress is safe to point
|
|
792
|
+
// at pasted text; without it a short blob can name an allocation that ends
|
|
793
|
+
// the process.
|
|
794
|
+
const body = v2
|
|
795
|
+
? brotliDecompressSync(bytes, { maxOutputLength: SHARE_MAX_BYTES })
|
|
796
|
+
: bytes;
|
|
797
|
+
env = JSON.parse(body.toString("utf8"));
|
|
731
798
|
} catch {
|
|
732
799
|
return { ok: false, reason: "corrupt" };
|
|
733
800
|
}
|
|
@@ -738,39 +805,352 @@ export function unwrapShare(blob, now = Date.now()) {
|
|
|
738
805
|
return { ok: true, payload: env.payload };
|
|
739
806
|
}
|
|
740
807
|
|
|
808
|
+
/**
|
|
809
|
+
* claude-swap's own identity for an account, as one comparable string.
|
|
810
|
+
*
|
|
811
|
+
* `(email, organizationUuid)`, which is the composite `transfer.py` keys its
|
|
812
|
+
* duplicate check and its already-here check on. Matching on the address alone
|
|
813
|
+
* would fold one address's two organizations into a single row, and the whole
|
|
814
|
+
* reason cswap carries the org is that they are two accounts.
|
|
815
|
+
*
|
|
816
|
+
* The address is lower-cased because both sides of every comparison here come
|
|
817
|
+
* from the same store or the same bundle, so folding case can only join what a
|
|
818
|
+
* human would call the same account, never split one.
|
|
819
|
+
*/
|
|
820
|
+
export function identityKey(email, org) {
|
|
821
|
+
return `${String(email ?? "").trim().toLowerCase()} ${String(org ?? "")}`;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* N single-account envelopes, folded into the one bundle cswap will take back.
|
|
826
|
+
*
|
|
827
|
+
* `cswap export --account` names ONE account, so a chosen subset cannot come
|
|
828
|
+
* out of a single call and the deck has to do the folding. It is deliberately
|
|
829
|
+
* not a new format: the head envelope is spread whole and only `accounts` and
|
|
830
|
+
* `activeAccountNumber` are replaced, so `version`, `exportedFrom`,
|
|
831
|
+
* `swapVersion` and any field a later claude-swap adds arrive on the far side
|
|
832
|
+
* exactly as that claude-swap wrote them. Nothing here hard-codes its
|
|
833
|
+
* FORMAT_VERSION, because a constant copied out of another project's source is
|
|
834
|
+
* a constant that drifts.
|
|
835
|
+
*
|
|
836
|
+
* `activeAccountNumber` is re-guarded rather than carried: cswap only records
|
|
837
|
+
* it when that slot is in the payload, and a subset can drop the slot the head
|
|
838
|
+
* envelope pointed at. An import that referenced a missing account would be
|
|
839
|
+
* seeding an active slot that never arrived.
|
|
840
|
+
*
|
|
841
|
+
* A duplicate identity is DROPPED rather than passed on. `import_accounts`
|
|
842
|
+
* refuses a whole envelope over one repeated `(email, org)` pair, so carrying
|
|
843
|
+
* it would trade five shared accounts for a bundle that imports none.
|
|
844
|
+
*/
|
|
845
|
+
export function mergeExports(texts) {
|
|
846
|
+
const envelopes = [];
|
|
847
|
+
for (const text of texts) {
|
|
848
|
+
let env;
|
|
849
|
+
try { env = JSON.parse(text); } catch { return { ok: false, reason: "unreadable_export" }; }
|
|
850
|
+
if (!env || typeof env !== "object" || !Array.isArray(env.accounts)) {
|
|
851
|
+
return { ok: false, reason: "unreadable_export" };
|
|
852
|
+
}
|
|
853
|
+
envelopes.push(env);
|
|
854
|
+
}
|
|
855
|
+
if (!envelopes.length) return { ok: false, reason: "nothing_to_share" };
|
|
856
|
+
|
|
857
|
+
const head = envelopes[0];
|
|
858
|
+
// Every part came out of one binary in one pass, so a disagreement here is
|
|
859
|
+
// not a version to reconcile - it is a sign the parts are not what we think.
|
|
860
|
+
if (envelopes.some(e => e.version !== head.version)) return { ok: false, reason: "mixed_versions" };
|
|
861
|
+
|
|
862
|
+
const accounts = [];
|
|
863
|
+
const dropped = [];
|
|
864
|
+
const seen = new Set();
|
|
865
|
+
for (const env of envelopes) {
|
|
866
|
+
for (const a of env.accounts) {
|
|
867
|
+
const key = identityKey(a?.email, a?.organizationUuid);
|
|
868
|
+
if (seen.has(key)) { dropped.push({ num: String(a?.number ?? ""), email: a?.email ?? "" }); continue; }
|
|
869
|
+
seen.add(key);
|
|
870
|
+
accounts.push(a);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
if (!accounts.length) return { ok: false, reason: "nothing_to_share" };
|
|
874
|
+
|
|
875
|
+
const nums = new Set(accounts.map(a => String(a?.number ?? "")));
|
|
876
|
+
const active = envelopes
|
|
877
|
+
.map(e => e.activeAccountNumber)
|
|
878
|
+
.find(n => n != null && nums.has(String(n)));
|
|
879
|
+
return {
|
|
880
|
+
ok: true,
|
|
881
|
+
dropped,
|
|
882
|
+
envelope: { ...head, activeAccountNumber: active ?? null, accounts },
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* One account, or several, packaged for another deck.
|
|
888
|
+
*
|
|
889
|
+
* claude-swap's envelope carries each account's OAuth token in the clear - its
|
|
890
|
+
* own module header says so ("No encryption is built in"). The wrapper adds an
|
|
891
|
+
* expiry so a copy left behind in clipboard history stops working, and nothing
|
|
892
|
+
* more: it is not encryption and is not presented as any. A bundle makes that
|
|
893
|
+
* larger, not different - five accounts is five tokens on the clipboard -
|
|
894
|
+
* which is why the dialog states the count before the copy rather than after.
|
|
895
|
+
*
|
|
896
|
+
* Say the limit of the expiry out loud, because the UI used to imply the
|
|
897
|
+
* opposite. `exp` is a plain number inside plain base64'd JSON with NO key, MAC
|
|
898
|
+
* or signature over it, so anyone holding the text can decode it, write a later
|
|
899
|
+
* `exp`, re-encode, and import it. unwrapShare's check is therefore a check
|
|
900
|
+
* against staleness, not against an adversary - and it cannot be made into one
|
|
901
|
+
* here. A MAC needs a secret both decks hold, and two decks that already shared
|
|
902
|
+
* a secret would not need this function; and even a perfect signature would
|
|
903
|
+
* only stop THIS import path, since the payload it wraps is the credential
|
|
904
|
+
* itself and `cswap import` accepts it unwrapped. The honest fix for a share
|
|
905
|
+
* that got away is to sign the account out and back in. See share-expiry-
|
|
906
|
+
* forgeable.test.ts, which pins the forgery rather than leaving it implied.
|
|
907
|
+
*
|
|
908
|
+
* Only the accounts asked for are exported. Reading the whole store and then
|
|
909
|
+
* dropping the unwanted rows would be one spawn instead of several, and it
|
|
910
|
+
* would pull a refresh token this deck was never asked to move into this
|
|
911
|
+
* process; it would also lose the failure, because a whole-store export skips a
|
|
912
|
+
* slot with no backup credentials in silence while `--account` on that slot is
|
|
913
|
+
* a hard error naming it. A count that is quietly short is the one outcome a
|
|
914
|
+
* share must never have.
|
|
915
|
+
*
|
|
916
|
+
* The default export shape is used deliberately, never --full, which would
|
|
917
|
+
* embed the entire ~/.claude.json including every project and MCP server.
|
|
918
|
+
*/
|
|
919
|
+
export async function shareAccounts(nums) {
|
|
920
|
+
const asked = Array.isArray(nums) ? nums : [nums];
|
|
921
|
+
// One spawn per account, so the length of this list is a length of time the
|
|
922
|
+
// request holds. A store never has fifty accounts; a caller that sends nine
|
|
923
|
+
// hundred numbers is not a person picking from a panel, and the ceiling
|
|
924
|
+
// costs nothing to the one who is.
|
|
925
|
+
if (asked.length > MAX_SHARE_ACCOUNTS) return { ok: false, reason: "too_many" };
|
|
926
|
+
const wanted = [];
|
|
927
|
+
for (const raw of asked) {
|
|
928
|
+
const n = Number(raw);
|
|
929
|
+
if (!Number.isInteger(n) || n < 1 || n > 999) return { ok: false, reason: "bad_account" };
|
|
930
|
+
if (!wanted.includes(n)) wanted.push(n);
|
|
931
|
+
}
|
|
932
|
+
if (!wanted.length) return { ok: false, reason: "bad_account" };
|
|
933
|
+
|
|
934
|
+
// Names for the accounts that fail, read from the store before anything is
|
|
935
|
+
// spawned - because the failure sentence must never be built from the
|
|
936
|
+
// export's own output.
|
|
937
|
+
const store = await readStore();
|
|
938
|
+
const texts = [];
|
|
939
|
+
const failed = [];
|
|
940
|
+
for (const n of wanted) {
|
|
941
|
+
const r = await run(await cswapBin(), ["export", "-", "--account", String(n)], { timeout: CSWAP_TIMEOUT_MS });
|
|
942
|
+
if (!r.ok || !r.stdout.trim()) {
|
|
943
|
+
// The failure sentence is built from stderr ALONE for this one command,
|
|
944
|
+
// because its stdout is the credential. `failureText` concatenates
|
|
945
|
+
// `${stderr}\n${stdout}` and `firstUseful` takes the LAST non-empty line -
|
|
946
|
+
// right for every other cswap command, and here it means any stdout at all
|
|
947
|
+
// outranks the real error. claude-swap writes its diagnostics to stderr
|
|
948
|
+
// specifically so stdout stays pure JSON in pipe mode, and it writes the
|
|
949
|
+
// envelope as its last act; a non-zero exit after a partial write would
|
|
950
|
+
// therefore put the tail of `json.dumps(envelope, indent=2)` in front of the
|
|
951
|
+
// user, and one of those lines is the refresh token on its own.
|
|
952
|
+
//
|
|
953
|
+
// Nothing is lost by dropping it: the ENOENT branch keys off `r.code`, which
|
|
954
|
+
// `run` sets, and cmd.exe's "is not recognized" is stderr's.
|
|
955
|
+
failed.push({
|
|
956
|
+
num: String(n),
|
|
957
|
+
email: store.emails?.[String(n)] || "",
|
|
958
|
+
detail: failureText({ ...r, stdout: "" }, "cswap export"),
|
|
959
|
+
});
|
|
960
|
+
continue;
|
|
961
|
+
}
|
|
962
|
+
texts.push(r.stdout);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// Nothing came out at all. There is no partial bundle to hand over, so this
|
|
966
|
+
// is the plain failure the single-account share has always reported.
|
|
967
|
+
if (!texts.length) {
|
|
968
|
+
return { ok: false, reason: "export_failed", detail: failed[0]?.detail ?? "", failed };
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
const merged = mergeExports(texts);
|
|
972
|
+
if (!merged.ok) return { ok: false, reason: merged.reason, failed };
|
|
973
|
+
for (const d of merged.dropped) {
|
|
974
|
+
failed.push({ ...d, detail: "another slot already holds this address in this organization" });
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
const shared = merged.envelope.accounts.map(a => ({ num: String(a?.number ?? ""), email: a?.email ?? "" }));
|
|
978
|
+
return {
|
|
979
|
+
ok: true,
|
|
980
|
+
blob: wrapShare(JSON.stringify(merged.envelope)),
|
|
981
|
+
expiresAt: Date.now() + SHARE_TTL_MS,
|
|
982
|
+
// What the bundle CARRIES, never what was asked for. The copy row counts
|
|
983
|
+
// this list, so a bundle that came up short says so.
|
|
984
|
+
shared,
|
|
985
|
+
failed,
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/**
|
|
990
|
+
* The one-account case, which is a bundle of one and takes the same path.
|
|
991
|
+
*
|
|
992
|
+
* That path PARSES what cswap wrote, where this used to hand its stdout on
|
|
993
|
+
* opaquely — the cost of `shared` being able to promise that the count on the
|
|
994
|
+
* copy button is the count in the blob. So an export shape the fold cannot read
|
|
995
|
+
* now fails the single share too, not just the bundle. Deliberate: one path
|
|
996
|
+
* means the two cannot drift into two envelope shapes, and a share that
|
|
997
|
+
* silently carried something this deck could not account for is the failure
|
|
998
|
+
* the count exists to prevent.
|
|
999
|
+
*/
|
|
741
1000
|
export async function shareAccount(num) {
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
1001
|
+
return shareAccounts([num]);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* The identities a bundle carries, or `[]` when it cannot be read.
|
|
1006
|
+
*
|
|
1007
|
+
* The payload is the credential, so this takes the two fields it needs and
|
|
1008
|
+
* nothing else: no caller ever receives the parsed envelope, and a bundle that
|
|
1009
|
+
* will not parse degrades to an unnamed import rather than to an error, since
|
|
1010
|
+
* cswap is the one entitled to refuse it.
|
|
1011
|
+
*/
|
|
1012
|
+
export function bundleAccounts(payload) {
|
|
1013
|
+
let env;
|
|
1014
|
+
try { env = JSON.parse(payload); } catch { return []; }
|
|
1015
|
+
if (!env || typeof env !== "object" || !Array.isArray(env.accounts)) return [];
|
|
1016
|
+
const out = [];
|
|
1017
|
+
for (const a of env.accounts) {
|
|
1018
|
+
if (!a || typeof a !== "object") continue;
|
|
1019
|
+
const email = typeof a.email === "string" ? a.email.trim() : "";
|
|
1020
|
+
if (!email) continue;
|
|
1021
|
+
out.push({ email, org: typeof a.organizationUuid === "string" ? a.organizationUuid : "" });
|
|
1022
|
+
}
|
|
1023
|
+
// All of them or none. `wanted` is what the result list counts against, so a
|
|
1024
|
+
// bundle read as three when it holds four reports "1 of 3 imported" about a
|
|
1025
|
+
// paste of four - the missing one arrives and is never named, and a non-empty
|
|
1026
|
+
// list keeps the store-diff fallback from running to catch it. claude-swap
|
|
1027
|
+
// itself refuses an envelope whose entry has no address, so this is a guard
|
|
1028
|
+
// against a shape neither project has today rather than a live case.
|
|
1029
|
+
return out.length === env.accounts.length ? out : [];
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
/**
|
|
1033
|
+
* The same bundle, cut down to one account.
|
|
1034
|
+
*
|
|
1035
|
+
* What "update anyway" sends. `--force` overwrites every account it matches, so
|
|
1036
|
+
* a forced import of the whole bundle would rewrite credentials the user never
|
|
1037
|
+
* pointed at; narrowing first is what keeps an overwrite a named act. Written
|
|
1038
|
+
* as a filter over the original envelope rather than a fresh one, for the same
|
|
1039
|
+
* reason mergeExports spreads its head: those fields belong to claude-swap.
|
|
1040
|
+
*/
|
|
1041
|
+
export function narrowBundle(payload, key) {
|
|
1042
|
+
let env;
|
|
1043
|
+
try { env = JSON.parse(payload); } catch { return { ok: false, reason: "corrupt" }; }
|
|
1044
|
+
if (!env || typeof env !== "object" || !Array.isArray(env.accounts)) return { ok: false, reason: "corrupt" };
|
|
1045
|
+
const accounts = env.accounts.filter(a => identityKey(a?.email, a?.organizationUuid) === key);
|
|
1046
|
+
if (!accounts.length) return { ok: false, reason: "not_in_bundle" };
|
|
1047
|
+
const nums = new Set(accounts.map(a => String(a?.number ?? "")));
|
|
1048
|
+
const active = env.activeAccountNumber != null && nums.has(String(env.activeAccountNumber))
|
|
1049
|
+
? env.activeAccountNumber
|
|
1050
|
+
: null;
|
|
1051
|
+
return { ok: true, payload: JSON.stringify({ ...env, activeAccountNumber: active, accounts }) };
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* What happened to each account in the bundle, decided by the store.
|
|
1056
|
+
*
|
|
1057
|
+
* The store is the fact. `cswap import` narrates itself per account on stderr,
|
|
1058
|
+
* and parsing that as the primary answer would make a reworded release report
|
|
1059
|
+
* imports that did not happen - the failure mode `newSlot` already refuses for
|
|
1060
|
+
* the same reason. So the slot map before and after the run decides the two
|
|
1061
|
+
* outcomes that matter: an identity holding a slot it did not hold arrived, and
|
|
1062
|
+
* one absent from both never came.
|
|
1063
|
+
*
|
|
1064
|
+
* stderr is then read for one thing only, and one no store diff can show: an
|
|
1065
|
+
* account already present whose credentials were REWRITTEN in place, which
|
|
1066
|
+
* moves no slot. `Replaced` is claude-swap's dead-token auto-heal (its #136),
|
|
1067
|
+
* `Overwrote` is a `--force`. If either line is ever reworded the nuance is
|
|
1068
|
+
* lost and the row reads "already here", which is still true - the degradation
|
|
1069
|
+
* is a less specific report, never a wrong one.
|
|
1070
|
+
*
|
|
1071
|
+
* And it is applied ONLY where the address names exactly one row in the bundle.
|
|
1072
|
+
* cswap's line carries no organization, so with one address held under two of
|
|
1073
|
+
* them a single `Replaced me@x.com` would mark both rows healed and one of
|
|
1074
|
+
* those would be false - which is the thing the paragraph above promises this
|
|
1075
|
+
* never does.
|
|
1076
|
+
*/
|
|
1077
|
+
export function importOutcomes(before, after, wanted, stderr = "") {
|
|
1078
|
+
const slotsBy = (store) => new Map(
|
|
1079
|
+
(store?.slots ?? []).map(s => [identityKey(store?.emails?.[s], store?.orgs?.[s]), s]),
|
|
1080
|
+
);
|
|
1081
|
+
const had = slotsBy(before);
|
|
1082
|
+
const now = slotsBy(after);
|
|
1083
|
+
|
|
1084
|
+
// How many rows in this bundle share each address. An address held twice is
|
|
1085
|
+
// an address cswap's own narration cannot resolve.
|
|
1086
|
+
const byAddress = new Map();
|
|
1087
|
+
for (const w of wanted) {
|
|
1088
|
+
const a = String(w.email ?? "").trim().toLowerCase();
|
|
1089
|
+
byAddress.set(a, (byAddress.get(a) ?? 0) + 1);
|
|
1090
|
+
}
|
|
1091
|
+
const rewritten = new Map();
|
|
1092
|
+
for (const line of String(stderr ?? "").split(/\r?\n/)) {
|
|
1093
|
+
const m = /^\s*(Replaced|Overwrote)\s+(\S+)/.exec(line);
|
|
1094
|
+
if (!m) continue;
|
|
1095
|
+
const addr = String(m[2]).trim().toLowerCase();
|
|
1096
|
+
if ((byAddress.get(addr) ?? 0) !== 1) continue;
|
|
1097
|
+
rewritten.set(addr, m[1] === "Replaced" ? "healed" : "updated");
|
|
759
1098
|
}
|
|
760
|
-
|
|
1099
|
+
|
|
1100
|
+
return wanted.map(w => {
|
|
1101
|
+
const key = identityKey(w.email, w.org);
|
|
1102
|
+
const wasHere = had.has(key);
|
|
1103
|
+
const slot = now.get(key) ?? null;
|
|
1104
|
+
if (!wasHere && slot != null) return { email: w.email, org: w.org, num: slot, state: "imported" };
|
|
1105
|
+
if (wasHere) {
|
|
1106
|
+
return {
|
|
1107
|
+
email: w.email,
|
|
1108
|
+
org: w.org,
|
|
1109
|
+
num: had.get(key),
|
|
1110
|
+
state: rewritten.get(String(w.email ?? "").trim().toLowerCase()) ?? "present",
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
return { email: w.email, org: w.org, num: null, state: "failed" };
|
|
1114
|
+
});
|
|
761
1115
|
}
|
|
762
1116
|
|
|
763
|
-
|
|
1117
|
+
/**
|
|
1118
|
+
* A share, or a bundle of them, taken into this deck's store.
|
|
1119
|
+
*
|
|
1120
|
+
* Non-destructive by default and deliberately so: without `--force` claude-swap
|
|
1121
|
+
* adds what is missing, leaves a healthy account exactly as it is, and replaces
|
|
1122
|
+
* only a slot its own usage row has quarantined as refresh-token-dead. That is
|
|
1123
|
+
* already the rule a person would ask for - leave what works, fix what does
|
|
1124
|
+
* not - so the default run never passes the flag.
|
|
1125
|
+
*
|
|
1126
|
+
* `force` is honoured ONLY together with `only`, which names a single account.
|
|
1127
|
+
* A forced import of a whole bundle would rewrite every matching credential on
|
|
1128
|
+
* this machine, and a fresh token replaced by a stale one is not recoverable
|
|
1129
|
+
* from here - the fix is a re-login. Requiring the pair is what makes the
|
|
1130
|
+
* clobber something a person chose while looking at the address.
|
|
1131
|
+
*/
|
|
1132
|
+
export async function importAccount(blob, { force = false, only = null } = {}) {
|
|
764
1133
|
const un = unwrapShare(blob);
|
|
765
1134
|
if (!un.ok) return { ok: false, reason: un.reason };
|
|
766
1135
|
|
|
1136
|
+
let payload = un.payload;
|
|
1137
|
+
const narrowing = only != null;
|
|
1138
|
+
if (narrowing) {
|
|
1139
|
+
const cut = narrowBundle(payload, identityKey(only?.email, only?.org));
|
|
1140
|
+
if (!cut.ok) return { ok: false, reason: cut.reason };
|
|
1141
|
+
payload = cut.payload;
|
|
1142
|
+
}
|
|
1143
|
+
const overwrite = force === true && narrowing;
|
|
1144
|
+
const wanted = bundleAccounts(payload);
|
|
1145
|
+
|
|
767
1146
|
return withStoreLock(async () => {
|
|
768
1147
|
const before = await readStore();
|
|
769
|
-
const
|
|
770
|
-
child
|
|
1148
|
+
const args = overwrite ? ["import", "-", "--force"] : ["import", "-"];
|
|
1149
|
+
const child = runInteractive(await cswapBin(), args, { timeout: CSWAP_TIMEOUT_MS });
|
|
1150
|
+
child.write(payload);
|
|
771
1151
|
// cswap reads stdin to EOF, so the pipe has to close for it to proceed.
|
|
772
1152
|
//
|
|
773
|
-
// This used to write a raw EOT byte and then call `endStdin(child)`
|
|
1153
|
+
// This used to write a raw EOT byte and then call `endStdin(child)` - a
|
|
774
1154
|
// helper that was never written. EOT only means end-of-file on a TTY, so
|
|
775
1155
|
// the byte did nothing to a pipe, and the call threw ReferenceError before
|
|
776
1156
|
// cswap ever saw the payload: the route answered 500 and the dialog fell
|
|
@@ -782,15 +1162,32 @@ export async function importAccount(blob) {
|
|
|
782
1162
|
if (!r.ok) return { ok: false, reason: "import_failed", detail: failureText(r, "cswap import") };
|
|
783
1163
|
|
|
784
1164
|
const after = await readStore();
|
|
785
|
-
const slot = newSlot(before, after);
|
|
786
1165
|
invalidateClaudeAccountsCache();
|
|
787
|
-
|
|
788
|
-
//
|
|
789
|
-
|
|
790
|
-
//
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
1166
|
+
|
|
1167
|
+
// An unreadable envelope was still imported, or still refused, by cswap -
|
|
1168
|
+
// only the naming is lost. Fall back to the store's own new slots so the
|
|
1169
|
+
// dialog can say what arrived even then.
|
|
1170
|
+
const results = wanted.length
|
|
1171
|
+
? importOutcomes(before, after, wanted, r.stderr)
|
|
1172
|
+
: (() => {
|
|
1173
|
+
const had = new Set(before.slots ?? []);
|
|
1174
|
+
return (after.slots ?? []).filter(s => !had.has(s))
|
|
1175
|
+
.map(s => ({ email: after.emails?.[s] || "", org: after.orgs?.[s] || "", num: s, state: "imported" }));
|
|
1176
|
+
})();
|
|
1177
|
+
|
|
1178
|
+
const arrived = results.filter(x => x.state === "imported");
|
|
1179
|
+
if (arrived.length) runDetached(await cswapBin(), ["list"]);
|
|
1180
|
+
return {
|
|
1181
|
+
ok: true,
|
|
1182
|
+
results,
|
|
1183
|
+
// Nothing new is not an error: without --force, cswap skips an account it
|
|
1184
|
+
// already holds. Saying which happened is the difference between "it
|
|
1185
|
+
// worked" and "why is nothing different".
|
|
1186
|
+
added: arrived.length > 0,
|
|
1187
|
+
num: arrived.length === 1 ? arrived[0].num : null,
|
|
1188
|
+
email: arrived.length === 1 ? arrived[0].email : null,
|
|
1189
|
+
output: firstUseful(r.stdout),
|
|
1190
|
+
};
|
|
794
1191
|
});
|
|
795
1192
|
}
|
|
796
1193
|
|
|
@@ -807,6 +1204,58 @@ export function removePromptMatches(line, num) {
|
|
|
807
1204
|
return Boolean(m) && m[1] === String(num);
|
|
808
1205
|
}
|
|
809
1206
|
|
|
1207
|
+
/**
|
|
1208
|
+
* Re-capture the active slot's credentials, which is what unfreezes a row whose
|
|
1209
|
+
* stored copy died.
|
|
1210
|
+
*
|
|
1211
|
+
* WHY THIS EXISTS AND WHY IT IS NOT A LOGIN. claude-swap keeps its own copy of
|
|
1212
|
+
* each account's credentials, taken when the slot was added. When that copy's
|
|
1213
|
+
* refresh token dies it quarantines the row: no further collection is
|
|
1214
|
+
* attempted, so the numbers freeze and every Refresh re-reads a store that
|
|
1215
|
+
* cannot change. #721 fixed the sentence the panel said about that state; this
|
|
1216
|
+
* is the button that ends it.
|
|
1217
|
+
*
|
|
1218
|
+
* `cswap add` on an account already in the store is an idempotent credential
|
|
1219
|
+
* refresh — registerSignedIn above says so in its own words: "No new slot means
|
|
1220
|
+
* the account was already managed and cswap refreshed its credentials in
|
|
1221
|
+
* place." It captures whatever is signed in RIGHT NOW, which for the active
|
|
1222
|
+
* slot is the account the row belongs to, with the working credentials the user
|
|
1223
|
+
* already has. Nobody is signed in or out, no browser opens, and the active
|
|
1224
|
+
* account does not change.
|
|
1225
|
+
*
|
|
1226
|
+
* It only ever repairs the ACTIVE slot, because "what is signed in right now"
|
|
1227
|
+
* is the only thing `cswap add` can see. The panel offers it nowhere else.
|
|
1228
|
+
*/
|
|
1229
|
+
export async function recaptureActive() {
|
|
1230
|
+
return withStoreLock(async () => {
|
|
1231
|
+
// Who is actually signed in, asked before and after, because `cswap add`
|
|
1232
|
+
// captures the live credentials and this is the one check that the thing it
|
|
1233
|
+
// captured is the thing the user meant.
|
|
1234
|
+
const before = await currentIdentity();
|
|
1235
|
+
if (!before?.email) return { ok: false, reason: "not_signed_in" };
|
|
1236
|
+
|
|
1237
|
+
const add = await run(await cswapBin(), ["add"], { timeout: CSWAP_TIMEOUT_MS });
|
|
1238
|
+
if (!add.ok) return { ok: false, reason: "add_failed", error: addFailureText(add) };
|
|
1239
|
+
|
|
1240
|
+
invalidateClaudeAccountsCache();
|
|
1241
|
+
// AWAITED, NOT DETACHED, AND THAT IS THE WHOLE DIFFERENCE THE PRESS MAKES.
|
|
1242
|
+
// `cswap add` clears the strike instantly, so a detached collection left a
|
|
1243
|
+
// window where the badge was gone but the numbers were still twenty hours
|
|
1244
|
+
// old and the row still said "due" in amber — a press that looked like it
|
|
1245
|
+
// had done nothing, which is the complaint this button exists to answer.
|
|
1246
|
+
// Waiting costs a few seconds and returns a row that has actually moved.
|
|
1247
|
+
//
|
|
1248
|
+
// Its failure is not the press's failure: the credentials are captured
|
|
1249
|
+
// either way, and claude-swap's own schedule will collect within minutes.
|
|
1250
|
+
// So a timeout here still reports success, with `collected: false` for a
|
|
1251
|
+
// caller that wants to say so.
|
|
1252
|
+
const collect = await run(await cswapBin(), ["list"], { timeout: CSWAP_TIMEOUT_MS })
|
|
1253
|
+
.catch(() => null);
|
|
1254
|
+
invalidateClaudeAccountsCache();
|
|
1255
|
+
return { ok: true, email: before.email, collected: collect?.ok === true };
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
|
|
810
1259
|
export async function removeAccount(num) {
|
|
811
1260
|
const n = Number(num);
|
|
812
1261
|
if (!Number.isInteger(n) || n < 1 || n > 999) return { ok: false, reason: "bad_account" };
|