Files
magpie/internal/backup
Junjie Zhouandmultica-agent 9ed8750de0 backup: pin the #880 subscription sign-in policy with a test (#901)
* backup: pin the #880 subscription sign-in policy with a test

A backup must not carry a subscription's sign-in: it is the sign-in of an
agent on one machine, so a bundle that carried one would sign the agent in
on the machine it was put on. #880 asked for that and was closed as
intentional. Nothing pinned it, so this adds the test that does.

Both stores, both credential policies, and the asymmetry the report tripped
over together: a subscription moved onto its plugin still has a row in
providers.json, and the row travels so the receiving machine offers the
subscription at all — the credential behind it does not. A user-typed key
is the control, so the policy cannot be read as "nothing credential-shaped
syncs", and Restore is checked for leaving this machine's sign-ins alone.

scopedKeys has no case for "providers" and falls through to the whole-
bundle bit. That fall-through is deliberately not pinned: scopedKeys is
called once, with "settings", so there is nothing observable to assert,
and a test that did would go red for a good reason the day the case is
added. The tests around it pass either way; the reason is written down.

Co-authored-by: multica-agent <github@multica.ai>

* backup: say what the #880 sign-in tests actually guarantee

Review of 974ca819 found no blocking problem but two comments that
pinned more than they could back up.

carried() claimed that a credential absent from a bundle's marshalled
bytes cannot be inside a sealed backup either. That is false, and it was
measured rather than argued: Seal marshals the whole Bundle in one
json.Marshal, but a []byte field is base64 in those bytes, and a Collect
that copies plugin-auth.json into b.Icons["auth-state.bin"] seals the
whole plugin store while every one of these tests stays green. The probe
is still the right one for what the policy is about, which is a
credential arriving as plain text, so the comment now says that and
records the two limits that keep it honest: a literal check misses JSON
escaping, and a Seal that grew a wire type of its own would decouple
carried() from the bytes it claims to describe.

TestRestoreLeavesLocalSignInsAlone compared the two sign-in stores byte
for byte. Byte equality is file identity, not policy, and re-indenting
logins.json — same accounts, same credentials, same counts — turned it
red. It now compares the sign-ins: who is signed in, and the credential
behind each, canonicalised, in a fixed order. An incidental reformat
cannot fail it, and a restore that really did take the sign-ins over
still does, which a probe that overwrote both stores confirms.

Also notes that TestCollectCarriesTheRowOfAMovedSubscription does not
compare Headers, and where that coverage does live.

Co-authored-by: multica-agent <github@multica.ai>

* backup: apply the #901 maintainer's four review changes

Drops the two line-number references in favour of the function names
Seal and scopedKeys("settings"), which do not go stale when the file
moves: Seal does the single json.Marshal of the Bundle (backup.go:252)
and the scopedKeys("settings") call is inside Restore (backup.go:401),
so both attributions check out.

Renames the four plugin fixtures that named a vendor they never used.
fixtureGrok* are written under movedProviderID ("opencode-zen") and
fixtureGeminiKey under "opencode-copilot"; both are OpenCode plugin
providers, so the names now say plugin. The built-in codex and claude
fixtures are left alone — those agents really do own those logins.

Removes TestScopedKeysFallsBackToTheWholeBundleBit and
TestScopedKeysPrefersAPartsOwnMarker with setScopedMarker. They are
about part-scoped key markers, not about the sign-in policy this file
states, and the file header no longer leans on them.

Closes the Icons blind spot instead of documenting it away. carried()
only ever saw plain text, and Bundle.Icons is map[string][]byte, so a
Collect copying plugin-auth.json into b.Icons["auth-state.bin"] put
the whole plugin store in a sealed backup with every assertion green.
whereSignInIs searches the decoded value of every Icons entry as well
as the marshalled bytes, and names the layer in the failure. Both the
keyless and keyed subtests now go red under that mutation, where they
previously passed.

Test-only: one file, no production code.

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-10-05 21:39:23 +08:00
..
gateway, gui: a provider's keys can be routed by weight, a key weighing 3 taking three requests for every one a key weighing 1 takes (dengtongcai, #841) — Routing already spread requests over a provider's keys in order, in turn, least used first or by pace, but never in a set proportion: two team keys meant to split 75/25 could only go evenly or one after the other. Routing now has By weight (routing "weight") for a provider with keys: each key that's on shows its weight and its share beside it in the editor (Weight 3 · 75%), a click edits it in place and posts keys/weight (provider.SetKeyWeight, 1 to 1000; none or 0 counts as 1, kept out of the file), and providers.json takes it as "weight" on each of keys (keyWeight for the first). The gateway picks as NGINX's smooth weighted round robin does (weighted.go): 3, 1 and 4 go round in every 8 requests in that ratio, never bunched; the others follow in their order for failover. A key resting after a failure is left out of the round while it rests, the others taking its share by their weights, and goes last. A conversation still stays with its key as Stays says (cache-warm across turns by default, within a turn always), and a request kept so is counted against the key that took it, not the one the round picked, so new conversations make up the share; Stays Off gives the ratio request by request. The weight goes with its key through Make first, the editor's Save, backups and WebDAV sync. The Routing page offers By weight for key pools and explains a pick by it; sink (Goes to the back) is off for it as for In turn. zh, ja, de strings. TestWeightedKeys (3:1:4 exact in each of 50 rounds of 8 through perKey and route; with one resting the other two 75/25 and the resting one last; a kept request made up by the next picks) and TestSetKeyWeight fail on the old code (no Weighted); key-weight.test.cjs (WebKit, en and zh: By weight shows the weights and shares, a click sets one and the shares redraw, Smart hides them, no scroll) fails on the old code (no By weight). key-import, panel-routing, routing-save, rate-limited-sink, routing-reset-deadline, account-arrange, gui-ja, gui-de and click-scroll pass on WebKit. go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
2026-10-05 08:50:57 +08:00
test: every package's tests pass on Windows, and none writes outside its sandbox (the user: 把 Windows 上那几个失败的测试修掉) — on a Windows box the suite failed in most packages, and the root package hung for 25 minutes. Tests set HOME, which os.UserHomeDir ignores on Windows (it reads USERPROFILE), so they read and wrote the runner's own home; each now sets USERPROFILE beside HOME, and Devin's, Goose's, Crush's and Claude Desktop's tests set APPDATA/LOCALAPPDATA, where those keep their files there. said() read the command's output only once it returned, and a pipe holds a few KB on Windows, so magpie model --help blocked forever; it reads while the command writes. Unix permission bits (the gateway key store's 0600 too), exec bits and symlinks Windows lacks are checked only off Windows; TOML strings hold Windows paths as literal strings, JSON as marshalled ones; a URL's path is compared as one. The provider and usage packages run in a home of their own (TestMain, as the gateway's already does), since a CLI's identity kept by a refresh still going when its test ended landed in the runner's ~/.config/magpie — and on a Mac in the user's own. Two product fixes came out of it: imagemcp took a file:///C:/… root or image for the path /C:/…, which doesn't exist on Windows, and now makes a Windows path of it (a host for a UNC share); and session discovery could keep a new Cowork session hidden on Linux, where a folder's mtime is a kernel tick and its size doesn't change, so an entry added within the tick of the last listing left both as they were — a listing made within 2s of its folder's last change is read again, as git does for racy files. TestFilePath and TestRacyDirectoryListedAgain fail without the change. On Windows 11 every package passes (32 of 32) and a run of each package in a fresh home leaves nothing in home, APPDATA or LOCALAPPDATA; on Linux (WSL) and macOS go test -tags nogui ./... passes. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build pass.
2026-10-01 23:15:07 +08:00
usage: with WebDAV or S3 sync, each computer shares its usage and sees the others', the total and each computer's part (#542) — sync carried keys, agents and the library, and each computer's Usage page counted only the calls made on it. Sync now has a Usage tick (off unless picked, usage=yes in magpie webdav/magpie s3): a computer with it on puts its own calls up as one file a day, magpie/usage/<id>-<day>.magpie-usage beside the bundle (under the prefix's usage/ on S3), each a gzipped list of times, agents, providers, models, tokens, costs and statuses, sealed with the sync passphrase (backup.SealData, the key cached per passphrase so a sync derives it once); no request body, session, archive name or key goes, a call another computer passed on over the LAN (Via) isn't put up again, and an error is cut to 300 characters. The id is a random one kept in usage-computer.json, which no backup carries, with the host name beside it. A computer only ever writes and deletes its own names, so none overwrites another's; the files are not in the bundle and its hash, so sharing usage never makes a sync or a conflict of the bundle; the first sync puts up the last 90 days, after that only yesterday and today, at most every 15 minutes (a Save or Sync now at once), and a day past 90 is deleted by the computer it is of. The others' files, listed (PROPFIND Depth 1; ListObjectsV2) and fetched only when changed, are kept in usage-others and read with the ledger, as Record.Computer; a failure is said in the sync row ("Couldn't share usage") and never fails the sync. The Requests page and the panel's Usage tab get a Computer pick (this computer, other computers when more than one, each by name) sending computer=this|others|<id>; a row from another computer says "· on <name>" beside its agent, and its details name the computer and say what was said stays there, nothing asked of this computer's session files for it. With no other computer's usage there is no pick and nothing new on either page, and an older magpie never lists or reads usage/. TestUsageSharedBetweenComputers (computers a and b over a fake WebDAV under httptest: each sees 4 calls, the totals and its own and the other's part, nothing counted twice over repeated syncs, the bundle not put again for usage, a new call reaching the other, a day past the window deleted), TestUsageSharedThroughS3 (the same through a fake S3, signed list and delete, a prefix with a space and a plus) and TestUsageOffAsksNothing are new and don't build without the change; usage-computers.test.cjs (WebKit, en and zh: no pick without other computers, the pick and computer=this, another computer's row and details with no /content asked, the panel's pick, the sync form's tick posting usage: true, no click moving the page) fails without it on all but the no-other-computers case. s3-sync, sync-other-kind, panel-usage, request-archive-usage, click-scroll and usage-ledger-chart pass in WebKit. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass (TestFXInState failed once on its TempDir cleanup, passes alone). Turning the Usage tick off, or sync off, lets the other computers' usage kept here go (DropAllShared), so the Usage page counts this computer's alone again: TestUsageSharedBetweenComputers checks both, and fails without it.
2026-10-02 17:47:36 +08:00
gateway: a model's web search goes to a search API of the user's own when no provider can search (#419 by HwFee: let me set up Tavily, Brave or SearXNG with my key rather than lose the search tool) — with no provider that searches by itself and no provider magpie could search with, the gateway took the client's web search tool away. Tavily, Brave Search, Exa, Firecrawl and SearXNG (an address of one's own, a key only if it asks for one) can now be set up, in an order: the injected web_search tool is then offered, and a search goes to the searcher's model as before, or, with none or when it fails, to each search API in turn, called directly with no model, its results handed back as the same hits and text the searcher path gives, so clients see the same web_search_tool_result blocks. The order is provider searches itself, searcher provider, search APIs, strip. They are kept in providers.json (0600) like provider keys; a backup carries them, their keys only when it carries keys, and one restored without keys keeps the key here; sync does the same, and a backup or sync from a magpie before them leaves the ones here alone. magpie search lists them, magpie search add <api> [key] [url=…] adds or updates one, magpie search rm <api> removes one; Settings has a Web search section (联网搜索) to add and remove them. Prompts, identity lines and headers to model providers are unchanged. TestWebSearchBySearchAPI, TestNoSearchAPINoTool, TestBackupSearchAPIs, TestSyncSearchAPIs and search-api.test.cjs fail without the change; TestSearchAPIsEachAsked checks each vendor against a fake, TestSearchCLI the command. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass; search-api, provider-search, click-scroll, old-webkit pass.
2026-10-01 19:44:44 +08:00