@openchambery/web 2.1.7 → 2.1.8-beta.3
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/dist/assets/{AgentAvatar-CAR4F5Sn.js → AgentAvatar-DTgZob6V.js} +1 -1
- package/dist/assets/{AssistantView-DgjRT4M4.js → AssistantView-CzNcYiVL.js} +3 -3
- package/dist/assets/{DiagramView-Dd_3w4Hk.js → DiagramView-Cmdi3PAV.js} +1 -1
- package/dist/assets/{FadeInOnReveal-CCVddfP5.js → FadeInOnReveal-Dcu_ZfKa.js} +1 -1
- package/dist/assets/{Icon-DDB7S6Vz.js → Icon-BKVaYkO0.js} +1 -1
- package/dist/assets/{ImageLoadingPlaceholder-C6fDEcU1.js → ImageLoadingPlaceholder-CICdqe37.js} +1 -1
- package/dist/assets/{LoadingImage-ByBH-5WW.js → LoadingImage-DC63uLZK.js} +1 -1
- package/dist/assets/MarkdownRenderer-CIapvMEi.js +2 -0
- package/dist/assets/{MarkdownRendererImpl-BXgUthom.js → MarkdownRendererImpl-CuovZBls.js} +1 -1
- package/dist/assets/{MarkstreamRendererImpl-5W9P9U-w.js → MarkstreamRendererImpl-DtQlVw4f.js} +1 -1
- package/dist/assets/{MobileDetailNavigation-Bk6C9i9l.js → MobileDetailNavigation-BcQ1gCG1.js} +1 -1
- package/dist/assets/{MobileShareBridge-B5nF-gT6.js → MobileShareBridge-CZbknYvz.js} +1 -1
- package/dist/assets/MobileTabPageHeader-DjbyneLy.js +2 -0
- package/dist/assets/{MultiRunWindow-CpuFaAyG.js → MultiRunWindow-CsDXEsR_.js} +1 -1
- package/dist/assets/{OnboardingScreen-DINUboLT.js → OnboardingScreen-B8R06QbP.js} +1 -1
- package/dist/assets/{OverlayScrollbar-BkxmoyYl.js → OverlayScrollbar-BrYVChrM.js} +1 -1
- package/dist/assets/{SettingsView--C3eizlJ.js → SettingsView-bWUvVuP2.js} +3 -2
- package/dist/assets/{SettingsWindow-DYUvg92F.js → SettingsWindow-Cs1Z7BAg.js} +1 -1
- package/dist/assets/{TerminalView-BqAariXf.js → TerminalView-Cfa5bIj1.js} +1 -1
- package/dist/assets/{ToolOutputDialog-D6lPUZrN.js → ToolOutputDialog-ChVqyMGX.js} +1 -1
- package/dist/assets/{appThemeRegistry-wWazoyp8.js → appThemeRegistry-CuS2weCQ.js} +1 -1
- package/dist/assets/{button-DymteyMv.js → button-NnBwy1et.js} +1 -1
- package/dist/assets/{collapsible-Dzp5rO0Q.js → collapsible-Divs2HzS.js} +1 -1
- package/dist/assets/{diagram-CQs9px6y.js → diagram-Dw2fVTEr.js} +1 -1
- package/dist/assets/{dialog-DfUeXqdE.js → dialog-thtcgEoi.js} +1 -1
- package/dist/assets/{dropdown-menu.styles-B6YMNOZz.js → dropdown-menu.styles-DG86fojG.js} +1 -1
- package/dist/assets/{es-2YvsazK2.js → es-CyvQWK39.js} +1 -1
- package/dist/assets/{fileMentionTouchSelection-CEIhM6Ov.js → fileMentionTouchSelection-D9YYbFDF.js} +1 -1
- package/dist/assets/{fr-Cy0M48Gw.js → fr-BgKUUIer.js} +1 -1
- package/dist/assets/{gitApi-BP68uLBQ.js → gitApi-q2aVvcM1.js} +1 -1
- package/dist/assets/{input-BvpZiRHI.js → input-BFFtmAyl.js} +1 -1
- package/dist/assets/{insertionBoundaries-BywXllQ9.js → insertionBoundaries-B2M9QiEF.js} +1 -1
- package/dist/assets/{ja-1zLWWJ2R.js → ja-vAsqZ3i2.js} +1 -1
- package/dist/assets/{ko-BLk_5Dhc.js → ko-CjgHIGr_.js} +1 -1
- package/dist/assets/{main-DdyFxokk.js → main-4h6m9pZ0.js} +4 -4
- package/dist/assets/{main-DSY3-ZQZ.js → main-B3grbXe8.js} +2 -2
- package/dist/assets/{markdownHeightCache-MW1NYOaG.js → markdownHeightCache-qy-s2Sd8.js} +1 -1
- package/dist/assets/{mcpQueries-BaQ2_bdm.js → mcpQueries-BD28dy9B.js} +1 -1
- package/dist/assets/{miniChat-B43ASec3.js → miniChat-ChSEmnwp.js} +2 -2
- package/dist/assets/{mobile-DloCio4f.js → mobile-CrKlS4SH.js} +2 -2
- package/dist/assets/{multirun-Blywdd6A.js → multirun-Dhq8Cx1W.js} +1 -1
- package/dist/assets/{pl-BE8MC0XQ.js → pl-CNRmHctC.js} +1 -1
- package/dist/assets/{projectMeta-BwPtPeMh.js → projectMeta-2J4Ull4h.js} +1 -1
- package/dist/assets/{pt-BR-ubhNymyH.js → pt-BR-B4NRHMT2.js} +1 -1
- package/dist/assets/{renderElectronMiniChatApp-DCRctqrx.js → renderElectronMiniChatApp-DsPg8kQs.js} +2 -2
- package/dist/assets/{renderMobileApp-DuFdSBXi.js → renderMobileApp-Zl-L5gSE.js} +4 -4
- package/dist/assets/{runtimeConfig-CqpbN2lM.js → runtimeConfig-GAVzvQGc.js} +1 -1
- package/dist/assets/{runtimeEndpointReset-bWBhgTs_.js → runtimeEndpointReset-DdNzM2Ae.js} +2 -2
- package/dist/assets/{scheduledTaskNavigation-BnKGGUlX.js → scheduledTaskNavigation-DqwoOss-.js} +1 -1
- package/dist/assets/{sessionChangeSummary-BlR9MyHu.js → sessionChangeSummary-CFlwf4sL.js} +1 -1
- package/dist/assets/{sessionLookup-6gcNEZnB.js → sessionLookup-BPCTTSdg.js} +1 -1
- package/dist/assets/{sortable-tabs-strip-CWR8KhJB.js → sortable-tabs-strip-BLrK5Ln9.js} +1 -1
- package/dist/assets/speechSettingsQueries-D4rx8jJk.js +4 -0
- package/dist/assets/{store-n-H5RvRn.js → store-_hKtW1V3.js} +2 -2
- package/dist/assets/{textarea-ClPlugKE.js → textarea-CN-vZS5F.js} +1 -1
- package/dist/assets/{tooltip-D-1Pgmq1.js → tooltip-uzT2SsYJ.js} +1 -1
- package/dist/assets/{uk-BNyyigDo.js → uk-CXna2F44.js} +1 -1
- package/dist/assets/{useAppFontEffects-8UJ5f0dG.js → useAppFontEffects-BlstuBSz.js} +7 -7
- package/dist/assets/{useDesktopWindowControlsLayout-B_hNd3pD.js → useDesktopWindowControlsLayout-BhS1NmBt.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-DISvoVLe.js → useEffectiveDirectory-CiR2SqiL.js} +1 -1
- package/dist/assets/{useMobileNavigationStore-BE_8Q8WJ.js → useMobileNavigationStore-BRqifj9r.js} +7 -7
- package/dist/assets/{useWorkerHighlightedLines-BFygybD8.js → useWorkerHighlightedLines-Jea_E56b.js} +1 -1
- package/dist/assets/{zh-CN-CNWzMPgL.js → zh-CN-C0S7hK8T.js} +1 -1
- package/dist/assets/{zh-TW-DrZ9S1yn.js → zh-TW-CVG-Rqd-.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/mini-chat.html +2 -2
- package/dist/mobile.html +2 -2
- package/package.json +1 -1
- package/server/lib/config-sync/DOCUMENTATION.md +33 -11
- package/server/lib/config-sync/config-sync.test.js +11 -11
- package/server/lib/config-sync/constants.js +0 -3
- package/server/lib/config-sync/contract.js +8 -8
- package/server/lib/config-sync/credentials.js +101 -0
- package/server/lib/config-sync/credentials.test.js +98 -0
- package/server/lib/config-sync/engine.js +17 -11
- package/server/lib/config-sync/index.js +0 -3
- package/server/lib/config-sync/local-backup.js +0 -13
- package/server/lib/config-sync/magpie.js +207 -0
- package/server/lib/config-sync/magpie.test.js +165 -0
- package/server/lib/config-sync/plan.js +32 -37
- package/server/lib/config-sync/receiver.js +28 -25
- package/server/lib/config-sync/routes.js +23 -2
- package/server/lib/config-sync/routes.test.js +64 -5
- package/server/lib/config-sync/scripts.js +2 -30
- package/server/lib/config-sync/selections.js +14 -10
- package/server/lib/opencode/DOCUMENTATION.md +2 -1
- package/server/lib/opencode/auth.d.ts +1 -0
- package/server/lib/opencode/auth.js +1 -1
- package/dist/assets/MarkdownRenderer-YbG78e0Y.js +0 -2
- package/dist/assets/MobileTabPageHeader-D7r6pKco.js +0 -2
- package/dist/assets/speechSettingsQueries-Bp5LgcR7.js +0 -4
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-
|
|
555
|
+
<script type="module" crossorigin src="/assets/main-B3grbXe8.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-
|
|
575
|
+
<link rel="modulepreload" crossorigin href="/assets/runtimeConfig-GAVzvQGc.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">
|
package/dist/mini-chat.html
CHANGED
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
}
|
|
75
75
|
</style>
|
|
76
76
|
|
|
77
|
-
<script type="module" crossorigin src="/assets/miniChat-
|
|
77
|
+
<script type="module" crossorigin src="/assets/miniChat-ChSEmnwp.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-
|
|
97
|
+
<link rel="modulepreload" crossorigin href="/assets/runtimeConfig-GAVzvQGc.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-
|
|
66
|
+
<script type="module" crossorigin src="/assets/mobile-CrKlS4SH.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-
|
|
86
|
+
<link rel="modulepreload" crossorigin href="/assets/runtimeConfig-GAVzvQGc.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
|
@@ -17,17 +17,19 @@ Own the direction-agnostic OpenCode config sync contract: allowlist planning on
|
|
|
17
17
|
| `local-backup.js` | Local (pull destination) generational backup + tar extract |
|
|
18
18
|
| `tar.js` | `collectLocalTarBuffer` (buffered) |
|
|
19
19
|
| `engine.js` | `applyConfigSyncPlan` orchestration for **push** (prepare → putTar* → finalize) |
|
|
20
|
+
| `credentials.js` | V2 credential API transfer, validation, account merge and in-memory rollback |
|
|
21
|
+
| `magpie.js` | Host-local Magpie discovery, verified CLI provisioning, native backup/restore |
|
|
20
22
|
| `target-id.js` | `ssh:<instanceId>` namespace helper |
|
|
21
23
|
|
|
22
24
|
## Contract shape
|
|
23
25
|
|
|
24
|
-
- **SyncTarget**: `{ id, kind, capabilities: { posixShell, tarExtract,
|
|
26
|
+
- **SyncTarget**: `{ id, kind, capabilities: { posixShell, tarExtract, providerCredentialsWrite } }`
|
|
25
27
|
- **TargetExecutor**: `{ probe(plan), prepare(plan, { syncRunId }), putTar({ kind, payload }), finalize(plan, { syncRunId }) }`
|
|
26
28
|
- `payload`: `Buffer | Uint8Array` — all executors currently buffer; reintroduce streaming only when a transport needs it.
|
|
27
|
-
- **Plan** (source-computed): `{ direction: 'push' | 'pull', syncRunId?, sourceTargetId?, targetId?, files, directories, agentsRoot,
|
|
28
|
-
- **Selections**: `{ fileGroups: boolean[], singleFiles: boolean[], directories: boolean[], agentsRoot: boolean,
|
|
29
|
+
- **Plan** (source-computed): `{ direction: 'push' | 'pull', syncRunId?, sourceTargetId?, targetId?, files, directories, agentsRoot, providerCredentials, deletes, totalBytes, selections? }`
|
|
30
|
+
- **Selections**: `{ fileGroups: boolean[], singleFiles: boolean[], directories: boolean[], agentsRoot: boolean, providerCredentials: boolean, magpie?: boolean }`
|
|
29
31
|
- Preview and apply must share one selections snapshot so the confirmed scope cannot drift.
|
|
30
|
-
- `
|
|
32
|
+
- `providerCredentials` is the selection/metadata key; the UI calls it **Provider keys**. It defaults unchecked and contains only `{ bytes }`, never credential values or a filesystem path. The protocol has no legacy credential-file fields, aliases, archive kinds, probe markers or backup roots.
|
|
31
33
|
|
|
32
34
|
## Direction flow
|
|
33
35
|
|
|
@@ -45,11 +47,12 @@ Switching direction in the wizard **must** discard the previous preview and re-c
|
|
|
45
47
|
at every depth, including followed symlinks, matching the inventory walk. Keep
|
|
46
48
|
these exclusions in `OPENCODE_CONFIG_SYNC_TAR_EXCLUDES`; bypassing the shared
|
|
47
49
|
archive builder can silently transfer dependency trees omitted by the preview.
|
|
48
|
-
- Managed SSH readiness is gated by `posixShell` (and `tarExtract` / `
|
|
50
|
+
- Managed SSH readiness is gated by `posixShell` (and `tarExtract` / `providerCredentialsWrite` as needed).
|
|
49
51
|
- Prepare (remote or local) writes generational backups under `<backupRoot>/<syncRunId>/` and prunes older than `OPENCODE_CONFIG_SYNC_BACKUP_GENERATIONS`. Failed runs keep their scene until pruned.
|
|
50
52
|
- Finalize confirms the run's backup directory exists.
|
|
51
53
|
- Run records (Electron `sync-run-store`) persist `direction` alongside `syncRunId` / summary; they are not stored in `settings.json`.
|
|
52
|
-
-
|
|
54
|
+
- **Provider keys:** read/write the active OpenCode service through the official V2 credential SDK. API keys and OAuth accounts retain IDs, labels, metadata and active selection. Matching IDs are replaced; other destination accounts remain. No `auth.json`, database copying, environment-key discovery, or credential backups on disk. Failed imports attempt in-memory rollback and report incomplete rollback explicitly. Already-applied config/skill files remain when credentials fail; retry is idempotent for credentials. Process termination cannot guarantee rollback.
|
|
55
|
+
- Credential API failures reject rather than becoming empty inventory. Empty successful inventory is `null`. Preview/run records contain metadata only. Transfer envelopes are versioned `opencode-credentials-v1`; only the current protocol is supported.
|
|
53
56
|
|
|
54
57
|
## HTTP receive protocol (direct / later relay)
|
|
55
58
|
|
|
@@ -59,15 +62,34 @@ Routes under `/api/openchamber/config-sync/*` (global `/api` auth gate: session
|
|
|
59
62
|
|--------|------|------|
|
|
60
63
|
| POST | `/probe` | Inventory + existence vs plan |
|
|
61
64
|
| POST | `/prepare` | Generational local backup + deletes; one inflight `syncRunId` |
|
|
62
|
-
| PUT | `/put/:kind?syncRunId=` |
|
|
63
|
-
| GET | `/download/:kind` |
|
|
65
|
+
| PUT | `/put/:kind?syncRunId=` | tar.gz for `config` / `agents`; versioned credential JSON bytes for selected `credentials` |
|
|
66
|
+
| GET | `/download/:kind` | Matching binary payload; `credentials` is never cached |
|
|
67
|
+
| GET | `/credentials/inventory` | Current-service credential byte count, without secrets |
|
|
68
|
+
| PUT | `/credentials` | Bounded credential import for local pull and SSH-forwarded transfer |
|
|
69
|
+
| 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 |
|
|
64
71
|
| POST | `/finalize` | Confirm backup generation + success receipt |
|
|
65
72
|
| POST | `/abort` | Clear inflight |
|
|
66
73
|
|
|
67
|
-
`
|
|
74
|
+
`credentials` put requires a selected plan; finalize rejects a missing credential transfer. Concurrent prepare → `sync_in_progress`; credential imports are separately serialized by rejection. Credential routes use the existing global authenticated API gate and sanitize upstream errors.
|
|
68
75
|
|
|
69
76
|
## Composition
|
|
70
77
|
|
|
71
|
-
- Electron SSH: `TargetExecutor` push + inventory/tar pull.
|
|
78
|
+
- Electron SSH: `TargetExecutor` push + inventory/tar pull for files; credentials use authenticated HTTP through the established SSH forward.
|
|
72
79
|
- Electron direct hosts: `direct-config-sync.mjs` calls these HTTP routes.
|
|
73
|
-
- **Relay hosts (ticket 06):** UI opens an E2EE tunnel (`packages/ui/src/lib/relay/relay-config-sync.ts`) and calls the same HTTP routes via `tunnel.fetch`. Target id is `relay:<serverId>` (signing-key fingerprint). Preview/apply refresh candidates and refuse on `serverId` mismatch (`relay_identity_changed`) with no fallback identity.
|
|
80
|
+
- **Relay hosts (ticket 06):** UI opens an E2EE tunnel (`packages/ui/src/lib/relay/relay-config-sync.ts`) and calls the same HTTP routes via `tunnel.fetch`. Target id is `relay:<serverId>` (signing-key fingerprint). Preview/apply refresh candidates and refuse on `serverId` mismatch (`relay_identity_changed`) with no fallback identity. Only explicit sync transports carry credential payloads; ordinary provider UI never receives them.
|
|
81
|
+
- Desktop local credential operations go through the local host HTTP routes, resolving its URL/token at call time. This also works when the active backend is an OTA module distinct from installer-owned config-sync code. Web/mobile hosts can receive through the same server; initiating the two-instance wizard remains desktop-only, including for VS Code.
|
|
82
|
+
|
|
83
|
+
## Magpie discovery and transfer
|
|
84
|
+
|
|
85
|
+
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
|
+
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
|
+
|
|
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
|
+
|
|
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
|
+
|
|
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
|
+
|
|
95
|
+
`/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.
|
|
@@ -92,8 +92,8 @@ describe('config-sync contract', () => {
|
|
|
92
92
|
await fsp.writeFile(path.join(configDir, 'AGENTS.md'), 'hi\n');
|
|
93
93
|
await fsp.writeFile(path.join(configDir, 'skills', 'a.md'), 'a\n');
|
|
94
94
|
|
|
95
|
-
const full = planOpenCodeConfigSync(home, { direction: SYNC_DIRECTION_PUSH,
|
|
96
|
-
const selections = buildDefaultSyncSelections({
|
|
95
|
+
const full = planOpenCodeConfigSync(home, { direction: SYNC_DIRECTION_PUSH, includeProviderCredentials: false });
|
|
96
|
+
const selections = buildDefaultSyncSelections({ includeProviderCredentials: false });
|
|
97
97
|
selections.fileGroups = selections.fileGroups.map((_, index) => index === 0);
|
|
98
98
|
selections.singleFiles = selections.singleFiles.map(() => false);
|
|
99
99
|
selections.directories = selections.directories.map((__, index) => index === 2); // skills
|
|
@@ -103,20 +103,20 @@ describe('config-sync contract', () => {
|
|
|
103
103
|
expect(filtered.files.map((entry) => entry.path)).toEqual(['opencode.jsonc']);
|
|
104
104
|
expect(filtered.directories.map((entry) => entry.path)).toEqual(['skills']);
|
|
105
105
|
expect(filtered.agentsRoot).toBeNull();
|
|
106
|
-
expect(filtered.
|
|
106
|
+
expect(filtered.providerCredentials).toBeNull();
|
|
107
107
|
|
|
108
108
|
const inventoryPlan = planOpenCodeConfigSyncFromInventory({
|
|
109
109
|
files: [{ path: 'opencode.jsonc', bytes: 3 }],
|
|
110
110
|
directories: [{ path: 'skills', fileCount: 1, bytes: 2 }],
|
|
111
111
|
agentsRoot: null,
|
|
112
|
-
|
|
112
|
+
providerCredentials: { bytes: 9 },
|
|
113
113
|
}, {
|
|
114
114
|
direction: SYNC_DIRECTION_PULL,
|
|
115
|
-
selections: { ...selections,
|
|
116
|
-
|
|
115
|
+
selections: { ...selections, providerCredentials: true },
|
|
116
|
+
includeProviderCredentials: true,
|
|
117
117
|
});
|
|
118
118
|
expect(inventoryPlan.direction).toBe('pull');
|
|
119
|
-
expect(inventoryPlan.
|
|
119
|
+
expect(inventoryPlan.providerCredentials).toEqual({ bytes: 9 });
|
|
120
120
|
expect(inventoryPlan.files.map((entry) => entry.path)).toEqual(['opencode.jsonc']);
|
|
121
121
|
});
|
|
122
122
|
});
|
|
@@ -132,7 +132,7 @@ describe('local pull destination backups', () => {
|
|
|
132
132
|
directories: [],
|
|
133
133
|
deletes: ['config.json', 'opencode.json'],
|
|
134
134
|
agentsRoot: null,
|
|
135
|
-
|
|
135
|
+
providerCredentials: null,
|
|
136
136
|
};
|
|
137
137
|
|
|
138
138
|
await prepareLocalSyncDestination(home, plan, { syncRunId: 'pull-01', generations: 2 });
|
|
@@ -206,7 +206,7 @@ describe('remote inventory script', () => {
|
|
|
206
206
|
await fsp.writeFile(path.join(skill, '.venv-asc', 'lib', 'binary.so'), 'x'.repeat(4096));
|
|
207
207
|
await fsp.writeFile(path.join(skill, 'SKILL.md'), 'keep');
|
|
208
208
|
}
|
|
209
|
-
const local = planOpenCodeConfigSync(home, {
|
|
209
|
+
const local = planOpenCodeConfigSync(home, { includeProviderCredentials: false });
|
|
210
210
|
expect(local.agentsRoot).toEqual({ fileCount: 1, bytes: 4 });
|
|
211
211
|
expect(local.directories.find((entry) => entry.path === 'skills')).toMatchObject({ fileCount: 1, bytes: 4 });
|
|
212
212
|
const stdout = execFileSync('sh', ['-c', buildRemoteSyncInventoryScript()], {
|
|
@@ -241,7 +241,7 @@ describe('remote inventory script', () => {
|
|
|
241
241
|
expect(files).toContain('AGENTS.md');
|
|
242
242
|
const directories = inventory.directories.map((entry) => entry.path);
|
|
243
243
|
expect(directories).toContain('agents');
|
|
244
|
-
expect(inventory
|
|
244
|
+
expect(inventory).not.toHaveProperty('providerCredentials');
|
|
245
245
|
for (const entry of inventory.files) {
|
|
246
246
|
expect(typeof entry.path).toBe('string');
|
|
247
247
|
expect(typeof entry.bytes).toBe('number');
|
|
@@ -263,6 +263,6 @@ describe('remote inventory script', () => {
|
|
|
263
263
|
expect(inventory.files).toEqual([]);
|
|
264
264
|
expect(inventory.directories).toEqual([]);
|
|
265
265
|
expect(inventory.agentsRoot).toBeNull();
|
|
266
|
-
expect(inventory
|
|
266
|
+
expect(inventory).not.toHaveProperty('providerCredentials');
|
|
267
267
|
});
|
|
268
268
|
});
|
|
@@ -21,11 +21,8 @@ export const OPENCODE_CONFIG_SYNC_ALLOWLIST = {
|
|
|
21
21
|
export const OPENCODE_CONFIG_SYNC_BACKUP_DIR = '.openchamber.sync-backup';
|
|
22
22
|
/** Backup root for `$HOME/.agents` (outside `~/.agents` so rm -rf does not eat it). */
|
|
23
23
|
export const OPENCODE_AGENTS_SYNC_BACKUP_DIR = '.openchamber.sync-backup-agents';
|
|
24
|
-
/** Backup root for provider `auth.json` (outside `~/.local/share`). */
|
|
25
|
-
export const OPENCODE_AUTH_SYNC_BACKUP_DIR = '.openchamber.sync-backup-auth';
|
|
26
24
|
|
|
27
25
|
export const OPENCODE_AGENTS_ROOT_PROBE_MARKER = '__AGENTS_ROOT__';
|
|
28
|
-
export const OPENCODE_AUTH_FILE_PROBE_MARKER = '__AUTH_FILE__';
|
|
29
26
|
|
|
30
27
|
export const OPENCODE_CONFIG_SYNC_MAX_BYTES = 512 * 1024 * 1024;
|
|
31
28
|
export const OPENCODE_CONFIG_SYNC_MAX_FILES = 20000;
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
* @typedef {{
|
|
9
9
|
* posixShell: boolean,
|
|
10
10
|
* tarExtract: boolean,
|
|
11
|
-
*
|
|
11
|
+
* providerCredentialsWrite: boolean,
|
|
12
12
|
* ocHttp?: boolean,
|
|
13
13
|
* }} SyncTargetCapabilities
|
|
14
14
|
*
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
* @typedef {{
|
|
22
22
|
* remoteExisting: string[],
|
|
23
23
|
* remoteAgentsRootExists: boolean,
|
|
24
|
-
*
|
|
24
|
+
* remoteProviderCredentialsExists: boolean,
|
|
25
25
|
* }} SyncProbeResult
|
|
26
26
|
*
|
|
27
27
|
* Payload for putTar. Callers always hand a fully buffered archive
|
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
* @typedef {{
|
|
32
32
|
* probe: (plan: object) => Promise<SyncProbeResult>,
|
|
33
33
|
* prepare: (plan: object, ctx: { syncRunId: string }) => Promise<void>,
|
|
34
|
-
* putTar: (args: { kind: 'config' | 'agents' | '
|
|
34
|
+
* putTar: (args: { kind: 'config' | 'agents' | 'credentials' | 'magpie', payload: SyncTarPayload }) => Promise<void>,
|
|
35
35
|
* finalize: (plan: object, ctx: { syncRunId: string }) => Promise<{ ok: true }>,
|
|
36
36
|
* }} TargetExecutor
|
|
37
37
|
*/
|
|
@@ -39,34 +39,34 @@ import {
|
|
|
39
39
|
export const EMPTY_SYNC_CAPABILITIES = Object.freeze({
|
|
40
40
|
posixShell: false,
|
|
41
41
|
tarExtract: false,
|
|
42
|
-
|
|
42
|
+
providerCredentialsWrite: false,
|
|
43
43
|
ocHttp: false,
|
|
44
44
|
});
|
|
45
45
|
|
|
46
46
|
export const MANAGED_SSH_SYNC_CAPABILITIES = Object.freeze({
|
|
47
47
|
posixShell: true,
|
|
48
48
|
tarExtract: true,
|
|
49
|
-
|
|
49
|
+
providerCredentialsWrite: true,
|
|
50
50
|
ocHttp: false,
|
|
51
51
|
});
|
|
52
52
|
|
|
53
53
|
export const DIRECT_HOST_SYNC_CAPABILITIES = Object.freeze({
|
|
54
54
|
posixShell: false,
|
|
55
55
|
tarExtract: false,
|
|
56
|
-
|
|
56
|
+
providerCredentialsWrite: true,
|
|
57
57
|
ocHttp: true,
|
|
58
58
|
});
|
|
59
59
|
|
|
60
60
|
export const RELAY_HOST_SYNC_CAPABILITIES = Object.freeze({
|
|
61
61
|
posixShell: false,
|
|
62
62
|
tarExtract: false,
|
|
63
|
-
|
|
63
|
+
providerCredentialsWrite: true,
|
|
64
64
|
ocHttp: true,
|
|
65
65
|
});
|
|
66
66
|
|
|
67
67
|
/**
|
|
68
68
|
* Build a namespaced SyncTarget for an SSH instance.
|
|
69
|
-
* Managed remotes expose posixShell/tarExtract/
|
|
69
|
+
* Managed remotes expose posixShell/tarExtract/providerCredentialsWrite; others expose none
|
|
70
70
|
* (semantic equivalent of the former mode==='managed' hard gate).
|
|
71
71
|
*
|
|
72
72
|
* @param {string} instanceId
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
2
|
+
import { currentClient } from '../opencode/auth.js';
|
|
3
|
+
|
|
4
|
+
const invalid = () => new Error('Invalid provider credentials sync payload');
|
|
5
|
+
const record = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
6
|
+
|
|
7
|
+
function validate(entries) {
|
|
8
|
+
if (!Array.isArray(entries)) throw invalid();
|
|
9
|
+
const ids = new Set();
|
|
10
|
+
const active = new Set();
|
|
11
|
+
for (const entry of entries) {
|
|
12
|
+
if (!record(entry) || typeof entry.id !== 'string' || !entry.id || ids.has(entry.id)
|
|
13
|
+
|| typeof entry.integrationID !== 'string' || !entry.integrationID
|
|
14
|
+
|| typeof entry.label !== 'string' || typeof entry.active !== 'boolean' || !record(entry.value)) throw invalid();
|
|
15
|
+
ids.add(entry.id);
|
|
16
|
+
if (entry.active && active.has(entry.integrationID)) throw invalid();
|
|
17
|
+
if (entry.active) active.add(entry.integrationID);
|
|
18
|
+
const value = entry.value;
|
|
19
|
+
if (value.type === 'key') {
|
|
20
|
+
if (typeof value.key !== 'string') throw invalid();
|
|
21
|
+
} else if (value.type === 'oauth') {
|
|
22
|
+
if (typeof value.methodID !== 'string' || typeof value.access !== 'string'
|
|
23
|
+
|| typeof value.refresh !== 'string' || !Number.isInteger(value.expires) || value.expires < 0) throw invalid();
|
|
24
|
+
} else if (value.type === 'external') {
|
|
25
|
+
if (typeof value.methodID !== 'string') throw invalid();
|
|
26
|
+
} else throw invalid();
|
|
27
|
+
if (value.metadata !== undefined && !record(value.metadata)) throw invalid();
|
|
28
|
+
}
|
|
29
|
+
return entries;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Keep secrets in memory. Preview and run records receive only byte counts.
|
|
33
|
+
export function createCredentialSync(resolveClient = currentClient) {
|
|
34
|
+
let importing = false;
|
|
35
|
+
const request = () => ({ signal: AbortSignal.timeout(15000) });
|
|
36
|
+
const read = async (client) => validate(await client.credential.list(request()));
|
|
37
|
+
const encode = (entries) => Buffer.from(JSON.stringify({ format: 'opencode-credentials-v1', entries }));
|
|
38
|
+
return {
|
|
39
|
+
async inventory() {
|
|
40
|
+
try {
|
|
41
|
+
const entries = await read(resolveClient());
|
|
42
|
+
return entries.length ? { bytes: encode(entries).byteLength } : null;
|
|
43
|
+
} catch { throw new Error('Unable to read provider credentials'); }
|
|
44
|
+
},
|
|
45
|
+
async download() {
|
|
46
|
+
try { return encode(await read(resolveClient())); }
|
|
47
|
+
catch { throw new Error('Unable to read provider credentials'); }
|
|
48
|
+
},
|
|
49
|
+
async import(payload) {
|
|
50
|
+
if (importing) throw new Error('Provider credentials sync already in progress');
|
|
51
|
+
importing = true;
|
|
52
|
+
try {
|
|
53
|
+
let entries;
|
|
54
|
+
try {
|
|
55
|
+
const decoded = JSON.parse(Buffer.from(payload).toString('utf8'));
|
|
56
|
+
if (decoded.format !== 'opencode-credentials-v1') throw invalid();
|
|
57
|
+
entries = validate(decoded.entries);
|
|
58
|
+
} catch { throw invalid(); }
|
|
59
|
+
const client = resolveClient();
|
|
60
|
+
let before;
|
|
61
|
+
try { before = await read(client); }
|
|
62
|
+
catch { throw new Error('Unable to read destination provider credentials'); }
|
|
63
|
+
const touched = [];
|
|
64
|
+
const create = (entry) => client.credential.create({
|
|
65
|
+
id: entry.id, integrationID: entry.integrationID, label: entry.label,
|
|
66
|
+
value: entry.value, activate: false,
|
|
67
|
+
}, request());
|
|
68
|
+
try {
|
|
69
|
+
for (const entry of entries) {
|
|
70
|
+
const previous = before.find((item) => item.id === entry.id);
|
|
71
|
+
if (previous && previous.integrationID !== entry.integrationID) throw invalid();
|
|
72
|
+
if (previous && previous.label === entry.label && isDeepStrictEqual(previous.value, entry.value)) continue;
|
|
73
|
+
touched.push({ entry, previous });
|
|
74
|
+
if (previous) await client.credential.remove({ credentialID: entry.id }, request());
|
|
75
|
+
await create(entry);
|
|
76
|
+
}
|
|
77
|
+
for (const entry of entries.filter((item) => item.active)) {
|
|
78
|
+
await client.credential.activate({ credentialID: entry.id }, request());
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
let restored = true;
|
|
82
|
+
for (const { entry, previous } of touched.reverse()) {
|
|
83
|
+
try {
|
|
84
|
+
const current = await read(client);
|
|
85
|
+
if (current.some((item) => item.id === entry.id)) await client.credential.remove({ credentialID: entry.id }, request());
|
|
86
|
+
if (previous) await create(previous);
|
|
87
|
+
} catch { restored = false; }
|
|
88
|
+
}
|
|
89
|
+
for (const entry of before.filter((item) => item.active && entries.some((source) => source.integrationID === item.integrationID))) {
|
|
90
|
+
try { await client.credential.activate({ credentialID: entry.id }, request()); }
|
|
91
|
+
catch { restored = false; }
|
|
92
|
+
}
|
|
93
|
+
throw new Error(restored
|
|
94
|
+
? 'Provider credentials sync failed; previous credentials restored'
|
|
95
|
+
: 'Provider credentials sync failed; rollback incomplete. Retry the sync.');
|
|
96
|
+
}
|
|
97
|
+
return { ok: true };
|
|
98
|
+
} finally { importing = false; }
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { OpenCode } from '@opencode/client';
|
|
3
|
+
import { createCredentialSync } from './credentials.js';
|
|
4
|
+
|
|
5
|
+
const key = (id, integrationID = 'openai', active = true) => ({
|
|
6
|
+
id, integrationID, label: id, active, value: { type: 'key', key: `fixture-${id}` },
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
// Exercise the installed SDK's real HTTP serialization, not a second SDK shape.
|
|
10
|
+
function service(initial = []) {
|
|
11
|
+
let entries = structuredClone(initial);
|
|
12
|
+
let failCreate = false;
|
|
13
|
+
let failList = false;
|
|
14
|
+
const client = OpenCode.make({ baseUrl: 'http://credentials.invalid', fetch: async (input, init) => {
|
|
15
|
+
const req = new Request(input, init);
|
|
16
|
+
const pathname = new URL(req.url).pathname;
|
|
17
|
+
const parts = pathname.split('/');
|
|
18
|
+
const id = parts[3];
|
|
19
|
+
if (req.method === 'GET') {
|
|
20
|
+
if (failList) return new Response('fixture-private-upstream-error', { status: 500 });
|
|
21
|
+
return Response.json({ data: entries });
|
|
22
|
+
}
|
|
23
|
+
if (req.method === 'DELETE') {
|
|
24
|
+
entries = entries.filter((entry) => entry.id !== id);
|
|
25
|
+
return new Response(null, { status: 204 });
|
|
26
|
+
}
|
|
27
|
+
if (pathname.endsWith('/activate')) {
|
|
28
|
+
const selected = entries.find((entry) => entry.id === id);
|
|
29
|
+
for (const entry of entries) if (entry.integrationID === selected.integrationID) entry.active = entry.id === id;
|
|
30
|
+
return new Response(null, { status: 204 });
|
|
31
|
+
}
|
|
32
|
+
const body = await req.json();
|
|
33
|
+
expect(body.integrationID).toBeTypeOf('string');
|
|
34
|
+
expect(body.value).toBeTruthy();
|
|
35
|
+
if (failCreate) {
|
|
36
|
+
failCreate = false;
|
|
37
|
+
return new Response('fixture-private-upstream-error', { status: 400 });
|
|
38
|
+
}
|
|
39
|
+
if (entries.some((entry) => entry.id === body.id)) return new Response(null, { status: 409 });
|
|
40
|
+
const { activate, ...value } = body;
|
|
41
|
+
const entry = { ...value, active: activate || !entries.some((item) => item.integrationID === body.integrationID && item.active) };
|
|
42
|
+
entries.push(entry);
|
|
43
|
+
return Response.json({ data: entry });
|
|
44
|
+
} });
|
|
45
|
+
return {
|
|
46
|
+
sync: createCredentialSync(() => client),
|
|
47
|
+
entries: () => structuredClone(entries),
|
|
48
|
+
failNextCreate: () => { failCreate = true; },
|
|
49
|
+
failReads: () => { failList = true; },
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('provider credential sync', () => {
|
|
54
|
+
it('round-trips keys, OAuth accounts, metadata and active selection without deleting destination accounts', async () => {
|
|
55
|
+
const oauth = { ...key('oauth'), value: {
|
|
56
|
+
type: 'oauth', methodID: 'oauth', access: 'fixture-access', refresh: 'fixture-refresh', expires: 1000,
|
|
57
|
+
metadata: { accountID: 'fixture-account' },
|
|
58
|
+
} };
|
|
59
|
+
const source = service([key('key', 'openai', false), oauth]);
|
|
60
|
+
const other = key('other', 'anthropic');
|
|
61
|
+
const destination = service([other]);
|
|
62
|
+
const payload = await source.sync.download();
|
|
63
|
+
expect(await source.sync.inventory()).toEqual({ bytes: payload.byteLength });
|
|
64
|
+
expect(JSON.stringify(await source.sync.inventory())).not.toContain('fixture-');
|
|
65
|
+
await destination.sync.import(payload);
|
|
66
|
+
expect(destination.entries()).toEqual([other, key('key', 'openai', false), oauth]);
|
|
67
|
+
await destination.sync.import(payload);
|
|
68
|
+
expect(destination.entries()).toHaveLength(3);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('updates matching IDs and rolls back failed replacement without leaking upstream errors', async () => {
|
|
72
|
+
const original = key('same');
|
|
73
|
+
const updated = { ...original, value: { type: 'key', key: 'fixture-updated' } };
|
|
74
|
+
const source = service([updated]);
|
|
75
|
+
const target = service([original, key('other', 'anthropic')]);
|
|
76
|
+
target.failNextCreate();
|
|
77
|
+
await expect(target.sync.import(await source.sync.download())).rejects.toThrow('previous credentials restored');
|
|
78
|
+
expect(target.entries()).toContainEqual(original);
|
|
79
|
+
expect(target.entries()).toContainEqual(key('other', 'anthropic'));
|
|
80
|
+
await target.sync.import(await source.sync.download());
|
|
81
|
+
expect(target.entries()).toContainEqual(updated);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('rejects legacy archives and malformed input before changing destination state', async () => {
|
|
85
|
+
const target = service([key('existing')]);
|
|
86
|
+
for (const payload of ['legacy tar', '{"openai":{"key":"fixture"}}', JSON.stringify({ format: 'opencode-credentials-v1', entries: [{}] })]) {
|
|
87
|
+
await expect(target.sync.import(Buffer.from(payload))).rejects.toThrow('Invalid provider credentials');
|
|
88
|
+
expect(target.entries()).toEqual([key('existing')]);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('does not turn unavailable credentials into empty inventory', async () => {
|
|
93
|
+
const source = service();
|
|
94
|
+
expect(await source.sync.inventory()).toBeNull();
|
|
95
|
+
source.failReads();
|
|
96
|
+
await expect(source.sync.inventory()).rejects.toThrow('Unable to read provider credentials');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -13,6 +13,8 @@ import { collectLocalTarBuffer } from './tar.js';
|
|
|
13
13
|
* syncRunId: string,
|
|
14
14
|
* sourceHomedir: string,
|
|
15
15
|
* collectTar?: typeof collectLocalTarBuffer,
|
|
16
|
+
* collectCredentials?: () => Promise<Buffer>,
|
|
17
|
+
* collectMagpie?: () => Promise<Buffer>,
|
|
16
18
|
* }} args
|
|
17
19
|
*/
|
|
18
20
|
export const applyConfigSyncPlan = async ({
|
|
@@ -21,6 +23,8 @@ export const applyConfigSyncPlan = async ({
|
|
|
21
23
|
syncRunId,
|
|
22
24
|
sourceHomedir,
|
|
23
25
|
collectTar = collectLocalTarBuffer,
|
|
26
|
+
collectCredentials,
|
|
27
|
+
collectMagpie,
|
|
24
28
|
}) => {
|
|
25
29
|
assertPlanDirection(plan);
|
|
26
30
|
const home = String(sourceHomedir || '');
|
|
@@ -31,9 +35,9 @@ export const applyConfigSyncPlan = async ({
|
|
|
31
35
|
];
|
|
32
36
|
const hasConfigPayload = tarEntries.length > 0;
|
|
33
37
|
const hasAgentsPayload = Boolean(plan.agentsRoot);
|
|
34
|
-
const
|
|
38
|
+
const hasCredentialPayload = Boolean(plan.providerCredentials);
|
|
35
39
|
|
|
36
|
-
if (!hasConfigPayload && !hasAgentsPayload && !
|
|
40
|
+
if (!hasConfigPayload && !hasAgentsPayload && !hasCredentialPayload && !plan.magpie) {
|
|
37
41
|
// Nothing to upload — preserve the historical short-circuit (no remote prepare).
|
|
38
42
|
return {
|
|
39
43
|
ok: true,
|
|
@@ -42,7 +46,7 @@ export const applyConfigSyncPlan = async ({
|
|
|
42
46
|
deletes: plan.deletes.length,
|
|
43
47
|
totalBytes: plan.totalBytes,
|
|
44
48
|
agentsRoot: null,
|
|
45
|
-
|
|
49
|
+
providerCredentials: null,
|
|
46
50
|
plan,
|
|
47
51
|
};
|
|
48
52
|
}
|
|
@@ -54,10 +58,10 @@ export const applyConfigSyncPlan = async ({
|
|
|
54
58
|
const agentsTarBuffer = hasAgentsPayload
|
|
55
59
|
? await collectTar(['-h', '-czf', '-', '-C', home, '.agents'], { windowsHide })
|
|
56
60
|
: null;
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
? await collectTar(['-h', '-czf', '-', '-C', authShareDir, 'auth.json'], { windowsHide })
|
|
61
|
+
const credentialPayload = hasCredentialPayload
|
|
62
|
+
? await collectCredentials()
|
|
60
63
|
: null;
|
|
64
|
+
const magpiePayload = plan.magpie ? await collectMagpie() : null;
|
|
61
65
|
|
|
62
66
|
await executor.prepare(plan, { syncRunId });
|
|
63
67
|
|
|
@@ -67,12 +71,13 @@ export const applyConfigSyncPlan = async ({
|
|
|
67
71
|
if (agentsTarBuffer) {
|
|
68
72
|
await executor.putTar({ kind: 'agents', payload: agentsTarBuffer });
|
|
69
73
|
}
|
|
70
|
-
if (
|
|
71
|
-
//
|
|
72
|
-
await executor.putTar({ kind: '
|
|
74
|
+
if (credentialPayload) {
|
|
75
|
+
// Versioned credential payload; never a database or filesystem archive.
|
|
76
|
+
await executor.putTar({ kind: 'credentials', payload: credentialPayload });
|
|
73
77
|
}
|
|
78
|
+
if (magpiePayload) await executor.putTar({ kind: 'magpie', payload: magpiePayload });
|
|
74
79
|
|
|
75
|
-
await executor.finalize(plan, { syncRunId });
|
|
80
|
+
const receipt = await executor.finalize(plan, { syncRunId });
|
|
76
81
|
|
|
77
82
|
return {
|
|
78
83
|
ok: true,
|
|
@@ -81,8 +86,9 @@ export const applyConfigSyncPlan = async ({
|
|
|
81
86
|
deletes: plan.deletes.length,
|
|
82
87
|
totalBytes: plan.totalBytes,
|
|
83
88
|
agentsRoot: plan.agentsRoot ? { fileCount: plan.agentsRoot.fileCount } : null,
|
|
84
|
-
|
|
89
|
+
providerCredentials: plan.providerCredentials ? { bytes: plan.providerCredentials.bytes } : null,
|
|
85
90
|
plan,
|
|
91
|
+
...(receipt?.magpieWarnings?.length ? { magpieWarnings: receipt.magpieWarnings } : {}),
|
|
86
92
|
};
|
|
87
93
|
};
|
|
88
94
|
|
|
@@ -2,9 +2,7 @@ export {
|
|
|
2
2
|
OPENCODE_CONFIG_SYNC_ALLOWLIST,
|
|
3
3
|
OPENCODE_CONFIG_SYNC_BACKUP_DIR,
|
|
4
4
|
OPENCODE_AGENTS_SYNC_BACKUP_DIR,
|
|
5
|
-
OPENCODE_AUTH_SYNC_BACKUP_DIR,
|
|
6
5
|
OPENCODE_AGENTS_ROOT_PROBE_MARKER,
|
|
7
|
-
OPENCODE_AUTH_FILE_PROBE_MARKER,
|
|
8
6
|
OPENCODE_CONFIG_SYNC_MAX_BYTES,
|
|
9
7
|
OPENCODE_CONFIG_SYNC_MAX_FILES,
|
|
10
8
|
OPENCODE_CONFIG_SYNC_BACKUP_GENERATIONS,
|
|
@@ -42,7 +40,6 @@ export {
|
|
|
42
40
|
buildRemoteSyncInventoryScript,
|
|
43
41
|
buildRemoteConfigTarScript,
|
|
44
42
|
buildRemoteAgentsTarScript,
|
|
45
|
-
buildRemoteAuthTarScript,
|
|
46
43
|
sanitizeSyncRunIdForPath,
|
|
47
44
|
shellQuote,
|
|
48
45
|
} from './scripts.js';
|
|
@@ -5,7 +5,6 @@ import { spawn } from 'node:child_process';
|
|
|
5
5
|
|
|
6
6
|
import {
|
|
7
7
|
OPENCODE_AGENTS_SYNC_BACKUP_DIR,
|
|
8
|
-
OPENCODE_AUTH_SYNC_BACKUP_DIR,
|
|
9
8
|
OPENCODE_CONFIG_SYNC_ALLOWLIST,
|
|
10
9
|
OPENCODE_CONFIG_SYNC_BACKUP_DIR,
|
|
11
10
|
OPENCODE_CONFIG_SYNC_BACKUP_GENERATIONS,
|
|
@@ -108,18 +107,6 @@ export const prepareLocalSyncDestination = async (homedir, plan, options) => {
|
|
|
108
107
|
await fsp.rm(path.join(home, '.agents'), { recursive: true, force: true });
|
|
109
108
|
}
|
|
110
109
|
|
|
111
|
-
if (plan?.authFile) {
|
|
112
|
-
const authBackupRoot = path.join(home, OPENCODE_AUTH_SYNC_BACKUP_DIR);
|
|
113
|
-
const authRun = path.join(authBackupRoot, syncRunId);
|
|
114
|
-
await fsp.rm(authRun, { recursive: true, force: true });
|
|
115
|
-
await fsp.mkdir(authRun, { recursive: true });
|
|
116
|
-
await pruneGenerations(authBackupRoot, syncRunId, generations);
|
|
117
|
-
await copyPathIfExists(
|
|
118
|
-
path.join(home, '.local', 'share', 'opencode', 'auth.json'),
|
|
119
|
-
path.join(authRun, 'auth.json'),
|
|
120
|
-
);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
110
|
return { runBackup };
|
|
124
111
|
};
|
|
125
112
|
|