@openchambery/web 2.1.8-beta.3 → 2.1.8-beta.5

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.
Files changed (117) hide show
  1. package/dist/assets/{AgentAvatar-DTgZob6V.js → AgentAvatar-qSjSZvIG.js} +1 -1
  2. package/dist/assets/{AssistantView-CzNcYiVL.js → AssistantView-DOr44dum.js} +3 -3
  3. package/dist/assets/{DiagramView-Cmdi3PAV.js → DiagramView-DwjEsYl2.js} +1 -1
  4. package/dist/assets/{FadeInOnReveal-Dcu_ZfKa.js → FadeInOnReveal-CD5d1k_u.js} +1 -1
  5. package/dist/assets/{Icon-BKVaYkO0.js → Icon-QASb6r3F.js} +1 -1
  6. package/dist/assets/{ImageLoadingPlaceholder-CICdqe37.js → ImageLoadingPlaceholder-BuGiEI7w.js} +1 -1
  7. package/dist/assets/{LoadingImage-DC63uLZK.js → LoadingImage-FRySi1BM.js} +1 -1
  8. package/dist/assets/{MarkdownRenderer-CIapvMEi.js → MarkdownRenderer-BVTJzJKH.js} +2 -2
  9. package/dist/assets/{MarkdownRendererImpl-CuovZBls.js → MarkdownRendererImpl-DKRawvvq.js} +1 -1
  10. package/dist/assets/{MarkstreamRendererImpl-DtQlVw4f.js → MarkstreamRendererImpl-8IdRnZ2d.js} +1 -1
  11. package/dist/assets/{MobileDetailNavigation-BcQ1gCG1.js → MobileDetailNavigation-WqYrphqb.js} +1 -1
  12. package/dist/assets/{MobileShareBridge-CZbknYvz.js → MobileShareBridge-C6AvFHjG.js} +1 -1
  13. package/dist/assets/MobileTabPageHeader-BbDsAtKl.js +2 -0
  14. package/dist/assets/{MultiRunWindow-CsDXEsR_.js → MultiRunWindow-19VPj6Of.js} +1 -1
  15. package/dist/assets/{OnboardingScreen-B8R06QbP.js → OnboardingScreen-CHa767GT.js} +1 -1
  16. package/dist/assets/{OverlayScrollbar-BrYVChrM.js → OverlayScrollbar-C7Ls30Hy.js} +1 -1
  17. package/dist/assets/{SettingsView-bWUvVuP2.js → SettingsView-DruiCw4C.js} +10 -10
  18. package/dist/assets/{SettingsWindow-Cs1Z7BAg.js → SettingsWindow-BBfF03M7.js} +1 -1
  19. package/dist/assets/{TerminalView-Cfa5bIj1.js → TerminalView-BE1wI19_.js} +1 -1
  20. package/dist/assets/{ToolOutputDialog-ChVqyMGX.js → ToolOutputDialog-DOXDmUxQ.js} +1 -1
  21. package/dist/assets/{appThemeRegistry-CuS2weCQ.js → appThemeRegistry-D4TkQDbf.js} +1 -1
  22. package/dist/assets/{button-NnBwy1et.js → button-DOrwfkre.js} +1 -1
  23. package/dist/assets/{collapsible-Divs2HzS.js → collapsible-BJwPZMpo.js} +1 -1
  24. package/dist/assets/{diagram-Dw2fVTEr.js → diagram-BFevUcKu.js} +1 -1
  25. package/dist/assets/{dialog-thtcgEoi.js → dialog-FSXwOR2i.js} +1 -1
  26. package/dist/assets/{dropdown-menu.styles-DG86fojG.js → dropdown-menu.styles-CfTQwJHe.js} +1 -1
  27. package/dist/assets/{es-CyvQWK39.js → es-VbQQdCdi.js} +1 -1
  28. package/dist/assets/{fileMentionTouchSelection-D9YYbFDF.js → fileMentionTouchSelection-DuGw9C0F.js} +1 -1
  29. package/dist/assets/{fr-BgKUUIer.js → fr-BotRbS9t.js} +1 -1
  30. package/dist/assets/{gitApi-q2aVvcM1.js → gitApi-QxaZos8j.js} +1 -1
  31. package/dist/assets/{input-BFFtmAyl.js → input-Ctgb06ot.js} +1 -1
  32. package/dist/assets/{insertionBoundaries-B2M9QiEF.js → insertionBoundaries-BFtbjSBb.js} +1 -1
  33. package/dist/assets/{ja-vAsqZ3i2.js → ja-l_gq6Coh.js} +1 -1
  34. package/dist/assets/{ko-CjgHIGr_.js → ko-yhU5rfe5.js} +1 -1
  35. package/dist/assets/{main-B3grbXe8.js → main-BJckugyb.js} +2 -2
  36. package/dist/assets/{main-4h6m9pZ0.js → main-CSpFdmT4.js} +10 -10
  37. package/dist/assets/{markdownHeightCache-qy-s2Sd8.js → markdownHeightCache-B6drDnVO.js} +1 -1
  38. package/dist/assets/{mcpQueries-BD28dy9B.js → mcpQueries-WrVQC4xT.js} +1 -1
  39. package/dist/assets/miniChat-YVKGcjkV.js +2 -0
  40. package/dist/assets/mobile-DtAt6Tnq.js +2 -0
  41. package/dist/assets/{multirun-Dhq8Cx1W.js → multirun-lQfWeDYv.js} +1 -1
  42. package/dist/assets/{pl-CNRmHctC.js → pl-BkynZgOJ.js} +1 -1
  43. package/dist/assets/{projectMeta-2J4Ull4h.js → projectMeta-gv05m6Dm.js} +1 -1
  44. package/dist/assets/{pt-BR-B4NRHMT2.js → pt-BR-Dw_Dokez.js} +1 -1
  45. package/dist/assets/renderElectronMiniChatApp-BRhwzG7e.js +2 -0
  46. package/dist/assets/{renderMobileApp-Zl-L5gSE.js → renderMobileApp-FbnzqDL3.js} +6 -6
  47. package/dist/assets/{runtimeConfig-GAVzvQGc.js → runtimeConfig-Bm1EPWPq.js} +1 -1
  48. package/dist/assets/{runtimeEndpointReset-DdNzM2Ae.js → runtimeEndpointReset-C4gavnsn.js} +5 -5
  49. package/dist/assets/{scheduledTaskNavigation-DqwoOss-.js → scheduledTaskNavigation-DEMPeoot.js} +1 -1
  50. package/dist/assets/sessionChangeSummary-DkNmkmIq.js +1 -0
  51. package/dist/assets/{sessionLookup-BPCTTSdg.js → sessionLookup-DWJfnR75.js} +1 -1
  52. package/dist/assets/{sortable-tabs-strip-BLrK5Ln9.js → sortable-tabs-strip-BVnwfMAa.js} +1 -1
  53. package/dist/assets/speechSettingsQueries-CoNlugxH.js +4 -0
  54. package/dist/assets/{store-_hKtW1V3.js → store-COtDAfPx.js} +2 -2
  55. package/dist/assets/{textarea-CN-vZS5F.js → textarea-DJ52TDJj.js} +1 -1
  56. package/dist/assets/{tooltip-uzT2SsYJ.js → tooltip-JgW0lJaV.js} +1 -1
  57. package/dist/assets/{uk-CXna2F44.js → uk-DSl09YqU.js} +1 -1
  58. package/dist/assets/{useAppFontEffects-BlstuBSz.js → useAppFontEffects-DAeZcw5Z.js} +51 -53
  59. package/dist/assets/{useDesktopWindowControlsLayout-BhS1NmBt.js → useDesktopWindowControlsLayout-ia1w5b_c.js} +1 -1
  60. package/dist/assets/{useEffectiveDirectory-CiR2SqiL.js → useEffectiveDirectory-BWMFhdHo.js} +1 -1
  61. package/dist/assets/{useMobileNavigationStore-BRqifj9r.js → useMobileNavigationStore-D7wof6Tl.js} +6 -6
  62. package/dist/assets/{useWorkerHighlightedLines-Jea_E56b.js → useWorkerHighlightedLines-DdBSr8AB.js} +1 -1
  63. package/dist/assets/vendor-codemirror-autocomplete-7c2L_4dM.js +12 -0
  64. package/dist/assets/vendor-codemirror-commands-B8KrBQ9B.js +1 -0
  65. package/dist/assets/vendor-codemirror-lang-angular-DXOgWEVj.js +23 -0
  66. package/dist/assets/{vendor-codemirror-lang-cpp-C3UKgJaQ.js → vendor-codemirror-lang-cpp-Dh8C3xqP.js} +1 -1
  67. package/dist/assets/{vendor-codemirror-lang-go-Cv5NYdGm.js → vendor-codemirror-lang-go-BXAJ1_dh.js} +5 -5
  68. package/dist/assets/{vendor-codemirror-lang-java-D6hBTIgD.js → vendor-codemirror-lang-java-DAyCHRhk.js} +1 -1
  69. package/dist/assets/{vendor-codemirror-lang-jinja-ehGXhAov.js → vendor-codemirror-lang-jinja-0TICrMfO.js} +1 -1
  70. package/dist/assets/{vendor-codemirror-lang-json-tHviF6YN.js → vendor-codemirror-lang-json-mLulNbwB.js} +1 -1
  71. package/dist/assets/{vendor-codemirror-lang-less-B-yDfSDo.js → vendor-codemirror-lang-less-CNvptX3P.js} +1 -1
  72. package/dist/assets/{vendor-codemirror-lang-liquid-BdoLH4KN.js → vendor-codemirror-lang-liquid-8g5seytq.js} +1 -1
  73. package/dist/assets/vendor-codemirror-lang-markdown-CV1IO6Qs.js +6 -0
  74. package/dist/assets/{vendor-codemirror-lang-php-Ch8GW4Xz.js → vendor-codemirror-lang-php-xP7rf28_.js} +1 -1
  75. package/dist/assets/vendor-codemirror-lang-python-YM2WVLHF.js +9 -0
  76. package/dist/assets/{vendor-codemirror-lang-rust-BEUlElU6.js → vendor-codemirror-lang-rust-CtafR0gt.js} +1 -1
  77. package/dist/assets/vendor-codemirror-lang-sass-B4iav_Ps.js +1 -0
  78. package/dist/assets/{vendor-codemirror-lang-sql-BnZql454.js → vendor-codemirror-lang-sql-C6q09UUZ.js} +2 -2
  79. package/dist/assets/{vendor-codemirror-lang-vue-BCwQBvLR.js → vendor-codemirror-lang-vue-DIB78UsR.js} +1 -1
  80. package/dist/assets/{vendor-codemirror-lang-wast-mrtA2eML.js → vendor-codemirror-lang-wast-C_JVdOCF.js} +1 -1
  81. package/dist/assets/{vendor-codemirror-lang-xml-ClAh-G2D.js → vendor-codemirror-lang-xml-DIuzY_d4.js} +1 -1
  82. package/dist/assets/{vendor-codemirror-lang-yaml-CjL8XEf2.js → vendor-codemirror-lang-yaml-BO0hIfQM.js} +1 -1
  83. package/dist/assets/{vendor-codemirror-language-data-DGDYune4.js → vendor-codemirror-language-data-Ctd4WxgW.js} +2 -2
  84. package/dist/assets/vendor-codemirror-search-ZqtrPLjU.js +2 -0
  85. package/dist/assets/{vendor-replit-codemirror-vim-CEibx3EP.js → vendor-replit-codemirror-vim-DDj0dSVY.js} +6 -6
  86. package/dist/assets/{zh-CN-C0S7hK8T.js → zh-CN-NtO1bA00.js} +1 -1
  87. package/dist/assets/{zh-TW-CVG-Rqd-.js → zh-TW-CF0z_L0k.js} +1 -1
  88. package/dist/index.html +2 -2
  89. package/dist/mini-chat.html +2 -2
  90. package/dist/mobile.html +2 -2
  91. package/package.json +1 -1
  92. package/server/lib/config-sync/DOCUMENTATION.md +10 -5
  93. package/server/lib/config-sync/engine.js +1 -1
  94. package/server/lib/config-sync/magpie-auth.js +111 -0
  95. package/server/lib/config-sync/magpie-auth.test.js +79 -0
  96. package/server/lib/config-sync/magpie.js +76 -7
  97. package/server/lib/config-sync/magpie.test.js +102 -2
  98. package/server/lib/opencode/DOCUMENTATION.md +2 -0
  99. package/server/lib/opencode/config-entities-layering.test.js +2 -1
  100. package/server/lib/opencode/lifecycle.js +4 -0
  101. package/server/lib/opencode/lifecycle.test.js +7 -2
  102. package/server/lib/opencode/plugin-routes.test.js +1 -1
  103. package/server/lib/opencode/plugins.js +31 -21
  104. package/server/lib/opencode/plugins.test.js +55 -10
  105. package/dist/assets/MobileTabPageHeader-DjbyneLy.js +0 -2
  106. package/dist/assets/miniChat-ChSEmnwp.js +0 -2
  107. package/dist/assets/mobile-CrKlS4SH.js +0 -2
  108. package/dist/assets/renderElectronMiniChatApp-DsPg8kQs.js +0 -2
  109. package/dist/assets/sessionChangeSummary-CFlwf4sL.js +0 -2
  110. package/dist/assets/speechSettingsQueries-D4rx8jJk.js +0 -4
  111. package/dist/assets/vendor-codemirror-autocomplete-DHomsvW7.js +0 -12
  112. package/dist/assets/vendor-codemirror-commands-C9DCaJ0C.js +0 -1
  113. package/dist/assets/vendor-codemirror-lang-angular-BDXnzWgq.js +0 -23
  114. package/dist/assets/vendor-codemirror-lang-markdown-Bt-vLz9k.js +0 -6
  115. package/dist/assets/vendor-codemirror-lang-python-B4naLXMo.js +0 -9
  116. package/dist/assets/vendor-codemirror-lang-sass-ibtp2iUB.js +0 -1
  117. package/dist/assets/vendor-codemirror-search-D2SOgzph.js +0 -2
package/dist/index.html CHANGED
@@ -552,7 +552,7 @@
552
552
  opacity: 0.72;
553
553
  }
554
554
  </style>
555
- <script type="module" crossorigin src="/assets/main-B3grbXe8.js"></script>
555
+ <script type="module" crossorigin src="/assets/main-BJckugyb.js"></script>
556
556
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-hePW80VL.js">
557
557
  <link rel="modulepreload" crossorigin href="/assets/runtime-auth-C8w0nlKy.js">
558
558
  <link rel="modulepreload" crossorigin href="/assets/runtime-switch-3ad3yzM7.js">
@@ -572,7 +572,7 @@
572
572
  <link rel="modulepreload" crossorigin href="/assets/terminalApi-bN5IYQau.js">
573
573
  <link rel="modulepreload" crossorigin href="/assets/types-BuiqMmh_.js">
574
574
  <link rel="modulepreload" crossorigin href="/assets/capgoAdapter-KNLHDj7G.js">
575
- <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-GAVzvQGc.js">
575
+ <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-Bm1EPWPq.js">
576
576
  <link rel="stylesheet" crossorigin href="/assets/src-C9BNQK0O.css">
577
577
  </head>
578
578
  <body class="h-full bg-background text-foreground">
@@ -74,7 +74,7 @@
74
74
  }
75
75
  </style>
76
76
 
77
- <script type="module" crossorigin src="/assets/miniChat-ChSEmnwp.js"></script>
77
+ <script type="module" crossorigin src="/assets/miniChat-YVKGcjkV.js"></script>
78
78
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-hePW80VL.js">
79
79
  <link rel="modulepreload" crossorigin href="/assets/runtime-auth-C8w0nlKy.js">
80
80
  <link rel="modulepreload" crossorigin href="/assets/runtime-switch-3ad3yzM7.js">
@@ -94,7 +94,7 @@
94
94
  <link rel="modulepreload" crossorigin href="/assets/terminalApi-bN5IYQau.js">
95
95
  <link rel="modulepreload" crossorigin href="/assets/types-BuiqMmh_.js">
96
96
  <link rel="modulepreload" crossorigin href="/assets/capgoAdapter-KNLHDj7G.js">
97
- <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-GAVzvQGc.js">
97
+ <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-Bm1EPWPq.js">
98
98
  <link rel="stylesheet" crossorigin href="/assets/src-C9BNQK0O.css">
99
99
  </head>
100
100
  <body class="h-full bg-background text-foreground">
package/dist/mobile.html CHANGED
@@ -63,7 +63,7 @@
63
63
  })();
64
64
  </script>
65
65
 
66
- <script type="module" crossorigin src="/assets/mobile-CrKlS4SH.js"></script>
66
+ <script type="module" crossorigin src="/assets/mobile-DtAt6Tnq.js"></script>
67
67
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-hePW80VL.js">
68
68
  <link rel="modulepreload" crossorigin href="/assets/runtime-auth-C8w0nlKy.js">
69
69
  <link rel="modulepreload" crossorigin href="/assets/runtime-switch-3ad3yzM7.js">
@@ -83,7 +83,7 @@
83
83
  <link rel="modulepreload" crossorigin href="/assets/terminalApi-bN5IYQau.js">
84
84
  <link rel="modulepreload" crossorigin href="/assets/types-BuiqMmh_.js">
85
85
  <link rel="modulepreload" crossorigin href="/assets/capgoAdapter-KNLHDj7G.js">
86
- <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-GAVzvQGc.js">
86
+ <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-Bm1EPWPq.js">
87
87
  <link rel="stylesheet" crossorigin href="/assets/src-C9BNQK0O.css">
88
88
  </head>
89
89
  <body class="h-full bg-background text-foreground">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openchambery/web",
3
- "version": "2.1.8-beta.3",
3
+ "version": "2.1.8-beta.5",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./server/index.js",
@@ -19,6 +19,7 @@ Own the direction-agnostic OpenCode config sync contract: allowlist planning on
19
19
  | `engine.js` | `applyConfigSyncPlan` orchestration for **push** (prepare → putTar* → finalize) |
20
20
  | `credentials.js` | V2 credential API transfer, validation, account merge and in-memory rollback |
21
21
  | `magpie.js` | Host-local Magpie discovery, verified CLI provisioning, native backup/restore |
22
+ | `magpie-auth.js` | Bounded portable authorization export, plugin-store merge and account verification |
22
23
  | `target-id.js` | `ssh:<instanceId>` namespace helper |
23
24
 
24
25
  ## Contract shape
@@ -67,7 +68,7 @@ Routes under `/api/openchamber/config-sync/*` (global `/api` auth gate: session
67
68
  | GET | `/credentials/inventory` | Current-service credential byte count, without secrets |
68
69
  | PUT | `/credentials` | Bounded credential import for local pull and SSH-forwarded transfer |
69
70
  | GET | `/magpie/inventory` | `{ magpie: { available, installed } }`, metadata only; never installs |
70
- | PUT | `/magpie` | Native Magpie restore for local pull and SSH-forwarded transfer; installs missing CLI |
71
+ | PUT | `/magpie` | Magpie config/authorization restore and verification for local pull and SSH-forwarded transfer; installs missing CLI |
71
72
  | POST | `/finalize` | Confirm backup generation + success receipt |
72
73
  | POST | `/abort` | Clear inflight |
73
74
 
@@ -84,12 +85,16 @@ Routes under `/api/openchamber/config-sync/*` (global `/api` auth gate: session
84
85
 
85
86
  The existing sync wizard discovers Magpie on the **source** for every preview. A push uses the local host's inventory; a pull uses the selected remote host's inventory (HTTP through the SSH forward, direct, or E2EE Relay). `magpieAvailable` survives selection filtering. The optional `magpie` plan entry is `{ bytes: 0 }` when selected: backup size is unknown until export, not counted as a zero-byte archive. Switching direction clears the Magpie selection and discovers the new source. A selected source disappearing is a failure, never an empty successful sync. This is discovery during the existing explicit sync flow, not a background file watcher.
86
87
 
87
- Discovery checks `MAGPIE_BINARY`, PATH, the user's `.local/bin`, macOS application bundles and Homebrew paths, the standard Windows user installation path, then OpenChamber's managed executable. Symlinks resolve before locating portable data. Installed config honors absolute `XDG_CONFIG_HOME`, otherwise `~/.config/magpie`; portable config follows `.portable` / adjacent `data`, including the macOS app parent and an applicable AppImage directory. A shared Applications/home/download directory's `data` is not adopted over installed config. Providers, settings, profiles, library or plugin config files make the source available even without a CLI. Metadata does not expose paths or secrets. Read errors fail discovery.
88
+ Discovery checks `MAGPIE_BINARY`, PATH, the user's `.local/bin`, macOS application bundles and Homebrew paths, the standard Windows user installation path, then OpenChamber's managed executable. Symlinks resolve before locating portable data. Installed config honors absolute `XDG_CONFIG_HOME`, otherwise `~/.config/magpie`; portable config follows `.portable` / adjacent `data`, including the macOS app parent and an applicable AppImage directory. A shared Applications/home/download directory's `data` is not adopted over installed config. The source is available only when a verified CLI is installed and providers, settings, profiles, library, plugin config or authorization stores exist. Leftover config without an installed CLI does not expose the sync option. Magpie's checkbox and explanatory copy form a separate conditional group, default unchecked; general OpenCode sync copy remains independent. Metadata does not expose paths or secrets. Read errors fail discovery.
88
89
 
89
- Selecting Magpie uses its native `backup` / `restore` with configuration, API keys, profiles, agent models, library and portable plugin declarations. Subscription OAuth and plugin sign-ins are excluded by Magpie itself; the option says so. This does not modify Magpie or copy its login databases. Native provider/group restore merges by ID and does not propagate deletions; settings and library follow Magpie's own restore semantics, retaining machine-local settings. This is a full native bundle, not a provider-only restore.
90
+ Selecting Magpie uses its native `backup` / `restore` with configuration, API keys, profiles, agent models, library and portable plugin declarations. Native provider/group restore merges by ID and does not propagate deletions; settings and library follow Magpie's own restore semantics, retaining machine-local settings. This is a full native bundle, not a provider-only restore.
90
91
 
91
- Missing executables are installed only during selected export/import (including a config-only source), never in preview. The host selects the official pure CLI asset for macOS/Linux/Windows x64/arm64 from `https://usemagpie.ai/api/latest`, verifies SHA-256, checks `--version`, then atomically publishes it under `<OPENCHAMBER_DATA_DIR>/tools/magpie/` (default `~/.config/openchamber`). Existing desktop clients and CLIs are reused. Installation is process-local single-flight, needs no global PATH change or desktop environment, and fails explicitly for unsupported platforms or unverifiable downloads.
92
+ The v2 envelope additionally carries recognized plugin API/OAuth accounts and self-contained saved Codex credentials. The source CLI account inventory limits export to recognized providers; hidden and orphaned plugin sign-ins are excluded. Plugin accounts merge by their native account key into `plugin-auth.json` under Magpie's exclusive-create lock, using a private atomic replacement. Destination-only accounts remain; repeated transfers do not append duplicates. Codex uses native `accounts import codex --yes`, which owns account identity/deduplication and the OS-level `logins.json` lock. OpenChamber never writes `logins.json` or copies machine-local account homes. Other subscription formats and accounts discovered only from external clients are not inferred or silently treated as transferred: unsupported active source accounts produce `sign_in_required`. An import re-reads `accounts --json` and requires all transferred plugin account counts and Codex identities to be recognized before returning success. This checks discovery, not successful model inference or future token renewal; revoked credentials still require login.
92
93
 
93
- The `magpie` transfer kind is a bounded, versioned `magpie-sync-v1` binary JSON envelope carrying an encrypted native backup and a random per-transfer passphrase. Both travel only through the authenticated sync transport; Relay adds its existing E2EE. Passphrases go to child stdin, never argv, run logs or disk. Private temporary directories hold only native encrypted backups and are removed on success/failure. Raw child output is kept local and sanitized before reporting. A zero exit with excluded sign-ins or unavailable agent settings is successful restore with `magpieWarnings` (`sign_in_required`, `agent_settings_skipped`), not a failed transfer. Receipts carry these codes through SSH/direct/Relay and desktop IPC to a localized completion warning. Provider/API-key, plugin-install or library-application failures remain errors with a sanitized reason. CLI failure can leave some native configuration applied; already-applied OpenCode files remain too. Retrying can complete the merge; this is not an atomic cross-application rollback.
94
+ Missing destination executables are installed only when Magpie is explicitly selected for import, never in preview or ordinary OpenCode sync. The host selects the official pure CLI asset for macOS/Linux/Windows x64/arm64 from `https://usemagpie.ai/api/latest`, verifies SHA-256, checks `--version`, then atomically publishes it under `<OPENCHAMBER_DATA_DIR>/tools/magpie/` (default `~/.config/openchamber`). Existing desktop clients and CLIs are reused. Installation is process-local single-flight, needs no global PATH change or desktop environment, and fails explicitly for unsupported platforms or unverifiable downloads.
95
+
96
+ Selected sync also publishes `~/.local/bin/magpie` (`magpie.exe` on Windows), a path searched explicitly by `opencode-magpie-plugin`. POSIX uses a symlink, Windows a hard link for installed-mode data and a symlink for portable data; unsupported linking fails explicitly. Existing valid user commands using the same data directory are retained; conflicting/broken entries are not overwritten. Shared-service and private OpenCode startup repair the command entry for an already managed CLI before launching OpenCode; they never download Magpie just because OpenChamber starts. SSH/direct/Relay push and pull apply Magpie before OpenCode config archives, preventing a newly synced plugin from loading before its dependency is installed. Native agent settings skipped before sign-in are retried after authorization succeeds.
97
+
98
+ The `magpie` transfer kind exports a bounded `magpie-sync-v2` JSON envelope containing the encrypted native backup, a random per-transfer passphrase and portable authorizations. The envelope is sensitive and stays in memory on authenticated sync transports; Relay adds its existing E2EE. New receivers accept old `magpie-sync-v1` config-only envelopes with the original warning behavior; old receivers reject v2 rather than silently discarding its accounts. Upgrade both hosts for authorization sync. Validate the entire envelope before installation or restore. Passphrases go to child stdin, never argv, run logs or disk. Private temporary directories hold native encrypted backups and, during native Codex import only, a mode-0600 credential file; both are removed on success/failure. Raw child output is sanitized before reporting. A zero exit with genuinely missing/unsupported sign-ins or unavailable agent settings returns `magpieWarnings` (`sign_in_required`, `agent_settings_skipped`). The sign-in warning is recomputed after authorization transfer, not copied from the pre-transfer native restore. Receipts carry these codes through SSH/direct/Relay and desktop IPC to localized completion warnings. Provider/API-key, plugin-install, library, account import and verification failures remain errors. CLI failure can leave configuration and earlier accounts applied; retry merges idempotently. There is no cross-application rollback, and process termination can interrupt cleanup.
94
99
 
95
100
  `/put/magpie` requires a selected prepared plan and finalize requires its receipt. Standalone `/magpie` is the corresponding authenticated host operation for SSH and local pull. Both use one host-local import/export lock. Desktop main only moves bytes and resolves the local host at call time; the web host owns process execution and installation, including under backend OTA. Web/mobile hosts can receive this operation; the initiating wizard remains desktop-only. No credential values enter previews or sync-run summaries.
@@ -65,6 +65,7 @@ export const applyConfigSyncPlan = async ({
65
65
 
66
66
  await executor.prepare(plan, { syncRunId });
67
67
 
68
+ if (magpiePayload) await executor.putTar({ kind: 'magpie', payload: magpiePayload });
68
69
  if (configTarBuffer) {
69
70
  await executor.putTar({ kind: 'config', payload: configTarBuffer });
70
71
  }
@@ -75,7 +76,6 @@ export const applyConfigSyncPlan = async ({
75
76
  // Versioned credential payload; never a database or filesystem archive.
76
77
  await executor.putTar({ kind: 'credentials', payload: credentialPayload });
77
78
  }
78
- if (magpiePayload) await executor.putTar({ kind: 'magpie', payload: magpiePayload });
79
79
 
80
80
  const receipt = await executor.finalize(plan, { syncRunId });
81
81
 
@@ -0,0 +1,111 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { randomBytes } from 'node:crypto';
4
+ import { setTimeout as delay } from 'node:timers/promises';
5
+
6
+ const MAX_AUTH_BYTES = 8 * 1024 * 1024;
7
+ const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
8
+ const accountKey = (value) => typeof value === 'string' && /^[\w.-]+(?:#[\w.-]+)?$/.test(value)
9
+ && !['__proto__', 'constructor', 'prototype'].includes(value);
10
+ const portableLogin = (row, plugins) => object(row) && typeof row.user === 'string'
11
+ && ((row.agent === 'codex' && !row.home && object(row.auth) && object(row.auth.tokens))
12
+ || (typeof row.agent === 'string' && row.agent.startsWith('plugin:')
13
+ && accountKey(row.home) && Object.hasOwn(plugins, row.home)
14
+ && row.agent === `plugin:${row.home.split('#')[0]}`));
15
+
16
+ const readStore = async (file, fallback) => {
17
+ try {
18
+ if ((await fs.stat(file)).size > MAX_AUTH_BYTES) throw new Error();
19
+ return JSON.parse(await fs.readFile(file, 'utf8'));
20
+ } catch (error) {
21
+ if (error.code === 'ENOENT') return fallback;
22
+ throw new Error('Unable to read Magpie authorization store');
23
+ }
24
+ };
25
+
26
+ export const validateMagpieAuth = (value) => {
27
+ if (!object(value) || !object(value.plugins) || !Array.isArray(value.logins)
28
+ || typeof value.incomplete !== 'boolean' || JSON.stringify(value).length > MAX_AUTH_BYTES
29
+ || Object.entries(value.plugins).some(([key, auth]) => !accountKey(key) || !object(auth)
30
+ || !['api', 'oauth'].includes(auth.type)
31
+ || (auth.type === 'api' ? typeof auth.key !== 'string' || !auth.key
32
+ : typeof auth.access !== 'string' || typeof auth.refresh !== 'string'))
33
+ || value.logins.some((row) => !portableLogin(row, value.plugins))) {
34
+ throw new Error('Invalid Magpie authorization payload');
35
+ }
36
+ return value;
37
+ };
38
+
39
+ export const collectMagpieAuth = async (dir, accounts) => {
40
+ if (!Array.isArray(accounts) || accounts.some((row) => !object(row) || typeof row.agent !== 'string')) {
41
+ throw new Error('Unable to read source Magpie accounts');
42
+ }
43
+ const plugins = await readStore(path.join(dir, 'plugin-auth.json'), {});
44
+ const rows = await readStore(path.join(dir, 'logins.json'), []);
45
+ if (!object(plugins) || !Array.isArray(rows) || rows.some((row) => !object(row))) {
46
+ throw new Error('Invalid Magpie authorization store');
47
+ }
48
+ const available = new Set(accounts.map((row) => row.agent));
49
+ const visiblePlugins = Object.fromEntries(Object.entries(plugins).filter(([key]) => available.has(key.split('#')[0])
50
+ && !rows.some((row) => row.agent === `plugin:${key.split('#')[0]}` && row.home === key && row.hidden)));
51
+ const logins = rows.filter((row) => !row.hidden && portableLogin(row, visiblePlugins));
52
+ return validateMagpieAuth({ plugins: visiblePlugins, logins,
53
+ incomplete: accounts.some((account) => !Object.keys(visiblePlugins).some((key) => key.split('#')[0] === account.agent)
54
+ && !logins.some((row) => row.agent === account.agent && row.user === account.user)) });
55
+ };
56
+
57
+ const writeStore = async (file, value) => {
58
+ const staging = `${file}.${randomBytes(8).toString('hex')}.tmp`;
59
+ try {
60
+ await fs.writeFile(staging, JSON.stringify(value), { mode: 0o600, flag: 'wx' });
61
+ await fs.rename(staging, file);
62
+ } finally { await fs.rm(staging, { force: true }); }
63
+ };
64
+
65
+ // Use Magpie's own lock filenames; never steal a lock from a running token refresh.
66
+ const lockStore = async (file) => {
67
+ for (let attempt = 0; attempt < 100; attempt++) {
68
+ try {
69
+ const handle = await fs.open(`${file}.lock`, 'wx', 0o600);
70
+ await handle.close();
71
+ return () => fs.rm(`${file}.lock`, { force: true });
72
+ } catch (error) {
73
+ if (error.code !== 'EEXIST') throw new Error('Unable to lock Magpie authorization store');
74
+ await delay(50);
75
+ }
76
+ }
77
+ throw new Error('Magpie authorization store is busy; retry sync');
78
+ };
79
+
80
+ export const restoreMagpieAuth = async (dir, input, importCodex) => {
81
+ const auth = validateMagpieAuth(input);
82
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
83
+ const pluginFile = path.join(dir, 'plugin-auth.json');
84
+ const unlockPlugins = await lockStore(pluginFile);
85
+ try {
86
+ const plugins = await readStore(pluginFile, {});
87
+ if (!object(plugins)) throw new Error('Invalid destination Magpie authorization store');
88
+ await writeStore(pluginFile, { ...plugins, ...auth.plugins });
89
+ } finally { await unlockPlugins(); }
90
+ // Codex's native importer owns its OS-level logins lock, account deduplication and identity.
91
+ for (const row of auth.logins) {
92
+ if (row.agent === 'codex' && !row.hidden) await importCodex(row.auth);
93
+ }
94
+ };
95
+
96
+ export const verifyMagpieAuth = (auth, accounts) => {
97
+ if (!Array.isArray(accounts) || accounts.some((row) => !object(row) || typeof row.agent !== 'string')) {
98
+ throw new Error('Unable to verify Magpie accounts');
99
+ }
100
+ const expected = new Map();
101
+ for (const key of Object.keys(auth.plugins)) {
102
+ const id = key.split('#')[0];
103
+ expected.set(id, (expected.get(id) || 0) + 1);
104
+ }
105
+ const missingAccount = auth.logins.some((row) => !row.hidden
106
+ && !accounts.some((account) => account.agent === row.agent.replace(/^plugin:/, '')
107
+ && String(account.user).toLowerCase() === row.user.toLowerCase()));
108
+ if (missingAccount || [...expected].some(([id, count]) => accounts.filter((row) => row.agent === id).length < count)) {
109
+ throw new Error('Magpie authorization saved but some accounts are not recognized; check destination plugins and retry');
110
+ }
111
+ };
@@ -0,0 +1,79 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { collectMagpieAuth, restoreMagpieAuth, validateMagpieAuth, verifyMagpieAuth } from './magpie-auth.js';
6
+
7
+ const dirs = [];
8
+ const directory = async () => {
9
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'magpie-auth-test-'));
10
+ dirs.push(dir);
11
+ return dir;
12
+ };
13
+ const auth = { plugins: { 'cursor#one': { type: 'oauth', access: 'fixture-access', refresh: 'fixture-refresh', expires: 123 } }, logins: [], incomplete: false };
14
+ afterEach(async () => { vi.restoreAllMocks(); await Promise.all(dirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); });
15
+
16
+ describe('Magpie authorization transfer', () => {
17
+ it('exports recognized plugin accounts, excludes hidden/stale accounts and flags unsupported subscriptions', async () => {
18
+ const dir = await directory();
19
+ await fs.writeFile(path.join(dir, 'plugin-auth.json'), JSON.stringify({ ...auth.plugins,
20
+ stale: { type: 'api', key: 'fixture' }, 'cursor#hidden': { type: 'api', key: 'fixture' } }));
21
+ await fs.writeFile(path.join(dir, 'logins.json'), JSON.stringify([
22
+ { agent: 'plugin:cursor', user: 'hidden', home: 'cursor#hidden', hidden: 'mark' },
23
+ { agent: 'chatgpt-api', user: 'fixture', auth: { clientId: 'machine-specific' } },
24
+ ]));
25
+ const result = await collectMagpieAuth(dir, [{ agent: 'cursor' }, { agent: 'chatgpt-api', user: 'fixture' }]);
26
+ expect(result.plugins).toEqual(auth.plugins);
27
+ expect(result.logins).toEqual([]);
28
+ expect(result.incomplete).toBe(true);
29
+ });
30
+
31
+ it('does not turn corrupted stores into successful empty credentials', async () => {
32
+ const dir = await directory();
33
+ await fs.writeFile(path.join(dir, 'plugin-auth.json'), '{broken');
34
+ await expect(collectMagpieAuth(dir, [])).rejects.toThrow('authorization store');
35
+ await expect(restoreMagpieAuth(dir, auth, vi.fn())).rejects.toThrow('authorization store');
36
+ expect(await fs.readFile(path.join(dir, 'plugin-auth.json'), 'utf8')).toBe('{broken');
37
+ expect(await fs.readdir(dir)).toEqual(['plugin-auth.json']);
38
+ });
39
+
40
+ it('merges repeated imports without deleting unrelated accounts and releases the plugin lock before native imports', async () => {
41
+ const dir = await directory();
42
+ await fs.writeFile(path.join(dir, 'plugin-auth.json'), '{"other":{"type":"api","key":"keep"}}');
43
+ const input = { ...auth, logins: [{ agent: 'codex', user: 'fixture', auth: { tokens: {} } }] };
44
+ const importCodex = vi.fn(async () => {
45
+ await expect(fs.stat(path.join(dir, 'plugin-auth.json.lock'))).rejects.toThrow();
46
+ });
47
+ await restoreMagpieAuth(dir, input, importCodex);
48
+ await restoreMagpieAuth(dir, input, importCodex);
49
+ expect(JSON.parse(await fs.readFile(path.join(dir, 'plugin-auth.json'), 'utf8'))).toEqual({ other: { type: 'api', key: 'keep' }, ...auth.plugins });
50
+ expect((await fs.stat(path.join(dir, 'plugin-auth.json'))).mode & 0o777).toBe(0o600);
51
+ expect(await fs.readdir(dir)).toEqual(['plugin-auth.json']);
52
+ });
53
+
54
+ it('keeps successful plugin writes when a native import fails, so retry can finish', async () => {
55
+ const dir = await directory();
56
+ await expect(restoreMagpieAuth(dir, { ...auth, logins: [{ agent: 'codex', user: 'fixture', auth: { tokens: {} } }] },
57
+ async () => { throw new Error('fixture failure'); })).rejects.toThrow();
58
+ expect(JSON.parse(await fs.readFile(path.join(dir, 'plugin-auth.json'), 'utf8'))).toEqual(auth.plugins);
59
+ expect(await fs.readdir(dir)).toEqual(['plugin-auth.json']);
60
+ });
61
+
62
+ it('preserves the previous store and cleans staging files when atomic publication fails', async () => {
63
+ const dir = await directory();
64
+ const file = path.join(dir, 'plugin-auth.json');
65
+ await fs.writeFile(file, '{"other":{"type":"api","key":"keep"}}');
66
+ vi.spyOn(fs, 'rename').mockRejectedValueOnce(new Error('fixture rename failure'));
67
+ await expect(restoreMagpieAuth(dir, auth, vi.fn())).rejects.toThrow();
68
+ expect(JSON.parse(await fs.readFile(file, 'utf8'))).toEqual({ other: { type: 'api', key: 'keep' } });
69
+ expect(await fs.readdir(dir)).toEqual(['plugin-auth.json']);
70
+ });
71
+
72
+ it('rejects unsafe shapes and verifies every expected account rather than one account per provider', () => {
73
+ expect(() => validateMagpieAuth({ ...auth, plugins: { '../path': { type: 'api', key: 'fixture' } } })).toThrow();
74
+ const two = { ...auth, plugins: { ...auth.plugins, 'cursor#two': auth.plugins['cursor#one'] } };
75
+ expect(() => verifyMagpieAuth(two, [{ agent: 'cursor' }])).toThrow('not recognized');
76
+ expect(() => verifyMagpieAuth(two, [{ agent: 'cursor' }, { agent: 'cursor' }])).not.toThrow();
77
+ expect(() => verifyMagpieAuth(auth, {})).toThrow('verify');
78
+ });
79
+ });
@@ -3,9 +3,10 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { spawn } from 'node:child_process';
5
5
  import { createHash, randomBytes } from 'node:crypto';
6
+ import { collectMagpieAuth, restoreMagpieAuth, validateMagpieAuth, verifyMagpieAuth } from './magpie-auth.js';
6
7
 
7
8
  const MAX_BYTES = 64 * 1024 * 1024;
8
- const CONFIG_FILES = ['providers.json', 'settings.json', 'profiles.json', 'library.json', 'plugins.json'];
9
+ const CONFIG_FILES = ['providers.json', 'settings.json', 'profiles.json', 'library.json', 'plugins.json', 'plugin-auth.json', 'logins.json'];
9
10
  const exists = async (file) => {
10
11
  try { return await fs.stat(file); }
11
12
  catch (error) { if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null; throw error; }
@@ -103,7 +104,7 @@ export function createMagpieSync({
103
104
 
104
105
  const inventory = async () => {
105
106
  const binary = await locate();
106
- return { available: await hasConfig(await dataDir(binary)), installed: Boolean(binary) };
107
+ return { available: Boolean(binary) && await hasConfig(await dataDir(binary)), installed: Boolean(binary) };
107
108
  };
108
109
 
109
110
  const downloadBytes = async (url, limit) => {
@@ -119,9 +120,38 @@ export function createMagpieSync({
119
120
  return Buffer.concat(chunks);
120
121
  };
121
122
 
123
+ const publishExecutable = async (binary) => {
124
+ const entry = path.join(home(), '.local', 'bin', platform === 'win32' ? 'magpie.exe' : 'magpie');
125
+ if (await exists(entry)) {
126
+ if (await fs.realpath(entry) === binary) return binary;
127
+ const version = await run(entry, ['--version'], { env, timeout: 10_000 });
128
+ if (!/^magpie\s+\S+/m.test(version)) throw new Error('Magpie command conflicts with an existing executable');
129
+ if (await dataDir(await fs.realpath(entry)) !== await dataDir(binary)) {
130
+ throw new Error('Magpie command uses a different portable configuration; resolve the command conflict before syncing');
131
+ }
132
+ return binary;
133
+ }
134
+ await fs.mkdir(path.dirname(entry), { recursive: true });
135
+ try {
136
+ if (platform === 'win32' && await dataDir(binary) === configDir()) await fs.link(binary, entry);
137
+ else await fs.symlink(binary, entry, 'file');
138
+ } catch (error) {
139
+ if (error.code !== 'EEXIST' || !await exists(entry)) throw new Error('Unable to publish Magpie command for plugins');
140
+ }
141
+ return binary;
142
+ };
143
+
144
+ const repairInstallation = async () => {
145
+ const binary = managedBinary();
146
+ if (!(await exists(binary))?.isFile()) return;
147
+ const version = await run(binary, ['--version'], { env, timeout: 10_000 });
148
+ if (!/^magpie\s+\S+/m.test(version)) throw new Error('Invalid managed Magpie executable');
149
+ await publishExecutable(await fs.realpath(binary));
150
+ };
151
+
122
152
  const ensure = async () => {
123
153
  const binary = await locate();
124
- if (binary) return binary;
154
+ if (binary) return publishExecutable(binary);
125
155
  if (installing) return installing;
126
156
  installing = (async () => {
127
157
  const cpu = { x64: 'amd64', arm64: 'arm64' }[arch];
@@ -146,7 +176,7 @@ export function createMagpieSync({
146
176
  if (!/^magpie\s+\S+/m.test(version)) throw new Error('Invalid downloaded Magpie CLI');
147
177
  await fs.rename(staging, target);
148
178
  } finally { await fs.rm(staging, { force: true }); }
149
- return target;
179
+ return publishExecutable(target);
150
180
  })();
151
181
  try { return await installing; }
152
182
  finally { installing = null; }
@@ -165,6 +195,7 @@ export function createMagpieSync({
165
195
 
166
196
  return {
167
197
  inventory,
198
+ repairInstallation,
168
199
  download: () => exclusive(async () => {
169
200
  if (!(await inventory()).available) throw new Error('No Magpie configuration found on source');
170
201
  const binary = await ensure();
@@ -174,7 +205,14 @@ export function createMagpieSync({
174
205
  const stat = await fs.stat(file);
175
206
  if (stat.size > MAX_BYTES) throw new Error('Magpie backup exceeds size limit');
176
207
  const backup = await fs.readFile(file);
177
- return Buffer.from(JSON.stringify({ format: 'magpie-sync-v1', passphrase, backup: backup.toString('base64') }));
208
+ let authorization;
209
+ try {
210
+ const accounts = JSON.parse(await run(binary, ['accounts', '--json'], { env }));
211
+ authorization = await collectMagpieAuth(await dataDir(binary), accounts);
212
+ } catch { throw new Error('Unable to export Magpie authorizations'); }
213
+ const payload = Buffer.from(JSON.stringify({ format: 'magpie-sync-v2', passphrase, backup: backup.toString('base64'), authorization }));
214
+ if (payload.length > MAX_BYTES * 2) throw new Error('Magpie sync exceeds size limit');
215
+ return payload;
178
216
  });
179
217
  }),
180
218
  import: (payload) => exclusive(async () => {
@@ -183,8 +221,9 @@ export function createMagpieSync({
183
221
  try {
184
222
  if (payload.length > MAX_BYTES * 2) throw new Error();
185
223
  decoded = JSON.parse(Buffer.from(payload).toString());
186
- if (decoded.format !== 'magpie-sync-v1' || !/^[a-f0-9]{64}$/.test(decoded.passphrase)
224
+ if (!['magpie-sync-v1', 'magpie-sync-v2'].includes(decoded.format) || !/^[a-f0-9]{64}$/.test(decoded.passphrase)
187
225
  || typeof decoded.backup !== 'string') throw new Error();
226
+ if (decoded.format === 'magpie-sync-v2') validateMagpieAuth(decoded.authorization);
188
227
  backup = Buffer.from(decoded.backup, 'base64');
189
228
  if (!backup.length || backup.length > MAX_BYTES || JSON.parse(backup.toString()).format !== 'magpie-backup') throw new Error();
190
229
  } catch { throw new Error('Invalid Magpie sync payload'); }
@@ -197,8 +236,38 @@ export function createMagpieSync({
197
236
  if (/Couldn't add the plugin/i.test(output)) throw new Error('Magpie restore partially completed: plugin installation failed. Check the target Magpie plugins.');
198
237
  if (/Library:.*couldn't/i.test(output)) throw new Error('Magpie restore partially completed: library application failed. Check the target Magpie library.');
199
238
  if (/Needs a key/i.test(output)) throw new Error('Magpie restore partially completed: some providers still require API keys.');
239
+ let needsSignIn = /not signed in on this computer/i.test(output);
240
+ if (decoded.format === 'magpie-sync-v2') {
241
+ const dir = await dataDir(binary);
242
+ try {
243
+ await restoreMagpieAuth(dir, decoded.authorization, async (auth) => {
244
+ const credentialFile = path.join(path.dirname(file), 'codex.json');
245
+ try {
246
+ await fs.writeFile(credentialFile, JSON.stringify(auth), { mode: 0o600, flag: 'wx' });
247
+ await run(binary, ['accounts', 'import', 'codex', credentialFile, '--yes'], { env });
248
+ } finally { await fs.rm(credentialFile, { force: true }); }
249
+ });
250
+ const accounts = JSON.parse(await run(binary, ['accounts', '--json'], { env }));
251
+ verifyMagpieAuth(decoded.authorization, accounts);
252
+ const configured = JSON.parse(await fs.readFile(path.join(dir, 'providers.json'), 'utf8').catch((error) => {
253
+ if (error.code === 'ENOENT') return '{"providers":[]}';
254
+ throw error;
255
+ }));
256
+ if (!Array.isArray(configured.providers)) throw new Error();
257
+ const signedIn = new Set(accounts.map((account) => account.agent));
258
+ needsSignIn = decoded.authorization.incomplete || configured.providers.some((provider) =>
259
+ !provider.hidden && provider.id && !['chat', 'responses', 'anthropic', 'gemini'].some((key) => provider[key])
260
+ && !signedIn.has(provider.id));
261
+ if (/Left as they are/i.test(output)) {
262
+ output = await run(binary, ['restore', file], { env, input: `${decoded.passphrase}\n` });
263
+ if (/Couldn't add the plugin|Needs a key|Library:.*couldn't/i.test(output)) throw new Error();
264
+ }
265
+ } catch {
266
+ throw new Error('Magpie authorization sync or verification failed; some configuration and accounts may already be applied. Retry to finish.');
267
+ }
268
+ }
200
269
  const magpieWarnings = [];
201
- if (/not signed in on this computer/i.test(output)) magpieWarnings.push('sign_in_required');
270
+ if (needsSignIn) magpieWarnings.push('sign_in_required');
202
271
  if (/Left as they are/i.test(output)) magpieWarnings.push('agent_settings_skipped');
203
272
  return { ok: true, ...(magpieWarnings.length ? { magpieWarnings } : {}) };
204
273
  });
@@ -23,13 +23,13 @@ const write = async (file, content = '{}') => {
23
23
  afterEach(async () => { await Promise.all(dirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); });
24
24
 
25
25
  describe('Magpie discovery and native transfer', () => {
26
- it('discovers config-only sources through XDG without installing during preview', async () => {
26
+ it('hides config-only sources until Magpie is installed, without installing during preview', async () => {
27
27
  const { home, options } = await machine();
28
28
  const fetchImpl = vi.fn();
29
29
  const sync = createMagpieSync({ ...options, fetchImpl });
30
30
  expect(await sync.inventory()).toEqual({ available: false, installed: false });
31
31
  await write(path.join(home, '.config', 'magpie', 'providers.json'));
32
- expect(await sync.inventory()).toEqual({ available: true, installed: false });
32
+ expect(await sync.inventory()).toEqual({ available: false, installed: false });
33
33
  expect(fetchImpl).not.toHaveBeenCalled();
34
34
  });
35
35
 
@@ -55,6 +55,7 @@ describe('Magpie discovery and native transfer', () => {
55
55
  await write(path.join(source.home, '.config', 'magpie', 'providers.json'));
56
56
  const runSource = vi.fn(async (_binary, args, ctx) => {
57
57
  if (args[0] === '--version') return 'magpie 0.1.0';
58
+ if (args[0] === 'accounts') return '[]';
58
59
  expect(args[0]).toBe('backup');
59
60
  expect(ctx.input).toMatch(/^[a-f0-9]{64}\n$/);
60
61
  expect(args).not.toContain(ctx.input.trim());
@@ -70,14 +71,17 @@ describe('Magpie discovery and native transfer', () => {
70
71
  } } }) : new Response(bytes));
71
72
  const runTarget = vi.fn(async (_binary, args, ctx) => {
72
73
  if (args[0] === '--version') return 'magpie 0.1.0';
74
+ if (args[0] === 'accounts') return '[]';
73
75
  expect(args[0]).toBe('restore');
74
76
  expect(ctx.input.trim()).toBe(JSON.parse(payload).passphrase);
75
77
  expect(JSON.parse(await fs.readFile(args[1], 'utf8')).format).toBe('magpie-backup');
78
+ await write(path.join(target.home, '.config', 'magpie', 'providers.json'), '{"providers":[]}');
76
79
  return 'restored';
77
80
  });
78
81
  const destination = createMagpieSync({ ...target.options, fetchImpl, run: runTarget });
79
82
  await expect(destination.import(payload)).resolves.toEqual({ ok: true });
80
83
  expect(fetchImpl).toHaveBeenCalledTimes(2);
84
+ expect(await fs.realpath(path.join(target.home, '.local/bin/magpie'))).toBe(await fs.realpath(path.join(target.home, '.config/openchamber/tools/magpie/magpie')));
81
85
  await expect(destination.import(payload)).resolves.toEqual({ ok: true });
82
86
  expect(fetchImpl).toHaveBeenCalledTimes(2);
83
87
  for (const item of [source, target]) {
@@ -85,6 +89,98 @@ describe('Magpie discovery and native transfer', () => {
85
89
  }
86
90
  });
87
91
 
92
+ it('repairs a previously managed installation without downloading or overwriting a user command', async () => {
93
+ const { home, options } = await machine();
94
+ const managed = path.join(home, '.config/openchamber/tools/magpie/magpie');
95
+ const entry = path.join(home, '.local/bin/magpie');
96
+ const fetchImpl = vi.fn();
97
+ const run = vi.fn(async () => 'magpie 0.1.1319');
98
+ const sync = createMagpieSync({ ...options, fetchImpl, run });
99
+ await sync.repairInstallation();
100
+ expect(run).not.toHaveBeenCalled();
101
+ await write(managed, 'fixture');
102
+ await sync.repairInstallation();
103
+ await sync.repairInstallation();
104
+ expect(await fs.realpath(entry)).toBe(await fs.realpath(managed));
105
+ await fs.unlink(entry);
106
+ await write(entry, 'user installation');
107
+ await sync.repairInstallation();
108
+ expect(await fs.readFile(entry, 'utf8')).toBe('user installation');
109
+ expect(fetchImpl).not.toHaveBeenCalled();
110
+ });
111
+
112
+ it('round-trips plugin accounts and Codex through v2 and verifies the final account inventory', async () => {
113
+ const source = await machine();
114
+ const target = await machine();
115
+ for (const item of [source, target]) await write(path.join(item.home, '.local/bin/magpie'), 'fixture');
116
+ const sourceDir = path.join(source.home, '.config/magpie');
117
+ const targetDir = path.join(target.home, '.config/magpie');
118
+ await write(path.join(sourceDir, 'providers.json'), '{"providers":[{"id":"astra"},{"id":"codex"}]}');
119
+ await write(path.join(sourceDir, 'plugin-auth.json'), JSON.stringify({ 'astra#one': { type: 'api', key: 'fixture-key' } }));
120
+ await write(path.join(sourceDir, 'logins.json'), JSON.stringify([
121
+ { agent: 'codex', user: 'fixture@example.invalid', auth: { tokens: { access_token: 'fixture-token' } } },
122
+ ]));
123
+ const payload = await createMagpieSync({ ...source.options, run: async (_binary, args) => {
124
+ if (args[0] === '--version') return 'magpie 0.1.1319';
125
+ if (args[0] === 'accounts') return '[{"agent":"astra"},{"agent":"codex","user":"fixture@example.invalid"}]';
126
+ await write(args[1], '{"format":"magpie-backup"}');
127
+ return '';
128
+ } }).download();
129
+ expect(JSON.parse(payload).format).toBe('magpie-sync-v2');
130
+ await write(path.join(targetDir, 'plugin-auth.json'), '{"destination-only":{"type":"api","key":"keep"}}');
131
+ let recognized = true;
132
+ const run = vi.fn(async (_binary, args) => {
133
+ if (args[0] === '--version') return 'magpie 0.1.1319';
134
+ if (args[0] === 'restore') {
135
+ await write(path.join(targetDir, 'providers.json'), await fs.readFile(path.join(sourceDir, 'providers.json')));
136
+ return 'not signed in on this computer';
137
+ }
138
+ if (args[1] === 'import') {
139
+ expect(args).not.toContain('fixture-token');
140
+ expect(JSON.parse(await fs.readFile(args[3], 'utf8')).tokens.access_token).toBe('fixture-token');
141
+ expect((await fs.stat(args[3])).mode & 0o777).toBe(0o600);
142
+ return '';
143
+ }
144
+ return JSON.stringify(recognized ? [{ agent: 'astra' }, { agent: 'codex', user: 'fixture@example.invalid' }] : []);
145
+ });
146
+ const sync = createMagpieSync({ ...target.options, run });
147
+ await expect(sync.import(payload)).resolves.toEqual({ ok: true });
148
+ await expect(sync.import(payload)).resolves.toEqual({ ok: true });
149
+ const stored = JSON.parse(await fs.readFile(path.join(targetDir, 'plugin-auth.json'), 'utf8'));
150
+ expect(Object.keys(stored)).toEqual(['destination-only', 'astra#one']);
151
+ recognized = false;
152
+ await expect(sync.import(payload)).rejects.toThrow('authorization sync or verification failed');
153
+ expect((await fs.readdir(target.home)).filter((name) => name.startsWith('openchamber-magpie-'))).toEqual([]);
154
+ });
155
+
156
+ it('rejects malformed v2 authorization before installing or restoring', async () => {
157
+ const { options } = await machine();
158
+ const run = vi.fn();
159
+ const fetchImpl = vi.fn();
160
+ const payload = Buffer.from(JSON.stringify({ format: 'magpie-sync-v2', passphrase: 'a'.repeat(64),
161
+ backup: Buffer.from('{"format":"magpie-backup"}').toString('base64'),
162
+ authorization: { plugins: {}, logins: [{ agent: 'codex', home: '/source/path', auth: {} }], incomplete: false } }));
163
+ await expect(createMagpieSync({ ...options, run, fetchImpl }).import(payload)).rejects.toThrow('Invalid Magpie sync payload');
164
+ expect(run).not.toHaveBeenCalled();
165
+ expect(fetchImpl).not.toHaveBeenCalled();
166
+ });
167
+
168
+ it('retries agent settings after authorizations instead of keeping a pre-login skip warning', async () => {
169
+ const { home, options } = await machine();
170
+ await write(path.join(home, '.local/bin/magpie'), 'fixture');
171
+ let restores = 0;
172
+ const run = async (_binary, args) => {
173
+ if (args[0] === '--version') return 'magpie 0.1.1319';
174
+ if (args[0] === 'accounts') return '[]';
175
+ return ++restores === 1 ? 'Left as they are' : 'restored';
176
+ };
177
+ const payload = Buffer.from(JSON.stringify({ format: 'magpie-sync-v2', passphrase: 'a'.repeat(64),
178
+ backup: Buffer.from('{"format":"magpie-backup"}').toString('base64'),
179
+ authorization: { plugins: {}, logins: [], incomplete: false } }));
180
+ await expect(createMagpieSync({ ...options, run }).import(payload)).resolves.toEqual({ ok: true });
181
+ expect(restores).toBe(2);
182
+ });
183
+
88
184
  it('does not install on malformed payload and refuses partial restore without leaking CLI output', async () => {
89
185
  const { home, options } = await machine();
90
186
  const fetchImpl = vi.fn();
@@ -123,6 +219,10 @@ describe('Magpie discovery and native transfer', () => {
123
219
  const result = await applyConfigSyncPlan({ plan, executor, syncRunId: 'test', sourceHomedir: home, collectMagpie: async () => Buffer.from('fixture') });
124
220
  expect(result.magpieWarnings).toEqual(['sign_in_required']);
125
221
  expect(received).toEqual([{ kind: 'magpie', payload: Buffer.from('fixture') }]);
222
+ received.length = 0;
223
+ await applyConfigSyncPlan({ plan: { ...plan, files: [{ path: 'opencode.json' }] }, executor, syncRunId: 'ordered', sourceHomedir: home,
224
+ collectTar: async () => Buffer.from('config'), collectMagpie: async () => Buffer.from('fixture') });
225
+ expect(received.map((entry) => entry.kind)).toEqual(['magpie', 'config']);
126
226
  const pull = planOpenCodeConfigSyncFromInventory({ files: [], directories: [], magpie: { available: true } }, { selections });
127
227
  expect(pull.magpieAvailable).toBe(true);
128
228
  expect(pull.magpie).toEqual({ bytes: 0 });